+39
-22
atproto/client/api_client.go
+39
-22
atproto/client/api_client.go
···
6
6
"encoding/json"
7
7
"fmt"
8
8
"net/http"
9
+
"net/url"
9
10
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
11
12
)
12
13
13
14
type APIClient struct {
14
-
HTTPClient *http.Client
15
+
// inner HTTP client
16
+
Client *http.Client
17
+
18
+
// host URL prefix: scheme, hostname, and port. This field is required.
15
19
Host string
20
+
21
+
// optional auth client "middleware"
16
22
Auth AuthMethod
17
-
DefaultHeaders map[string]string
23
+
24
+
// optional HTTP headers which will be included in all requests. Only a single value per key is included; request-level headers will override any client-level defaults.
25
+
DefaultHeaders http.Header
18
26
}
19
27
20
28
// High-level helper for simple JSON "Query" API calls.
21
29
//
22
30
// Does not work with all API endpoints. For more control, use the Do() method with APIRequest.
23
-
func (c *APIClient) Get(ctx context.Context, endpoint syntax.NSID, params map[string]string, out any) error {
24
-
hdr := map[string]string{
25
-
"Accept": "application/json",
31
+
func (c *APIClient) Get(ctx context.Context, endpoint syntax.NSID, params url.Values, out any) error {
32
+
hdr := map[string][]string{
33
+
"Accept": []string{"application/json"},
26
34
}
27
35
req := APIRequest{
28
-
HTTPVerb: "GET",
36
+
Method: http.MethodGet,
29
37
Endpoint: endpoint,
30
38
Body: nil,
31
39
QueryParams: params,
···
35
43
if err != nil {
36
44
return err
37
45
}
38
-
39
46
defer resp.Body.Close()
40
-
// TODO: duplicate error handling with Post()?
47
+
41
48
if !(resp.StatusCode >= 200 && resp.StatusCode < 300) {
42
49
var eb ErrorBody
43
50
if err := json.NewDecoder(resp.Body).Decode(&eb); err != nil {
···
56
63
return nil
57
64
}
58
65
59
-
// High-level helper for simple JSON-to-JSON "Procedure" API calls.
66
+
// High-level helper for simple JSON-to-JSON "Procedure" API calls, with no query params.
60
67
//
61
-
// Does not work with all API endpoints. For more control, use the Do() method with APIRequest.
68
+
// Does not work with all possible atproto API endpoints. For more control, use the Do() method with APIRequest.
62
69
func (c *APIClient) Post(ctx context.Context, endpoint syntax.NSID, body any, out any) error {
63
70
bodyJSON, err := json.Marshal(body)
64
71
if err != nil {
65
72
return err
66
73
}
67
-
hdr := map[string]string{
68
-
"Accept": "application/json",
69
-
"Content-Type": "application/json",
74
+
hdr := map[string][]string{
75
+
"Accept": []string{"application/json"},
76
+
"Content-Type": []string{"application/json"},
70
77
}
71
78
req := APIRequest{
72
-
HTTPVerb: "POST",
79
+
Method: http.MethodPost,
73
80
Endpoint: endpoint,
74
81
Body: bytes.NewReader(bodyJSON),
75
82
QueryParams: nil,
···
79
86
if err != nil {
80
87
return err
81
88
}
82
-
83
89
defer resp.Body.Close()
84
-
// TODO: duplicate error handling with Get()?
90
+
85
91
if !(resp.StatusCode >= 200 && resp.StatusCode < 300) {
86
92
var eb ErrorBody
87
93
if err := json.NewDecoder(resp.Body).Decode(&eb); err != nil {
···
101
107
}
102
108
103
109
// Full-power method for atproto API requests.
110
+
//
111
+
// NOTE: this does not currently parse error response JSON body, thought it might in the future.
104
112
func (c *APIClient) Do(ctx context.Context, req APIRequest) (*http.Response, error) {
105
113
httpReq, err := req.HTTPRequest(ctx, c.Host, c.DefaultHeaders)
106
114
if err != nil {
107
115
return nil, err
108
116
}
109
117
110
-
// TODO: thread-safe?
111
-
if c.HTTPClient == nil {
112
-
c.HTTPClient = http.DefaultClient
118
+
// NOTE: this updates the client object itself
119
+
if c.Client == nil {
120
+
c.Client = http.DefaultClient
113
121
}
114
122
115
123
var resp *http.Response
116
124
if c.Auth != nil {
117
-
resp, err = c.Auth.DoWithAuth(c.HTTPClient, httpReq)
125
+
resp, err = c.Auth.DoWithAuth(c.Client, httpReq)
118
126
} else {
119
-
resp, err = c.HTTPClient.Do(httpReq)
127
+
resp, err = c.Client.Do(httpReq)
120
128
}
121
129
if err != nil {
122
130
return nil, err
123
131
}
124
-
// TODO: handle some common response errors: rate-limits, 5xx, auth required, etc
125
132
return resp, nil
126
133
}
134
+
135
+
func NewPublicClient(host string) *APIClient {
136
+
return &APIClient{
137
+
Client: http.DefaultClient,
138
+
Host: host,
139
+
DefaultHeaders: map[string][]string{
140
+
"User-Agent": []string{"indigo-sdk"},
141
+
},
142
+
}
143
+
}
+42
-14
atproto/client/api_request.go
+42
-14
atproto/client/api_request.go
···
3
3
import (
4
4
"context"
5
5
"io"
6
+
"fmt"
6
7
"net/http"
7
8
"net/url"
8
9
9
10
"github.com/bluesky-social/indigo/atproto/syntax"
10
11
)
11
12
13
+
var (
14
+
// atproto API "Query" Lexicon method, which is HTTP GET. Not to be confused with proposed "HTTP QUERY" method.
15
+
MethodQuery = http.MethodGet
16
+
17
+
// atproto API "Procedure" Lexicon method, which is HTTP POST.
18
+
MethodProcedure = http.MethodPost
19
+
)
20
+
12
21
type APIRequest struct {
13
-
HTTPVerb string // TODO: type?
22
+
// HTTP method, eg "GET" (required)
23
+
Method string
24
+
25
+
// atproto API endpoint, as NSID (required)
14
26
Endpoint syntax.NSID
27
+
28
+
// optional request body. if this is provided, then 'Content-Type' header should be specified
15
29
Body io.Reader
16
-
QueryParams map[string]string // TODO: better type for this?
17
-
Headers map[string]string
30
+
31
+
// optional query parameters. These will be encoded as provided.
32
+
QueryParams url.Values
33
+
34
+
// optional HTTP headers. Only the first value will be included for each header key ("Set" behavior).
35
+
Headers http.Header
18
36
}
19
37
20
-
func (r *APIRequest) HTTPRequest(ctx context.Context, host string, headers map[string]string) (*http.Request, error) {
38
+
// Turns the API request in to an `http.Request`.
39
+
//
40
+
// `host` parameter should be a URL prefix: schema, hostname, port.
41
+
// `headers` parameters are treated as client-level defaults. Only a single value is allowed per key ("Set" behavior), and will be clobbered by any request-level header values.
42
+
func (r *APIRequest) HTTPRequest(ctx context.Context, host string, headers http.Header) (*http.Request, error) {
21
43
u, err := url.Parse(host)
22
44
if err != nil {
23
45
return nil, err
24
46
}
47
+
if u.Host == "" {
48
+
return nil, fmt.Errorf("empty hostname in host URL")
49
+
}
50
+
if u.Scheme == "" {
51
+
return nil, fmt.Errorf("empty scheme in host URL")
52
+
}
53
+
if r.Endpoint == "" {
54
+
return nil, fmt.Errorf("empty request endpoint")
55
+
}
25
56
u.Path = "/xrpc/" + r.Endpoint.String()
57
+
u.RawQuery = nil
26
58
if r.QueryParams != nil {
27
-
q := u.Query()
28
-
for k, v := range r.QueryParams {
29
-
q.Add(k, v)
30
-
}
31
-
u.RawQuery = q.Encode()
59
+
u.RawQuery = r.QueryParams.Encode()
32
60
}
33
-
httpReq, err := http.NewRequestWithContext(ctx, r.HTTPVerb, u.String(), r.Body)
61
+
httpReq, err := http.NewRequestWithContext(ctx, r.Method, u.String(), r.Body)
34
62
if err != nil {
35
63
return nil, err
36
64
}
37
65
38
66
// first set default headers...
39
67
if headers != nil {
40
-
for k, v := range headers {
41
-
httpReq.Header.Set(k, v)
68
+
for k := range headers {
69
+
httpReq.Header.Set(k, headers.Get(k))
42
70
}
43
71
}
44
72
45
73
// ... then request-specific take priority (overwrite)
46
74
if r.Headers != nil {
47
-
for k, v := range r.Headers {
48
-
httpReq.Header.Set(k, v)
75
+
for k := range r.Headers {
76
+
httpReq.Header.Set(k, headers.Get(k))
49
77
}
50
78
}
51
79