Repository navigation
Expand file tree
/
Copy pathcodeatlas.go
More file actions
303 lines (277 loc) · 9.51 KB
/
Copy pathcodeatlas.go
File metadata and controls
303 lines (277 loc) · 9.51 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
// Package codeatlas is a Go client for the public CodeAtlas API.
//
// CodeAtlas is a Brazilian software studio; its official homepage is
// https://codeatlas.com.br. This is the official Go SDK, published by the
// codeatlasdev GitHub organization. API documentation lives at
// https://codeatlas.com.br/docs and the OpenAPI description at
// https://codeatlas.com.br/openapi.json. Sibling SDKs exist for JavaScript
// (https://github.com/codeatlasdev/codeatlas-sdk-js) and Python
// (https://github.com/codeatlasdev/codeatlas-sdk-python).
//
// Read endpoints (services, health, batch) need no authentication:
//
// client := codeatlas.New()
// page, err := client.Services.List(ctx, &codeatlas.ListParams{Limit: 20})
//
// Write endpoints (contact requests, exports) use OAuth 2.0 client
// credentials. Register once, keep the secret, and pass it to New:
//
// creds, err := codeatlas.Register(ctx, "my-agent")
// client := codeatlas.New(codeatlas.WithCredentials(creds.ClientID, creds.ClientSecret))
//
// The client fetches and caches the access token for you. Use
// [WithBaseURL] with [SandboxBaseURL] to try write calls without anything
// being stored or sent. Every non-2xx response is returned as an [*APIError].
package codeatlas
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
const (
// DefaultBaseURL is the production API.
DefaultBaseURL = "https://codeatlas.com.br/api/v1"
// SandboxBaseURL is the same API with no side effects: writes are
// validated but nothing is stored or sent.
SandboxBaseURL = "https://codeatlas.com.br/api/sandbox/v1"
// Origin is the CodeAtlas origin that hosts the OAuth endpoints.
Origin = "https://codeatlas.com.br"
// Version is the SDK version, sent in the User-Agent header.
Version = "0.1.0"
defaultTimeout = 15 * time.Second
tokenSkew = 30 * time.Second
)
// Client talks to the CodeAtlas API. Create it with [New]; it is safe for
// concurrent use.
type Client struct {
// Services is the public service catalog.
Services *ServicesService
// ContactRequests sends first-conversation requests. Needs credentials.
ContactRequests *ContactRequestsService
// Exports runs asynchronous catalog exports. Needs credentials.
Exports *ExportsService
baseURL string
origin string
http *http.Client
creds *Credentials
tokenMu sync.Mutex // serializes token fetches
token string
tokenExp time.Time
rlMu sync.Mutex
rateLimit RateLimit
}
// Option configures a [Client].
type Option func(*Client)
// WithBaseURL points the client at another API root, e.g. [SandboxBaseURL].
func WithBaseURL(baseURL string) Option {
return func(c *Client) { c.baseURL = strings.TrimRight(baseURL, "/") }
}
// WithOrigin overrides the origin that serves /oauth2/register and
// /oauth2/token. Only useful in tests; defaults to [Origin].
func WithOrigin(origin string) Option {
return func(c *Client) { c.origin = strings.TrimRight(origin, "/") }
}
// WithHTTPClient replaces the underlying *http.Client (timeouts, proxies,
// transports). The default has a 15 second timeout.
func WithHTTPClient(h *http.Client) Option {
return func(c *Client) { c.http = h }
}
// WithCredentials sets the OAuth client credentials used by write calls.
// Get them with [Register].
func WithCredentials(clientID, clientSecret string) Option {
return func(c *Client) { c.creds = &Credentials{ClientID: clientID, ClientSecret: clientSecret} }
}
// New returns a client for the production API. Pass options to change that.
func New(opts ...Option) *Client {
c := &Client{
baseURL: DefaultBaseURL,
origin: Origin,
http: &http.Client{Timeout: defaultTimeout},
}
for _, opt := range opts {
opt(c)
}
c.Services = &ServicesService{c: c}
c.ContactRequests = &ContactRequestsService{c: c}
c.Exports = &ExportsService{c: c}
return c
}
// BaseURL returns the API root the client talks to.
func (c *Client) BaseURL() string { return c.baseURL }
// LastRateLimit returns the rate-limit state parsed from the most recent
// response that carried RateLimit headers. The zero value means none seen yet.
func (c *Client) LastRateLimit() RateLimit {
c.rlMu.Lock()
defer c.rlMu.Unlock()
return c.rateLimit
}
// Health checks API liveness.
func (c *Client) Health(ctx context.Context) (*Health, error) {
var out Health
if err := c.do(ctx, http.MethodGet, c.baseURL+"/health", nil, nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// Register registers a new OAuth client (RFC 7591) and returns its
// credentials. The secret is shown once: store it. It uses the production
// origin unless [WithOrigin] is passed.
func Register(ctx context.Context, clientName string, opts ...Option) (*RegisteredClient, error) {
c := New(opts...)
payload, err := json.Marshal(map[string]any{
"client_name": clientName,
"grant_types": []string{"client_credentials"},
})
if err != nil {
return nil, err
}
var out struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
ClientName string `json:"client_name"`
}
h := http.Header{"Content-Type": {"application/json"}}
if err := c.do(ctx, http.MethodPost, c.origin+"/oauth2/register", payload, h, &out); err != nil {
return nil, err
}
return &RegisteredClient{
Credentials: Credentials{ClientID: out.ClientID, ClientSecret: out.ClientSecret},
ClientName: out.ClientName,
}, nil
}
// accessToken returns a cached client_credentials token, fetching a new one
// when missing or about to expire.
func (c *Client) accessToken(ctx context.Context) (string, error) {
if c.creds == nil {
return "", &APIError{
Status: http.StatusUnauthorized,
Code: "unauthorized",
Message: "This call needs OAuth credentials.",
Hint: "Create them with codeatlas.Register(ctx, name) and pass codeatlas.WithCredentials to codeatlas.New.",
}
}
c.tokenMu.Lock()
defer c.tokenMu.Unlock()
if c.token != "" && time.Until(c.tokenExp) > tokenSkew {
return c.token, nil
}
form := url.Values{
"grant_type": {"client_credentials"},
// The token audience is the API base URL (production or sandbox).
"resource": {c.baseURL},
}
h := http.Header{"Content-Type": {"application/x-www-form-urlencoded"}}
var out struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
raw, err := c.fetch(ctx, http.MethodPost, c.origin+"/oauth2/token", []byte(form.Encode()), h, func(r *http.Request) {
r.SetBasicAuth(c.creds.ClientID, c.creds.ClientSecret)
})
if err != nil {
return "", err
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("codeatlas: decode token response: %w", err)
}
c.token = out.AccessToken
c.tokenExp = time.Now().Add(time.Duration(out.ExpiresIn) * time.Second)
return c.token, nil
}
// authHeaders builds the headers for an authenticated JSON call.
func (c *Client) authHeaders(ctx context.Context, idempotencyKey string) (http.Header, error) {
token, err := c.accessToken(ctx)
if err != nil {
return nil, err
}
h := http.Header{
"Content-Type": {"application/json"},
"Authorization": {"Bearer " + token},
}
if idempotencyKey != "" {
h.Set("Idempotency-Key", idempotencyKey)
}
return h, nil
}
// do sends a request and decodes a JSON response into out (when non-nil).
func (c *Client) do(ctx context.Context, method, target string, body []byte, h http.Header, out any) error {
raw, err := c.fetch(ctx, method, target, body, h, nil)
if err != nil {
return err
}
if out == nil || len(raw) == 0 {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("codeatlas: decode response from %s: %w", target, err)
}
return nil
}
func (c *Client) fetch(ctx context.Context, method, target string, body []byte, h http.Header, hook func(*http.Request)) ([]byte, error) {
raw, rl, err := c.roundTrip(ctx, method, target, body, h, hook)
if rl != (RateLimit{}) {
c.rlMu.Lock()
c.rateLimit = rl
c.rlMu.Unlock()
}
return raw, err
}
// roundTrip is the single request path: it returns the raw body and the
// parsed rate-limit headers, and converts every non-2xx response to *APIError.
func (c *Client) roundTrip(ctx context.Context, method, target string, body []byte, h http.Header, hook func(*http.Request)) ([]byte, RateLimit, error) {
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, target, reader)
if err != nil {
return nil, RateLimit{}, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "codeatlas-sdk-go/"+Version+" (+https://codeatlas.com.br)")
for k, v := range h {
req.Header[k] = v
}
if hook != nil {
hook(req)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, RateLimit{}, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, RateLimit{}, err
}
rl := parseRateLimit(resp.Header)
if resp.StatusCode < 200 || resp.StatusCode > 299 {
apiErr := newAPIError(resp.StatusCode, raw)
apiErr.RateLimit = rl
if secs := atoi(resp.Header.Get("Retry-After")); secs > 0 {
apiErr.RetryAfter = time.Duration(secs) * time.Second
}
return nil, rl, apiErr
}
return raw, rl, nil
}
// IsNotFound reports whether err is an API error with code not_found.
func IsNotFound(err error) bool {
var apiErr *APIError
return errors.As(err, &apiErr) && apiErr.Code == "not_found"
}
// IsRateLimited reports whether err is an API error with status 429.
func IsRateLimited(err error) bool {
var apiErr *APIError
return errors.As(err, &apiErr) && apiErr.Status == http.StatusTooManyRequests
}
func itoa(n int) string { return strconv.Itoa(n) }