Skip to content

Commit 9d06903

Browse files
committed
feat: typed raw request methods
Get, Post, Put, Patch and Delete on the client take an API path and reuse the configured auth, refresh, retries, client tag and error mapping. They exist for endpoints without a typed method yet, so a coverage gap never forces a fork or a second HTTP client.
1 parent 0ebba6f commit 9d06903

2 files changed

Lines changed: 149 additions & 0 deletions

File tree

raw.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package spoo
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/url"
7+
)
8+
9+
// The raw request methods below are the supported pressure valve for
10+
// API endpoints the SDK has no typed method for yet. They speak
11+
// through the configured client, so auth, token refresh, retries,
12+
// timeout, the attribution tag and *Error mapping all apply; only the
13+
// request and response types are the caller's. path is relative to the
14+
// client's base URL and must start with "/", e.g. "/api/v1/urls".
15+
// Needing one of these means the SDK surface has a gap worth an issue
16+
// at https://github.com/spoo-me/spoo-go/issues.
17+
18+
// Get performs a raw GET request against an API path and decodes the
19+
// JSON response into out (skipped when out is nil). It is the
20+
// supported pressure valve for endpoints the SDK does not cover yet;
21+
// needing it is worth an issue.
22+
func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error {
23+
return c.do(ctx, http.MethodGet, path, query, nil, out)
24+
}
25+
26+
// Post performs a raw POST request against an API path, marshaling
27+
// body to JSON (no body when nil) and decoding the JSON response into
28+
// out (skipped when out is nil). It is the supported pressure valve
29+
// for endpoints the SDK does not cover yet; needing it is worth an
30+
// issue.
31+
func (c *Client) Post(ctx context.Context, path string, body, out any) error {
32+
return c.do(ctx, http.MethodPost, path, nil, body, out)
33+
}
34+
35+
// Put performs a raw PUT request against an API path, marshaling body
36+
// to JSON (no body when nil) and decoding the JSON response into out
37+
// (skipped when out is nil). It is the supported pressure valve for
38+
// endpoints the SDK does not cover yet; needing it is worth an issue.
39+
func (c *Client) Put(ctx context.Context, path string, body, out any) error {
40+
return c.do(ctx, http.MethodPut, path, nil, body, out)
41+
}
42+
43+
// Patch performs a raw PATCH request against an API path, marshaling
44+
// body to JSON (no body when nil) and decoding the JSON response into
45+
// out (skipped when out is nil). It is the supported pressure valve
46+
// for endpoints the SDK does not cover yet; needing it is worth an
47+
// issue.
48+
func (c *Client) Patch(ctx context.Context, path string, body, out any) error {
49+
return c.do(ctx, http.MethodPatch, path, nil, body, out)
50+
}
51+
52+
// Delete performs a raw DELETE request against an API path and decodes
53+
// the JSON response into out (skipped when out is nil). It is the
54+
// supported pressure valve for endpoints the SDK does not cover yet;
55+
// needing it is worth an issue.
56+
func (c *Client) Delete(ctx context.Context, path string, out any) error {
57+
return c.do(ctx, http.MethodDelete, path, nil, nil, out)
58+
}

raw_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package spoo
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"net/http"
8+
"net/http/httptest"
9+
"net/url"
10+
"testing"
11+
12+
"github.com/spoo-me/spoo-go/option"
13+
)
14+
15+
// The raw methods must ride the same machinery as every typed call:
16+
// auth header, client tag, query encoding, body marshaling, decoding.
17+
func TestRawMethodsReuseClientMachinery(t *testing.T) {
18+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+
if r.Header.Get("Authorization") != "Bearer spoo_key123" {
20+
t.Errorf("Authorization = %q", r.Header.Get("Authorization"))
21+
}
22+
if r.Header.Get("X-Spoo-Client") != "raw-test/1.0" {
23+
t.Errorf("X-Spoo-Client = %q", r.Header.Get("X-Spoo-Client"))
24+
}
25+
switch r.Method {
26+
case http.MethodGet:
27+
if r.URL.Path != "/api/v1/future" || r.URL.Query().Get("page") != "2" {
28+
t.Errorf("unexpected GET: %s %v", r.URL.Path, r.URL.Query())
29+
}
30+
case http.MethodPost, http.MethodPut, http.MethodPatch:
31+
var body map[string]any
32+
json.NewDecoder(r.Body).Decode(&body)
33+
if body["name"] != "x" {
34+
t.Errorf("%s body = %v", r.Method, body)
35+
}
36+
case http.MethodDelete:
37+
if r.URL.Path != "/api/v1/future/abc" {
38+
t.Errorf("unexpected DELETE path: %s", r.URL.Path)
39+
}
40+
}
41+
w.Write([]byte(`{"ok":true}`))
42+
}))
43+
defer srv.Close()
44+
45+
c := NewClient(
46+
option.WithBaseURL(srv.URL),
47+
option.WithAPIKey("spoo_key123"),
48+
option.WithClientTag("raw-test/1.0"),
49+
)
50+
ctx := context.Background()
51+
body := map[string]string{"name": "x"}
52+
53+
var out struct {
54+
OK bool `json:"ok"`
55+
}
56+
if err := c.Get(ctx, "/api/v1/future", url.Values{"page": {"2"}}, &out); err != nil || !out.OK {
57+
t.Fatalf("Get: err=%v out=%+v", err, out)
58+
}
59+
if err := c.Post(ctx, "/api/v1/future", body, nil); err != nil {
60+
t.Fatalf("Post: %v", err)
61+
}
62+
if err := c.Put(ctx, "/api/v1/future", body, nil); err != nil {
63+
t.Fatalf("Put: %v", err)
64+
}
65+
if err := c.Patch(ctx, "/api/v1/future", body, nil); err != nil {
66+
t.Fatalf("Patch: %v", err)
67+
}
68+
if err := c.Delete(ctx, "/api/v1/future/abc", nil); err != nil {
69+
t.Fatalf("Delete: %v", err)
70+
}
71+
}
72+
73+
// Raw calls surface API failures as the same *Error every typed
74+
// method returns.
75+
func TestRawMethodsMapErrors(t *testing.T) {
76+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
77+
w.WriteHeader(http.StatusNotFound)
78+
w.Write([]byte(`{"error":"no such thing","code":"not_found"}`))
79+
}))
80+
defer srv.Close()
81+
82+
c := NewClient(option.WithBaseURL(srv.URL))
83+
err := c.Get(context.Background(), "/api/v1/future", nil, nil)
84+
var apiErr *Error
85+
if !errors.As(err, &apiErr) || apiErr.Code != "not_found" {
86+
t.Fatalf("err = %v, want *Error with code not_found", err)
87+
}
88+
if !IsNotFound(err) {
89+
t.Fatalf("IsNotFound(%v) = false", err)
90+
}
91+
}

0 commit comments

Comments
 (0)