Skip to content

Commit ec12ca2

Browse files
authored
chore: Client.Do so the sdk client can be used directly (#12)
1 parent 076ceb4 commit ec12ca2

3 files changed

Lines changed: 162 additions & 0 deletions

File tree

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,20 @@ if err != nil {
138138

139139
For finer control, call `CreateUpload` and `CompleteUpload` directly and do the `PUT` to the presigned URL yourself.
140140

141+
## Escape hatch
142+
143+
`Client.Do` is a low-level entry point for endpoints that do not yet have a dedicated method. It shares the client's auth, User-Agent, retries and backoff. The body is JSON-encoded when non-nil; pass `json.RawMessage` to forward a pre-encoded payload. The raw `*http.Response` is returned so the caller decides how to handle non-2xx — close the body when done.
144+
145+
```go
146+
resp, err := client.Do(ctx, http.MethodPost, "/contacts/create", map[string]any{
147+
"email": "user@example.com",
148+
})
149+
if err != nil {
150+
return err
151+
}
152+
defer resp.Body.Close()
153+
```
154+
141155
## License
142156

143157
MIT

client.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ package loops
2121

2222
import (
2323
"bytes"
24+
"context"
2425
"encoding/json"
2526
"fmt"
2627
"io"
@@ -142,6 +143,36 @@ func (c *Client) logResponse(resp *http.Response) {
142143
}
143144
}
144145

146+
// Do executes an arbitrary request against the Loops API. The request is
147+
// built against the configured base URL with the Authorization and
148+
// User-Agent headers attached, body is JSON-encoded when non-nil, and
149+
// the call runs through the shared retry/backoff plumbing. Callers are
150+
// responsible for closing the returned response body.
151+
//
152+
// Do is an escape hatch for endpoints that do not yet have a dedicated
153+
// method, or for callers that want to share this client's retries and
154+
// User-Agent. Non-2xx responses are returned as-is — inspect
155+
// resp.StatusCode and decode the body to suit. Transport errors are
156+
// wrapped, matching the behavior of the higher-level methods.
157+
//
158+
// To send a pre-encoded JSON payload without re-marshaling, pass a
159+
// [json.RawMessage].
160+
func (c *Client) Do(ctx context.Context, method, path string, body any) (*http.Response, error) {
161+
var reader io.Reader
162+
if body != nil {
163+
b, err := json.Marshal(body)
164+
if err != nil {
165+
return nil, fmt.Errorf("failed to encode request: %w", err)
166+
}
167+
reader = bytes.NewReader(b)
168+
}
169+
req, err := c.newRequest(method, path, reader)
170+
if err != nil {
171+
return nil, err
172+
}
173+
return c.do(req.WithContext(ctx))
174+
}
175+
145176
func (c *Client) do(req *http.Request) (*http.Response, error) {
146177
var (
147178
resp *http.Response

client_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package loops
22

33
import (
44
"bytes"
5+
"context"
6+
"encoding/json"
57
"fmt"
68
"io"
79
"net/http"
@@ -239,3 +241,118 @@ func TestDo_Retries(t *testing.T) {
239241
})
240242
}
241243
}
244+
245+
func TestDo_Exported(t *testing.T) {
246+
var gotMethod, gotPath, gotAuth, gotUA, gotCT string
247+
var gotBody []byte
248+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
249+
gotMethod = r.Method
250+
gotPath = r.URL.Path
251+
gotAuth = r.Header.Get("Authorization")
252+
gotUA = r.Header.Get("User-Agent")
253+
gotCT = r.Header.Get("Content-Type")
254+
gotBody, _ = io.ReadAll(r.Body)
255+
w.WriteHeader(http.StatusCreated)
256+
fmt.Fprint(w, `{"id":"abc"}`)
257+
}))
258+
defer server.Close()
259+
260+
client := NewClient("test-key", WithBaseURL(server.URL))
261+
payload := map[string]any{"email": "user@example.com"}
262+
resp, err := client.Do(context.Background(), http.MethodPost, "/contacts/create", payload)
263+
if err != nil {
264+
t.Fatalf("unexpected error: %v", err)
265+
}
266+
defer resp.Body.Close()
267+
268+
if resp.StatusCode != http.StatusCreated {
269+
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusCreated)
270+
}
271+
if gotMethod != http.MethodPost {
272+
t.Errorf("method = %q, want %q", gotMethod, http.MethodPost)
273+
}
274+
if gotPath != "/contacts/create" {
275+
t.Errorf("path = %q, want %q", gotPath, "/contacts/create")
276+
}
277+
if gotAuth != "Bearer test-key" {
278+
t.Errorf("Authorization = %q, want %q", gotAuth, "Bearer test-key")
279+
}
280+
if gotUA != "loops-go/"+Version {
281+
t.Errorf("User-Agent = %q, want %q", gotUA, "loops-go/"+Version)
282+
}
283+
if gotCT != "application/json" {
284+
t.Errorf("Content-Type = %q, want %q", gotCT, "application/json")
285+
}
286+
var decoded map[string]any
287+
if err := json.Unmarshal(gotBody, &decoded); err != nil {
288+
t.Fatalf("body not valid JSON: %v (body=%q)", err, gotBody)
289+
}
290+
if decoded["email"] != "user@example.com" {
291+
t.Errorf("body email = %v, want %q", decoded["email"], "user@example.com")
292+
}
293+
}
294+
295+
func TestDo_NilBody(t *testing.T) {
296+
var gotCT string
297+
var gotLen int64
298+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
299+
gotCT = r.Header.Get("Content-Type")
300+
gotLen = r.ContentLength
301+
w.WriteHeader(http.StatusOK)
302+
}))
303+
defer server.Close()
304+
305+
client := NewClient("test-key", WithBaseURL(server.URL))
306+
resp, err := client.Do(context.Background(), http.MethodGet, "/api-key", nil)
307+
if err != nil {
308+
t.Fatalf("unexpected error: %v", err)
309+
}
310+
defer resp.Body.Close()
311+
312+
if gotCT != "" {
313+
t.Errorf("Content-Type = %q, want empty", gotCT)
314+
}
315+
if gotLen > 0 {
316+
t.Errorf("ContentLength = %d, want 0", gotLen)
317+
}
318+
}
319+
320+
func TestDo_NonOKReturnsRawResponse(t *testing.T) {
321+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
322+
w.WriteHeader(http.StatusBadRequest)
323+
fmt.Fprint(w, `{"error":"bad request"}`)
324+
}))
325+
defer server.Close()
326+
327+
client := NewClient("test-key", WithBaseURL(server.URL))
328+
resp, err := client.Do(context.Background(), http.MethodGet, "/whatever", nil)
329+
if err != nil {
330+
t.Fatalf("unexpected error: %v", err)
331+
}
332+
defer resp.Body.Close()
333+
334+
if resp.StatusCode != http.StatusBadRequest {
335+
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
336+
}
337+
body, _ := io.ReadAll(resp.Body)
338+
if string(body) != `{"error":"bad request"}` {
339+
t.Errorf("body = %q, want raw error body passthrough", body)
340+
}
341+
}
342+
343+
func TestDo_ContextCancel(t *testing.T) {
344+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
345+
<-r.Context().Done()
346+
}))
347+
defer server.Close()
348+
349+
client := NewClient("test-key", WithBaseURL(server.URL))
350+
ctx, cancel := context.WithCancel(context.Background())
351+
cancel()
352+
353+
resp, err := client.Do(ctx, http.MethodGet, "/", nil)
354+
if err == nil {
355+
resp.Body.Close()
356+
t.Fatal("expected error from cancelled context, got nil")
357+
}
358+
}

0 commit comments

Comments
 (0)