-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Expand file tree
/
Copy pathclient.go
More file actions
962 lines (841 loc) · 29.5 KB
/
Copy pathclient.go
File metadata and controls
962 lines (841 loc) · 29.5 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
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
package codersdk
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"sync"
"unicode"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/semconv/v1.14.0/httpconv"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/websocket"
)
// These cookies are Coder-specific. If a new one is added or changed, the name
// shouldn't be likely to conflict with any user-application set cookies.
// Be sure to strip additional cookies in httpapi.StripCoderCookies!
// SessionTokenCookie represents the name of the cookie or query parameter the API key is stored in.
// NOTE: This is declared as a var so that we can override it in `develop.sh` if required.
var SessionTokenCookie = "coder_session_token"
const (
// SessionTokenHeader is the custom header to use for authentication.
SessionTokenHeader = "Coder-Session-Token"
// OAuth2StateCookie is the name of the cookie that stores the oauth2 state.
OAuth2StateCookie = "oauth_state"
// OAuth2PKCEVerifier is the name of the cookie that stores the oauth2 PKCE
// verifier. This is the raw verifier that when hashed, will match the challenge
// sent in the initial oauth2 request.
OAuth2PKCEVerifier = "oauth_pkce_verifier"
// OAuth2RedirectCookie is the name of the cookie that stores the oauth2 redirect.
OAuth2RedirectCookie = "oauth_redirect"
// OAuth2RedirectURICookie stores the dynamically computed OIDC redirect_uri
// when CODER_OIDC_REDIRECT_ALLOWED_HOSTS is enabled. The same value must be
// used for both the authorization request and the token exchange (RFC 6749
// section 4.1.3).
OAuth2RedirectURICookie = "oauth_redirect_uri"
// PathAppSessionTokenCookie is the name of the cookie that stores an
// application-scoped API token on workspace proxy path app domains.
//nolint:gosec
PathAppSessionTokenCookie = "coder_path_app_session_token"
// SubdomainAppSessionTokenCookie is the name of the cookie that stores an
// application-scoped API token on subdomain app domains (both the primary
// and proxies).
//
// To avoid conflicts between multiple proxies, we append an underscore and
// a hash suffix to the cookie name.
//nolint:gosec
SubdomainAppSessionTokenCookie = "coder_subdomain_app_session_token"
// SignedAppTokenCookie is the name of the cookie that stores a temporary
// JWT that can be used to authenticate instead of the app session token.
//nolint:gosec
SignedAppTokenCookie = "coder_signed_app_token"
// SignedAppTokenQueryParameter is the name of the query parameter that
// stores a temporary JWT that can be used to authenticate instead of the
// session token. This is only acceptable on reconnecting-pty requests, not
// apps.
//
// It has a random suffix to avoid conflict with user query parameters on
// apps.
//nolint:gosec
SignedAppTokenQueryParameter = "coder_signed_app_token_23db1dde"
// BypassRatelimitHeader is the custom header to use to bypass ratelimits.
// Only owners can bypass rate limits. This is typically used for scale testing.
// nolint: gosec
BypassRatelimitHeader = "X-Coder-Bypass-Ratelimit"
// Note: the use of X- prefix is deprecated, and we should eventually remove
// it from BypassRatelimitHeader.
//
// See: https://datatracker.ietf.org/doc/html/rfc6648.
// CLITelemetryHeader contains a base64-encoded representation of the CLI
// command that was invoked to produce the request. It is for internal use
// only.
CLITelemetryHeader = "Coder-CLI-Telemetry"
// CoderDesktopTelemetryHeader contains a JSON-encoded representation of Desktop telemetry
// fields, including device ID, OS, and Desktop version.
CoderDesktopTelemetryHeader = "Coder-Desktop-Telemetry"
// ProvisionerDaemonPSK contains the authentication pre-shared key for an external provisioner daemon
ProvisionerDaemonPSK = "Coder-Provisioner-Daemon-PSK"
// ProvisionerDaemonKey contains the authentication key for an external provisioner daemon
ProvisionerDaemonKey = "Coder-Provisioner-Daemon-Key"
// AIGatewayKeyHeader contains the authentication key for a standalone AI Gateway replica.
AIGatewayKeyHeader = "X-Coder-AI-Governance-Gateway-Key"
// BuildVersionHeader contains build information of Coder.
BuildVersionHeader = "X-Coder-Build-Version"
// EntitlementsWarnings contains active warnings for the user's entitlements.
EntitlementsWarningHeader = "X-Coder-Entitlements-Warning"
)
// loggableMimeTypes is a list of MIME types that are safe to log
// the output of. This is useful for debugging or testing.
var loggableMimeTypes = map[string]struct{}{
"application/json": {},
"text/plain": {},
// lots of webserver error pages are HTML
"text/html": {},
}
type ClientOption func(*Client)
// New creates a Coder client for the provided URL.
func New(serverURL *url.URL, opts ...ClientOption) *Client {
client := &Client{
URL: serverURL,
HTTPClient: &http.Client{},
SessionTokenProvider: FixedSessionTokenProvider{},
}
for _, opt := range opts {
opt(client)
}
return client
}
// Client is an HTTP caller for methods to the Coder API.
// @typescript-ignore Client
type Client struct {
// mu protects the fields sessionToken, logger, and logBodies. These
// need to be safe for concurrent access.
mu sync.RWMutex
SessionTokenProvider SessionTokenProvider
logger slog.Logger
logBodies bool
HTTPClient *http.Client
URL *url.URL
// PlainLogger may be set to log HTTP traffic in a human-readable form.
// It uses the LogBodies option.
// Deprecated: Use WithPlainLogger to set this.
PlainLogger io.Writer
// Trace can be enabled to propagate tracing spans to the Coder API.
// This is useful for tracking a request end-to-end.
// Deprecated: Use WithTrace to set this.
Trace bool
// DisableDirectConnections forces any connections to workspaces to go
// through DERP, regardless of the BlockEndpoints setting on each
// connection.
// Deprecated: Use WithDisableDirectConnections to set this.
DisableDirectConnections bool
// derpTLSConfig is an optional TLS config for DERP connections.
derpTLSConfig *tls.Config
}
// Logger returns the logger for the client.
func (c *Client) Logger() slog.Logger {
c.mu.RLock()
defer c.mu.RUnlock()
return c.logger
}
// SetLogger sets the logger for the client.
// Deprecated: Use WithLogger to set this.
func (c *Client) SetLogger(logger slog.Logger) {
c.mu.Lock()
defer c.mu.Unlock()
c.logger = logger
}
// LogBodies returns whether requests and response bodies are logged.
func (c *Client) LogBodies() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.logBodies
}
// SetLogBodies sets whether to log request and response bodies.
// Deprecated: Use WithLogBodies to set this.
func (c *Client) SetLogBodies(logBodies bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.logBodies = logBodies
}
// SessionToken returns the currently set token for the client.
func (c *Client) SessionToken() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.SessionTokenProvider.GetSessionToken()
}
// SetSessionToken sets a fixed token for the client.
// Deprecated: Create a new client using WithSessionToken instead of changing the token after creation.
func (c *Client) SetSessionToken(token string) {
c.mu.Lock()
defer c.mu.Unlock()
c.SessionTokenProvider = FixedSessionTokenProvider{SessionToken: token}
}
func prefixLines(prefix, s []byte) []byte {
ss := bytes.NewBuffer(make([]byte, 0, len(s)*2))
for _, line := range bytes.Split(s, []byte("\n")) {
_, _ = ss.Write(prefix)
_, _ = ss.Write(line)
_ = ss.WriteByte('\n')
}
return ss.Bytes()
}
// Request performs a HTTP request with the body provided. The caller is
// responsible for closing the response body.
func (c *Client) Request(ctx context.Context, method, path string, body interface{}, opts ...RequestOption) (*http.Response, error) {
opts = append([]RequestOption{c.SessionTokenProvider.AsRequestOption()}, opts...)
return c.RequestWithoutSessionToken(ctx, method, path, body, opts...)
}
// RequestWithoutSessionToken performs a HTTP request. It is similar to Request, but does not set
// the session token in the request header, nor does it make a call to the SessionTokenProvider.
// This allows session token providers to call this method without causing reentrancy issues.
func (c *Client) RequestWithoutSessionToken(ctx context.Context, method, path string, body interface{}, opts ...RequestOption) (*http.Response, error) {
if ctx == nil {
return nil, xerrors.Errorf("context should not be nil")
}
ctx, span := tracing.StartSpanWithName(ctx, tracing.FuncNameSkip(1))
defer span.End()
serverURL, err := c.URL.Parse(path)
if err != nil {
return nil, xerrors.Errorf("parse url: %w", err)
}
var r io.Reader
if body != nil {
switch data := body.(type) {
case io.Reader:
r = data
case []byte:
r = bytes.NewReader(data)
default:
// Assume JSON in all other cases.
buf := bytes.NewBuffer(nil)
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
err = enc.Encode(body)
if err != nil {
return nil, xerrors.Errorf("encode body: %w", err)
}
r = buf
}
}
// Copy the request body so we can log it.
var reqLogFields []slog.Field
c.mu.RLock()
logBodies := c.logBodies
c.mu.RUnlock()
if r != nil && logBodies {
reqBody, err := io.ReadAll(r)
if err != nil {
return nil, xerrors.Errorf("read request body: %w", err)
}
r = bytes.NewReader(reqBody)
reqLogFields = append(reqLogFields, slog.F("body", string(reqBody)))
}
req, err := http.NewRequestWithContext(ctx, method, serverURL.String(), r)
if err != nil {
return nil, xerrors.Errorf("create request: %w", err)
}
if r != nil {
req.Header.Set("Content-Type", "application/json")
}
for _, opt := range opts {
opt(req)
}
span.SetAttributes(httpconv.ClientRequest(req)...)
// Inject tracing headers if enabled.
if c.Trace {
tmp := otel.GetTextMapPropagator()
hc := propagation.HeaderCarrier(req.Header)
tmp.Inject(ctx, hc)
}
// We already capture most of this information in the span (minus
// the request body which we don't want to capture anyways).
ctx = slog.With(ctx,
slog.F("method", req.Method),
slog.F("url", req.URL.String()),
)
tracing.RunWithoutSpan(ctx, func(ctx context.Context) {
c.Logger().Debug(ctx, "sdk request", reqLogFields...)
})
resp, err := c.HTTPClient.Do(req)
// We log after sending the request because the HTTP Transport may modify
// the request within Do, e.g. by adding headers.
if resp != nil && c.PlainLogger != nil {
out, err := httputil.DumpRequest(resp.Request, logBodies)
if err != nil {
return nil, xerrors.Errorf("dump request: %w", err)
}
out = prefixLines([]byte("http --> "), out)
_, _ = c.PlainLogger.Write(out)
}
if err != nil {
return nil, err
}
if c.PlainLogger != nil {
out, err := httputil.DumpResponse(resp, logBodies)
if err != nil {
return nil, xerrors.Errorf("dump response: %w", err)
}
out = prefixLines([]byte("http <-- "), out)
_, _ = c.PlainLogger.Write(out)
}
span.SetAttributes(httpconv.ClientResponse(resp)...)
span.SetStatus(httpconv.ClientStatus(resp.StatusCode))
// Copy the response body so we can log it if it's a loggable mime type.
var respLogFields []slog.Field
if resp.Body != nil && logBodies {
mimeType := parseMimeType(resp.Header.Get("Content-Type"))
if _, ok := loggableMimeTypes[mimeType]; ok {
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, xerrors.Errorf("copy response body for logs: %w", err)
}
err = resp.Body.Close()
if err != nil {
return nil, xerrors.Errorf("close response body: %w", err)
}
resp.Body = io.NopCloser(bytes.NewReader(respBody))
respLogFields = append(respLogFields, slog.F("body", string(respBody)))
}
}
// See above for why this is not logged to the span.
tracing.RunWithoutSpan(ctx, func(ctx context.Context) {
c.Logger().Debug(ctx, "sdk response",
append(respLogFields,
slog.F("status", resp.StatusCode),
slog.F("trace_id", resp.Header.Get("X-Trace-Id")),
slog.F("span_id", resp.Header.Get("X-Span-Id")),
)...,
)
})
return resp, err
}
func (c *Client) Dial(ctx context.Context, path string, opts *websocket.DialOptions) (*websocket.Conn, error) {
u, err := c.URL.Parse(path)
if err != nil {
return nil, err
}
if opts == nil {
opts = &websocket.DialOptions{}
}
// Propagate the client's HTTP client to the websocket dialer
// so that custom TLS configurations (e.g. mesh TLS between
// replicas) are used for the handshake request. Without this,
// the websocket library falls back to http.DefaultClient.
if opts.HTTPClient == nil {
opts.HTTPClient = c.HTTPClient
}
c.SessionTokenProvider.SetDialOption(opts)
conn, resp, err := websocket.Dial(ctx, u.String(), opts)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
if err != nil {
return nil, err
}
return conn, nil
}
// ExpectJSONMime is a helper function that will assert the content type
// of the response is application/json.
func ExpectJSONMime(res *http.Response) error {
contentType := res.Header.Get("Content-Type")
mimeType := parseMimeType(contentType)
if mimeType != "application/json" {
return xerrors.Errorf("unexpected non-JSON response %q", contentType)
}
return nil
}
// ReadBodyAsError reads the response as a codersdk.Response, and
// wraps it in a codersdk.Error type for easy marshaling.
//
// This will always return an error, so only call it if the response failed
// your expectations. Usually via status code checking.
// nolint:staticcheck
func ReadBodyAsError(res *http.Response) error {
if res == nil {
return xerrors.Errorf("no body returned")
}
defer res.Body.Close()
resp, err := io.ReadAll(res.Body)
if err != nil {
return xerrors.Errorf("read body: %w", err)
}
if mimeErr := ExpectJSONMime(res); mimeErr != nil {
if len(resp) > 2048 {
resp = append(resp[:2048], []byte("...")...)
}
if len(resp) == 0 {
resp = []byte("no response body")
}
return newResponseError(res, Response{
Message: mimeErr.Error(),
Detail: string(resp),
})
}
var m Response
err = json.NewDecoder(bytes.NewBuffer(resp)).Decode(&m)
if err != nil {
if errors.Is(err, io.EOF) {
return newResponseError(res, Response{
Message: "empty response body",
})
}
return xerrors.Errorf("decode body: %w", err)
}
if m.Message == "" {
if len(resp) > 1024 {
resp = append(resp[:1024], []byte("...")...)
}
m.Message = fmt.Sprintf("unexpected status code %d, response has no message", res.StatusCode)
m.Detail = string(resp)
}
return newResponseError(res, m)
}
// newResponseError wraps an API response in an *Error annotated with
// the status code, request method, and request URL from res. For 401
// responses it also sets a helper message suggesting 'coder login'.
func newResponseError(res *http.Response, response Response) *Error {
var requestMethod, requestURL string
if res.Request != nil {
requestMethod = res.Request.Method
if res.Request.URL != nil {
requestURL = res.Request.URL.String()
}
}
var helpMessage string
if res.StatusCode == http.StatusUnauthorized {
// 401 means the user is not logged in
// 403 would mean that the user is not authorized
helpMessage = "Try logging in using 'coder login'."
}
return &Error{
Response: response,
statusCode: res.StatusCode,
method: requestMethod,
url: requestURL,
Helper: helpMessage,
}
}
// jsonBodySniffLen bounds how much of a response body is retained while
// decoding to identify HTML without buffering the entire response.
const jsonBodySniffLen = 512
// htmlResponseHelper suggests the most common causes of receiving HTML
// from what should be a Coder API endpoint: a misconfigured Coder URL,
// or an intermediary such as a reverse proxy or SSO portal intercepting
// API requests.
const htmlResponseHelper = "Ensure the Coder URL is correct and that any reverse proxy or SSO in front of it passes /api/v2 requests through to Coder."
// ReadBodyAsJSON decodes the response body as JSON into v. It is
// intended for typed API endpoints whose accepted responses are always
// JSON, replacing direct json.NewDecoder(res.Body).Decode(...) calls.
// It must not be used for streaming or non-JSON endpoints such as
// WebSockets, server-sent events, or file downloads.
//
// Bodies that are HTML, empty, or otherwise not valid JSON produce a
// structured *Error describing the response instead of a bare decode
// error such as "invalid character '<' looking for beginning of value".
// Intermediaries like reverse proxies and SSO portals commonly return
// such bodies with a 200 status code.
//
// Valid JSON is decoded even when an intermediary omits or mislabels
// the Content-Type header. The exception is a Content-Type declaring
// HTML, which is always reported as an invalid response since Coder
// API endpoints never serve HTML. The caller remains responsible for
// closing the response body.
func ReadBodyAsJSON(res *http.Response, v any) error {
return decodeBodyAsJSON(res, v, nil)
}
// ReadBodyAsJSONUseNumber behaves like ReadBodyAsJSON but decodes JSON
// numbers into json.Number instead of float64, preserving integer
// precision for callers that re-serialize or type-assert numeric
// claims, such as license JWT claims.
func ReadBodyAsJSONUseNumber(res *http.Response, v any) error {
return decodeBodyAsJSON(res, v, func(dec *json.Decoder) {
dec.UseNumber()
})
}
// decodeBodyAsJSON decodes the response body as JSON into v. When
// configure is non-nil it is called with the decoder before decoding,
// allowing callers to set options such as UseNumber.
func decodeBodyAsJSON(res *http.Response, v any, configure func(*json.Decoder)) error {
if res == nil || res.Body == nil {
return xerrors.New("no response body to decode")
}
mimeType := parseMimeType(res.Header.Get("Content-Type"))
if isHTMLMimeType(mimeType) {
return htmlBodyError(res)
}
body := &responseBodyReader{Reader: res.Body}
prefix := &bodyPrefixWriter{}
dec := json.NewDecoder(io.TeeReader(body, prefix))
if configure != nil {
configure(dec)
}
err := dec.Decode(v)
switch {
case err == nil:
return nil
case body.err != nil && errors.Is(err, body.err):
return xerrors.Errorf("read response body: %w", err)
case len(prefix.bytes) == 0 && errors.Is(err, io.EOF):
return invalidBodyError(res, Response{
Message: "Received an empty response from the Coder API.",
Detail: invalidBodyDetail(res),
}, "", nil)
case isHTMLBody(mimeType, prefix.bytes):
return htmlBodyError(res)
default:
return invalidBodyError(res, Response{
Message: "Received an invalid JSON response from the Coder API.",
Detail: fmt.Sprintf("decode body: %s, %s", err.Error(), invalidBodyDetail(res)),
}, "", err)
}
}
// htmlBodyError returns the structured *Error reported when the Coder API
// response body is HTML rather than JSON.
func htmlBodyError(res *http.Response) *Error {
return invalidBodyError(res, Response{
Message: "Received an HTML response instead of JSON from the Coder API.",
Detail: invalidBodyDetail(res),
}, htmlResponseHelper, nil)
}
// responseBodyReader records non-EOF read errors so decode errors from custom
// JSON unmarshallers are not mistaken for transport failures.
type responseBodyReader struct {
io.Reader
err error
}
func (r *responseBodyReader) Read(p []byte) (int, error) {
n, err := r.Reader.Read(p)
if err != nil && !errors.Is(err, io.EOF) && r.err == nil {
r.err = err
}
return n, err
}
// bodyPrefixWriter retains only the beginning of a body while reporting every
// byte as written so it can be used with io.TeeReader.
type bodyPrefixWriter struct {
bytes []byte
}
func (w *bodyPrefixWriter) Write(p []byte) (int, error) {
if remaining := jsonBodySniffLen - len(w.bytes); remaining > 0 {
w.bytes = append(w.bytes, p[:min(remaining, len(p))]...)
}
return len(p), nil
}
// isHTMLBody reports whether a response body is HTML, either by its
// media type or by starting with '<' after optional whitespace. Valid
// JSON can never start with '<', so the sniff also identifies HTML
// bodies mislabeled with a JSON content type. Markup bodies such as
// XML are intentionally classified the same way since the remediation,
// fixing the URL or the intermediary, is identical.
func isHTMLBody(mimeType string, prefix []byte) bool {
if isHTMLMimeType(mimeType) {
return true
}
// Strip a UTF-8 byte order mark, which some intermediaries prepend
// to HTML error pages.
trimmed := bytes.TrimPrefix(prefix, []byte("\xef\xbb\xbf"))
trimmed = bytes.TrimLeftFunc(trimmed, unicode.IsSpace)
return len(trimmed) > 0 && trimmed[0] == '<'
}
func isHTMLMimeType(mimeType string) bool {
return mimeType == "text/html" || mimeType == "application/xhtml+xml"
}
// invalidBodyDetail describes an invalid response body and how it was
// reached, for inclusion in an Error Detail.
func invalidBodyDetail(res *http.Response) string {
var sb strings.Builder
if contentType := res.Header.Get("Content-Type"); contentType != "" {
_, _ = fmt.Fprintf(&sb, "content type %q", contentType)
} else {
_, _ = sb.WriteString("no content type")
}
if orig := originalRequestURL(res); orig != "" && res.Request != nil && res.Request.URL != nil && orig != res.Request.URL.String() {
_, _ = fmt.Fprintf(&sb, ", after following redirects from %s", orig)
}
return sb.String()
}
// originalRequestURL returns the URL of the first request in the
// redirect chain that produced res, or "" when unknown. res.Request
// itself is the final request after any redirects.
func originalRequestURL(res *http.Response) string {
req := res.Request
for req != nil && req.Response != nil && req.Response.Request != nil {
req = req.Response.Request
}
if req == nil || req.URL == nil {
return ""
}
return req.URL.String()
}
// invalidBodyError builds the *Error returned when a response with an
// accepted status code does not contain the expected JSON body.
func invalidBodyError(res *http.Response, response Response, helper string, cause error) *Error {
var requestMethod, requestURL string
if res.Request != nil {
requestMethod = res.Request.Method
if res.Request.URL != nil {
requestURL = res.Request.URL.String()
}
}
return &Error{
Response: response,
statusCode: res.StatusCode,
method: requestMethod,
url: requestURL,
contentType: res.Header.Get("Content-Type"),
cause: cause,
invalidBody: true,
Helper: helper,
}
}
// Error represents an unaccepted or invalid request to the API.
// @typescript-ignore Error
type Error struct {
Response
statusCode int
method string
url string
contentType string
// cause is the underlying error, such as a JSON decode failure,
// exposed to errors.Is and errors.As via Unwrap.
cause error
// invalidBody marks errors for responses whose status code was
// accepted but whose body was not the expected JSON. Error() then
// describes the body rather than an unexpected status code.
invalidBody bool
Helper string
}
func (e *Error) StatusCode() int {
return e.statusCode
}
func (e *Error) Method() string {
return e.method
}
func (e *Error) URL() string {
return e.url
}
// ContentType returns the Content-Type header of the response that
// produced the error, when known.
func (e *Error) ContentType() string {
return e.contentType
}
// Unwrap returns the underlying cause of the error, if any, such as the
// JSON decode error for a response body that could not be decoded.
func (e *Error) Unwrap() error {
return e.cause
}
func (e *Error) Friendly() string {
var sb strings.Builder
_, _ = fmt.Fprintf(&sb, "%s. %s", strings.TrimSuffix(e.Message, "."), e.Helper)
for _, err := range e.Validations {
_, _ = fmt.Fprintf(&sb, "\n- %s: %s", err.Field, err.Detail)
}
return sb.String()
}
func (e *Error) Error() string {
var builder strings.Builder
if e.method != "" && e.url != "" {
_, _ = fmt.Fprintf(&builder, "%v %v: ", e.method, e.url)
}
if e.invalidBody {
_, _ = fmt.Fprintf(&builder, "invalid API response (status code %d): %s", e.statusCode, e.Message)
} else {
_, _ = fmt.Fprintf(&builder, "unexpected status code %d: %s", e.statusCode, e.Message)
}
if e.Helper != "" {
_, _ = fmt.Fprintf(&builder, ": %s", e.Helper)
}
if e.Detail != "" {
_, _ = fmt.Fprintf(&builder, "\n\tError: %s", e.Detail)
}
for _, err := range e.Validations {
_, _ = fmt.Fprintf(&builder, "\n\t%s: %s", err.Field, err.Detail)
}
return builder.String()
}
// NewTestError is a helper function to create a Error, setting the internal fields. It's generally only useful for
// testing.
func NewTestError(statusCode int, method string, u string) *Error {
return &Error{
statusCode: statusCode,
method: method,
url: u,
}
}
// NewError creates a new Error with the response and status code.
func NewError(statusCode int, response Response) *Error {
return &Error{
statusCode: statusCode,
Response: response,
}
}
type closeFunc func() error
func (c closeFunc) Close() error {
return c()
}
func parseMimeType(contentType string) string {
mimeType, _, err := mime.ParseMediaType(contentType)
if err != nil {
mimeType = strings.TrimSpace(strings.Split(contentType, ";")[0])
}
return mimeType
}
// Response represents a generic HTTP response.
type Response struct {
// Message is an actionable message that depicts actions the request took.
// These messages should be fully formed sentences with proper punctuation.
// Examples:
// - "A user has been created."
// - "Failed to create a user."
Message string `json:"message"`
// Detail is a debug message that provides further insight into why the
// action failed. This information can be technical and a regular golang
// err.Error() text.
// - "database: too many open connections"
// - "stat: too many open files"
Detail string `json:"detail,omitempty"`
// Validations are form field-specific friendly error messages. They will be
// shown on a form field in the UI. These can also be used to add additional
// context if there is a set of errors in the primary 'Message'.
Validations []ValidationError `json:"validations,omitempty"`
}
// ValidationError represents a scoped error to a user input.
type ValidationError struct {
Field string `json:"field" validate:"required"`
Detail string `json:"detail" validate:"required"`
}
func (e ValidationError) Error() string {
return fmt.Sprintf("field: %s detail: %s", e.Field, e.Detail)
}
var _ error = (*ValidationError)(nil)
// CoderDesktopTelemetry represents the telemetry data sent from Coder Desktop clients.
// @typescript-ignore CoderDesktopTelemetry
type CoderDesktopTelemetry struct {
DeviceID string `json:"device_id"`
DeviceOS string `json:"device_os"`
CoderDesktopVersion string `json:"coder_desktop_version"`
}
// FromHeader parses the desktop telemetry from the provided header value.
// Returns nil if the header is empty or if parsing fails.
func (t *CoderDesktopTelemetry) FromHeader(headerValue string) error {
if headerValue == "" {
return nil
}
return json.Unmarshal([]byte(headerValue), t)
}
// IsEmpty returns true if all fields in the telemetry data are empty.
func (t *CoderDesktopTelemetry) IsEmpty() bool {
return t.DeviceID == "" && t.DeviceOS == "" && t.CoderDesktopVersion == ""
}
// IsConnectionError is a convenience function for checking if the source of an
// error is due to a 'connection refused', 'no such host', etc.
func IsConnectionError(err error) bool {
var (
// E.g. no such host
dnsErr *net.DNSError
// Eg. connection refused
opErr *net.OpError
)
return xerrors.As(err, &dnsErr) || xerrors.As(err, &opErr)
}
func AsError(err error) (*Error, bool) {
var e *Error
return e, xerrors.As(err, &e)
}
// RequestOption is a function that can be used to modify an http.Request.
type RequestOption func(*http.Request)
// WithQueryParam adds a query parameter to the request.
func WithQueryParam(key, value string) RequestOption {
return func(r *http.Request) {
if value == "" {
return
}
q := r.URL.Query()
q.Add(key, value)
r.URL.RawQuery = q.Encode()
}
}
// HeaderTransport is a http.RoundTripper that adds some headers to all requests.
// @typescript-ignore HeaderTransport
type HeaderTransport struct {
Transport http.RoundTripper
Header http.Header
}
var _ http.RoundTripper = &HeaderTransport{}
func (h *HeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
for k, v := range h.Header {
for _, vv := range v {
req.Header.Add(k, vv)
}
}
if h.Transport == nil {
return http.DefaultTransport.RoundTrip(req)
}
return h.Transport.RoundTrip(req)
}
func (h *HeaderTransport) CloseIdleConnections() {
type closeIdler interface {
CloseIdleConnections()
}
if tr, ok := h.Transport.(closeIdler); ok {
tr.CloseIdleConnections()
}
}
// ClientOptions
func WithSessionToken(token string) ClientOption {
return func(c *Client) {
c.SessionTokenProvider = FixedSessionTokenProvider{SessionToken: token}
}
}
func WithHTTPClient(httpClient *http.Client) ClientOption {
return func(c *Client) {
c.HTTPClient = httpClient
}
}
func WithLogger(logger slog.Logger) ClientOption {
return func(c *Client) {
c.logger = logger
}
}
func WithLogBodies() ClientOption {
return func(c *Client) {
c.logBodies = true
}
}
func WithPlainLogger(plainLogger io.Writer) ClientOption {
return func(c *Client) {
c.PlainLogger = plainLogger
}
}
func WithTrace() ClientOption {
return func(c *Client) {
c.Trace = true
}
}
func WithDisableDirectConnections() ClientOption {
return func(c *Client) {
c.DisableDirectConnections = true
}
}
func WithDERPTLSConfig(cfg *tls.Config) ClientOption {
return func(c *Client) {
c.derpTLSConfig = cfg
}
}
// DERPTLSConfig returns the optional TLS config for DERP connections.
func (c *Client) DERPTLSConfig() *tls.Config {
return c.derpTLSConfig
}