+1
atproto/client/admin_auth.go
+1
atproto/client/admin_auth.go
+20
-12
atproto/client/apiclient.go
+20
-12
atproto/client/apiclient.go
···
10
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
11
)
12
12
13
+
// Interface for auth implementations which can be used with [APIClient].
13
14
type AuthMethod interface {
14
-
// endpoint parameter is included for auth methods which need to include the NSID in authorization tokens
15
+
// Endpoint parameter is included for auth methods which need to include the NSID in authorization tokens
15
16
DoWithAuth(c *http.Client, req *http.Request, endpoint syntax.NSID) (*http.Response, error)
16
17
}
17
18
19
+
// General purpose client for atproto "XRPC" API endpoints.
18
20
type APIClient struct {
19
-
// inner HTTP client
21
+
// Inner HTTP client. May be customized after the overall [APIClient] struct is created; for example to set a default request timeout.
20
22
Client *http.Client
21
23
22
-
// host URL prefix: scheme, hostname, and port. This field is required.
24
+
// Host URL prefix: scheme, hostname, and port. This field is required.
23
25
Host string
24
26
25
-
// optional auth client "middleware"
27
+
// Optional auth client "middleware".
26
28
Auth AuthMethod
27
29
28
-
// 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.
30
+
// 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.
29
31
Headers http.Header
30
32
31
-
// optional authenticated account DID for this client. Does not change client behavior; this is included as a convenience for calling code, logging, etc.
33
+
// optional authenticated account DID for this client. Does not change client behavior; this field is included as a convenience for calling code, logging, etc.
32
34
AccountDID *syntax.DID
33
35
}
34
36
35
37
// Creates a simple APIClient for the provided host. This is appropriate for use with unauthenticated ("public") atproto API endpoints, or to use as a base client to add authentication.
36
38
//
37
-
// Uses the default stdlib http.Client, and sets a default User-Agent.
39
+
// Uses [http.DefaultClient], and sets a default User-Agent.
38
40
func NewAPIClient(host string) *APIClient {
39
41
return &APIClient{
40
42
Client: http.DefaultClient,
···
47
49
48
50
// High-level helper for simple JSON "Query" API calls.
49
51
//
50
-
// Does not work with all API endpoints. For more control, use the Do() method with APIRequest.
52
+
// This method automatically parses non-successful responses to [APIError].
53
+
//
54
+
// For Query endpoints which return non-JSON data, or other situations needing complete configuration of the request and response, use the [APIClient.Do] method.
51
55
func (c *APIClient) Get(ctx context.Context, endpoint syntax.NSID, params map[string]any, out any) error {
52
56
53
57
req := NewAPIRequest(http.MethodGet, endpoint, nil)
···
87
91
88
92
// High-level helper for simple JSON-to-JSON "Procedure" API calls, with no query params.
89
93
//
90
-
// Does not work with all possible atproto API endpoints. For more control, use the Do() method with APIRequest.
94
+
// This method automatically parses non-successful responses to [APIError].
95
+
//
96
+
// For Query endpoints which expect non-JSON request bodies; return non-JSON responses; direct use of [io.Reader] for the request body; or other situations needing complete configuration of the request and response, use the [APIClient.Do] method.
91
97
func (c *APIClient) Post(ctx context.Context, endpoint syntax.NSID, body any, out any) error {
92
98
bodyJSON, err := json.Marshal(body)
93
99
if err != nil {
···
124
130
125
131
// Full-featured method for atproto API requests.
126
132
//
127
-
// NOTE: this does not currently parse error response JSON body, thought it might in the future.
133
+
// TODO: this does not currently parse API error response JSON body to [APIError], thought it might in the future.
128
134
func (c *APIClient) Do(ctx context.Context, req *APIRequest) (*http.Response, error) {
129
135
130
136
if c.Client == nil {
···
150
156
151
157
// Returns a shallow copy of the APIClient with the provided service ref configured as a proxy header.
152
158
//
153
-
// To configure service proxying without creating a copy, simply set the "Atproto-Proxy" header.
159
+
// To configure service proxying without creating a copy, simply set the 'Atproto-Proxy' header.
154
160
func (c *APIClient) WithService(ref string) *APIClient {
155
161
hdr := c.Headers.Clone()
156
162
hdr.Set("Atproto-Proxy", ref)
···
164
170
return &out
165
171
}
166
172
167
-
// Configures labeler header (Atproto-Accept-Labelers) with the indicated "redact" level labelers, and regular labelers.
173
+
// Configures labeler header ('Atproto-Accept-Labelers') with the indicated "redact" level labelers, and regular labelers.
174
+
//
175
+
// Overwrites any existing client-level header value.
168
176
func (c *APIClient) SetLabelers(redact, other []syntax.DID) {
169
177
c.Headers.Set("Atproto-Accept-Labelers", encodeLabelerHeader(redact, other))
170
178
}
+6
-5
atproto/client/apirequest.go
+6
-5
atproto/client/apirequest.go
···
11
11
)
12
12
13
13
var (
14
-
// atproto API "Query" Lexicon method, which is HTTP GET. Not to be confused with proposed "HTTP QUERY" method.
14
+
// atproto API "Query" Lexicon method, which is HTTP GET. Not to be confused with the IETF draft "HTTP QUERY" method.
15
15
MethodQuery = http.MethodGet
16
16
17
17
// atproto API "Procedure" Lexicon method, which is HTTP POST.
···
28
28
// Optional request body (may be nil). If this is provided, then 'Content-Type' header should be specified
29
29
Body io.Reader
30
30
31
-
// Optional function to return new reader for request body; used for retries. strongly recommended if Body is defined. Body still needs to be defined, even if this function is provided.
31
+
// Optional function to return new reader for request body; used for retries. Strongly recommended if Body is defined. Body still needs to be defined, even if this function is provided.
32
32
GetBody func() (io.ReadCloser, error)
33
33
34
34
// Optional query parameters (field may be nil). These will be encoded as provided.
···
40
40
41
41
// Initializes a new request struct. Initializes Headers and QueryParams so they can be manipulated immediately.
42
42
//
43
-
// If body is provided (it can be nil), will try to turn it in to the most retry-able form (and wrap as io.ReadCloser).
43
+
// If body is provided (it can be nil), will try to turn it in to the most retry-able form (and wrap as [io.ReadCloser]).
44
44
func NewAPIRequest(method string, endpoint syntax.NSID, body io.Reader) *APIRequest {
45
45
req := APIRequest{
46
46
Method: method,
···
66
66
return &req
67
67
}
68
68
69
-
// Creates an `http.Request` for this API request.
69
+
// Creates an [http.Request] for this API request.
70
70
//
71
71
// `host` parameter should be a URL prefix: schema, hostname, port (required)
72
-
// `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. (optional; may be nil)
72
+
//
73
+
// `clientHeaders`, if provided, is 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. (optional; may be nil)
73
74
func (r *APIRequest) HTTPRequest(ctx context.Context, host string, clientHeaders http.Header) (*http.Request, error) {
74
75
u, err := url.Parse(host)
75
76
if err != nil {
+21
atproto/client/doc.go
+21
atproto/client/doc.go
···
1
+
/*
2
+
General-purpose client for atproto "XRPC" HTTP API endpoints.
3
+
4
+
[APIClient] wraps an [http.Client] and provides an ergonomic atproto-specific (but not Lexicon-specific) interface for "Query" (GET) and "Procedure" (POST) endpoints. It does not support "Event Stream" (WebSocket) endpoints. The client is expected to be used with a single host at a time, though it does have special support ([APIClient.WithService]) for proxied service requests when connected to a PDS host. The client does not authenticate requests by default, but supports pluggable authentication methods (see below). The [APIReponse] struct represents a generic API request, and helps with conversion to an [http.Request].
5
+
6
+
The [APIError] struct can represent a generic API error response (eg, an HTTP response with a 4xx or 5xx response code), including the 'error' and 'message' JSON response fields expected with atproto. It is intended to be used with [errors.Is] in error handling, or to provide helpful error messages.
7
+
8
+
The [AuthMethod] interface allows [APIClient] to work with multiple forms of authentication in atproto. It is expected that more complex auth systems (eg, those using signed JWTs) will be implemented in separate packages, but this package does include two simple auth methods:
9
+
10
+
- [PasswordAuth] is the original PDS user auth method, using access and refresh tokens.
11
+
- [AdminAuth] is simple HTTP Basic authentication for administrative requests, as implemented by many atproto services (Relay, Ozone, PDS, etc).
12
+
13
+
## Design Notes
14
+
15
+
Several [AuthMethod] implementations are expected to require retrying entire request at unexpected times. For example, unexpected OAuth DPoP nonce changes, or unexpected password session token refreshes. The auth method may also need to make requests to other servers as part of the refresh process (eg, OAuth when working with a PDS/entryway split). This means that requests should be "retryable" as often as possible. This is mostly a concern for Procedures (HTTP POST) with a non-empty body. The [http.Client] will attempt to "unclose" some common [io.ReadCloser] types (like [bytes.Buffer]), but others may need special handling, using the [APIRequest.GetBody] method. This package will try to make types implementing [io.Seeker] tryable; this helps with things like passing in a open file descriptor for file uploads.
16
+
17
+
In theory, the [http.RoundTripper] interface could have been used instead of [AuthMethod]; or auth methods could have been injected in to [http.Client] instances directly. This package avoids this pattern for a few reasons. The first is that wrangling layered stacks of [http.RoundTripper] can become cumbersome. Calling code may want to use [http.Client] variants which add observability, retries, circuit-breaking, or other non-auth customization. Secondly, some atproto auth methods will require requests to other servers or endpoints, and having a common [http.Client] to re-use for these requests makes sense. Finally, several atproto auth methods need to know the target endpoint as an NSID; while this could be re-parsed from the request URL, it is simpler and more reliable to pass it as an argument.
18
+
19
+
This package tries to use minimal dependencies beyond the Go standard library, to make it easy to reference as a dependency. It does require the [github.com/bluesky-social/indigo/atproto/syntax] and [github.com/bluesky-social/indigo/atproto/identity] sibling packages. In particular, this package does not include any auth methods requiring JWTs, to avoid adding any specific JWT implementation as a dependency.
20
+
*/
21
+
package client
+1
-1
atproto/client/lexclient.go
+1
-1
atproto/client/lexclient.go
···
10
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
11
)
12
12
13
-
// Implements the `LexClient` interface, for use with code-generated API helpers.
13
+
// Implements the [github.com/bluesky-social/indigo/lex/util.LexClient] interface, for use with code-generated API helpers.
14
14
func (c *APIClient) LexDo(ctx context.Context, kind string, inpenc string, method string, params map[string]any, bodyobj any, out any) error {
15
15
// some of the code here is copied from indigo:xrpc/xrpc.go
16
16
+15
-10
atproto/client/password_auth.go
+15
-10
atproto/client/password_auth.go
···
14
14
15
15
type RefreshCallback = func(ctx context.Context, data PasswordSessionData)
16
16
17
+
// Implementation of [AuthMethod] for password-based auth sessions with atproto PDS hosts. Automatically refreshes "access token" using a "refresh token" when needed.
18
+
//
19
+
// It is safe to use this auth method concurrently from multiple goroutines.
17
20
type PasswordAuth struct {
18
21
Session PasswordSessionData
19
22
···
26
29
lk sync.RWMutex
27
30
}
28
31
32
+
// Data about a PDS password auth session which can be persisted and then used to resume the session later.
29
33
type PasswordSessionData struct {
30
34
AccessToken string `json:"access_token"`
31
35
RefreshToken string `json:"refresh_token"`
···
33
37
Host string `json:"host"`
34
38
}
35
39
40
+
// Creates a deep copy of the session data.
36
41
func (sd *PasswordSessionData) Clone() PasswordSessionData {
37
42
return PasswordSessionData{
38
43
AccessToken: sd.AccessToken,
···
114
119
if err != nil {
115
120
return nil, err
116
121
}
117
-
// TODO: could handle auth failure as special error type here
122
+
// NOTE: could handle auth failure as special error type here
118
123
return retryResp, err
119
124
}
120
125
···
128
133
// Refreshes auth tokens (takes a write-lock on session data).
129
134
//
130
135
// `priorRefreshToken` argument is used to check if a concurrent refresh already took place.
131
-
//
132
-
// TODO: need a "Logout" method as well? which takes the refresh token (not access token)
133
136
func (a *PasswordAuth) Refresh(ctx context.Context, c *http.Client, priorRefreshToken string) error {
134
137
135
138
a.lk.Lock()
···
162
165
if err := json.NewDecoder(resp.Body).Decode(&eb); err != nil {
163
166
return &APIError{StatusCode: resp.StatusCode}
164
167
}
165
-
// TODO: indicate in this error that it was from refresh process, not original request?
168
+
// TODO: indicate in the error that it was from refresh process, not original request?
166
169
return eb.APIError(resp.StatusCode)
167
170
}
168
171
···
212
215
return nil
213
216
}
214
217
215
-
// Creates a new APIClient with PasswordAuth for the provided user. The provided identity directory is used to resolve the PDS host for the account.
218
+
// Creates a new [APIClient] with [PasswordAuth] for the provided user. The provided identity directory is used to resolve the PDS host for the account.
216
219
//
217
220
// `authToken` is optional; is used when multi-factor authentication is enabled for the account.
218
-
// `cb` is an optional callback which will be called with updated session data after any token refresh, in a goroutine.
221
+
//
222
+
// `cb` is an optional callback which will be called with updated session data after any token refresh.
219
223
func LoginWithPassword(ctx context.Context, dir identity.Directory, username syntax.AtIdentifier, password, authToken string, cb RefreshCallback) (*APIClient, error) {
220
224
221
225
ident, err := dir.Lookup(ctx, username)
···
240
244
return c, nil
241
245
}
242
246
243
-
// Creates a new APIClient with PasswordAuth, based on a login to the provided host. Note that with some PDS implementations, 'username' could be an email address. This login method also works in situations where an account's network identity does not resolve to this specific host.
247
+
// Creates a new [APIClient] with [PasswordAuth], based on a login to the provided host. Note that with some PDS implementations, 'username' could be an email address. This login method also works in situations where an account's network identity does not resolve to this specific host.
244
248
//
245
249
// `authToken` is optional; is used when multi-factor authentication is enabled for the account.
246
-
// `cb` is an optional callback which will be called with updated session data after any token refresh, in a goroutine.
250
+
//
251
+
// `cb` is an optional callback which will be called with updated session data after any token refresh.
247
252
func LoginWithPasswordHost(ctx context.Context, host, username, password, authToken string, cb RefreshCallback) (*APIClient, error) {
248
253
249
254
c := NewAPIClient(host)
···
283
288
return c, nil
284
289
}
285
290
286
-
// Creates an APIClient using PasswordAuth, based on existing session data.
291
+
// Creates an [APIClient] using [PasswordAuth], based on existing session data.
287
292
//
288
-
// `cb` is an optional callback which will be called with updated session data after any token refresh, in a goroutine.
293
+
// `cb` is an optional callback which will be called with updated session data after any token refresh.
289
294
func ResumePasswordSession(data PasswordSessionData, cb RefreshCallback) *APIClient {
290
295
c := NewAPIClient(data.Host)
291
296
ra := PasswordAuth{