porting all github actions from bluesky-social/indigo to tangled CI

iterate on APIClient

Changed files
+81 -36
atproto
+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
··· 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