Skip to content

Commit f6a6b42

Browse files
committed
feat: client library
Adds a client library and tests for the Spade API. It is loosely based on https://github.com/storacha/filecoin-spade-client/blob/main/pkg/spadeclient/spadeclient.go refs storacha/spade-agent#7 Squashed commit of the following: commit 4471f05cad3a2207ca70200b04c7093013f4d734 Author: Alan Shaw <alan@storacha.network> Date: Thu Jan 29 12:10:06 2026 +0000 test: clean up commit ee6ffbc3757c8f5a7a2bdbfe5d8add7a6c99ae2c Author: Alan Shaw <alan@storacha.network> Date: Thu Jan 29 11:20:02 2026 +0000 test: add remaining tests commit 9a7bd96c77702446352465e742ccacfa8228f4ad Author: Alan Shaw <alan@storacha.network> Date: Wed Jan 28 18:57:31 2026 +0000 test: add tests commit 835ca6849d7d16b9e9d9e9e1c2761c15a9fb9766 Author: Alan Shaw <alan@storacha.network> Date: Wed Jan 28 12:25:50 2026 +0000 feat: wip client
1 parent fb05238 commit f6a6b42

16 files changed

Lines changed: 721 additions & 31 deletions

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ mkbin:
66
@mkdir -p bin/
77

88
webapi: mkbin genapitypes
9-
go build -o bin/spade-webapi ./webapi
9+
go build -o bin/spade-webapi ./cmd/webapi
1010

1111
genapitypes:
1212
go generate ./apitypes/apierrors.go

apitypes/core.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@ import (
88
)
99

1010
// ResponseEnvelope is the structure wrapping all responses from the deal engine
11-
type ResponseEnvelope struct {
12-
RequestID string `json:"request_id,omitempty"`
13-
ResponseTime time.Time `json:"response_timestamp"`
14-
ResponseStateEpoch int64 `json:"response_state_epoch,omitempty"`
15-
ResponseCode int `json:"response_code"`
16-
ErrCode int `json:"error_code,omitempty"`
17-
ErrSlug string `json:"error_slug,omitempty"`
18-
ErrLines []string `json:"error_lines,omitempty"`
19-
InfoLines []string `json:"info_lines,omitempty"`
20-
ResponseEntries *int `json:"response_entries,omitempty"`
21-
Response ResponsePayload `json:"response"`
11+
type ResponseEnvelope[T ResponsePayload] struct {
12+
RequestID string `json:"request_id,omitempty"`
13+
ResponseTime time.Time `json:"response_timestamp"`
14+
ResponseStateEpoch int64 `json:"response_state_epoch,omitempty"`
15+
ResponseCode int `json:"response_code"`
16+
ErrCode int `json:"error_code,omitempty"`
17+
ErrSlug string `json:"error_slug,omitempty"`
18+
ErrLines []string `json:"error_lines,omitempty"`
19+
InfoLines []string `json:"info_lines,omitempty"`
20+
ResponseEntries *int `json:"response_entries,omitempty"`
21+
Response T `json:"response"`
2222
}
2323
type isResponsePayload struct{}
2424

client/client.go

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package client
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"strings"
11+
12+
filaddr "github.com/filecoin-project/go-address"
13+
"github.com/google/uuid"
14+
cid "github.com/ipfs/go-cid"
15+
logging "github.com/ipfs/go-log/v2"
16+
"github.com/storacha/spade/apitypes"
17+
"github.com/storacha/spade/spid"
18+
spid_lotus "github.com/storacha/spade/spid/lotus"
19+
)
20+
21+
var log = logging.Logger("spade/client")
22+
23+
var DefaultBaseURL, _ = url.Parse("https://api.spade.storacha.network")
24+
25+
// IdentifyFunc produces a FIL-SPID-V0 challenge for authentication.
26+
type IdentifyFunc func(ctx context.Context, addr filaddr.Address, args url.Values) (spid.Challenge, error)
27+
28+
type clientConfig struct {
29+
baseURL url.URL
30+
httpClient *http.Client
31+
}
32+
33+
type Option func(*clientConfig)
34+
35+
func WithBaseURL(baseURL url.URL) Option {
36+
return func(c *clientConfig) {
37+
c.baseURL = baseURL
38+
}
39+
}
40+
41+
func WithHTTPClient(httpClient *http.Client) Option {
42+
return func(c *clientConfig) {
43+
c.httpClient = httpClient
44+
}
45+
}
46+
47+
type SpadeClient struct {
48+
baseURL url.URL
49+
httpClient *http.Client
50+
identify IdentifyFunc
51+
storageProvider filaddr.Address
52+
}
53+
54+
func New(storageProvider filaddr.Address, identify IdentifyFunc, options ...Option) *SpadeClient {
55+
config := &clientConfig{
56+
httpClient: http.DefaultClient,
57+
baseURL: *DefaultBaseURL,
58+
}
59+
for _, option := range options {
60+
option(config)
61+
}
62+
return &SpadeClient{storageProvider: storageProvider, baseURL: config.baseURL, identify: identify, httpClient: config.httpClient}
63+
}
64+
65+
func (c *SpadeClient) EligiblePieces(ctx context.Context, options ...EligiblePiecesOption) (apitypes.ResponsePiecesEligible, error) {
66+
cfg := EligiblePiecesConfig{}
67+
for _, option := range options {
68+
option(&cfg)
69+
}
70+
71+
args := url.Values{}
72+
if cfg.TenantID != 0 {
73+
args.Set("tenant", fmt.Sprintf("%d", cfg.TenantID))
74+
}
75+
if cfg.Limit > 0 {
76+
args.Set("limit", fmt.Sprintf("%d", cfg.Limit))
77+
}
78+
79+
data, err := c.doRequest(ctx, http.MethodGet, "/sp/eligible_pieces", args)
80+
var resp apitypes.ResponseEnvelope[apitypes.ResponsePiecesEligible]
81+
if err = unmarshalResponseEnvelope(&resp, data); err != nil {
82+
return apitypes.ResponsePiecesEligible{}, err
83+
}
84+
return resp.Response, nil
85+
}
86+
87+
func (c *SpadeClient) PendingProposals(ctx context.Context) (apitypes.ResponsePendingProposals, error) {
88+
args := url.Values{}
89+
data, err := c.doRequest(ctx, http.MethodGet, "/sp/pending_proposals", args)
90+
var resp apitypes.ResponseEnvelope[apitypes.ResponsePendingProposals]
91+
if err = unmarshalResponseEnvelope(&resp, data); err != nil {
92+
return apitypes.ResponsePendingProposals{}, err
93+
}
94+
return resp.Response, nil
95+
}
96+
97+
func (c *SpadeClient) PieceManifest(ctx context.Context, proposal uuid.UUID) (apitypes.ResponsePieceManifestFR58, error) {
98+
args := url.Values{}
99+
args.Set("proposal", proposal.String())
100+
data, err := c.doRequest(ctx, http.MethodGet, "/sp/piece_manifest", args)
101+
var resp apitypes.ResponseEnvelope[apitypes.ResponsePieceManifestFR58]
102+
if err = unmarshalResponseEnvelope(&resp, data); err != nil {
103+
return apitypes.ResponsePieceManifestFR58{}, err
104+
}
105+
return resp.Response, nil
106+
}
107+
108+
func (c *SpadeClient) ReservePiece(ctx context.Context, piece cid.Cid, options ...ReservePieceOption) (apitypes.ResponseDealRequest, error) {
109+
cfg := ReservePieceConfig{}
110+
for _, option := range options {
111+
option(&cfg)
112+
}
113+
114+
args := url.Values{}
115+
args.Set("call", "reserve_piece")
116+
args.Set("piece_cid", piece.String())
117+
if cfg.TenantID != 0 {
118+
args.Set("tenant", fmt.Sprintf("%d", cfg.TenantID))
119+
}
120+
if cfg.TenantPolicy != "" {
121+
args.Set("tenant_policy", cfg.TenantPolicy)
122+
}
123+
124+
data, err := c.doRequest(ctx, http.MethodPost, "/sp/invoke", args)
125+
if err != nil {
126+
return apitypes.ResponseDealRequest{}, err
127+
}
128+
129+
var resp apitypes.ResponseEnvelope[apitypes.ResponseDealRequest]
130+
if err = unmarshalResponseEnvelope(&resp, data); err != nil {
131+
return apitypes.ResponseDealRequest{}, err
132+
}
133+
134+
return resp.Response, nil
135+
}
136+
137+
func (c *SpadeClient) doRequest(ctx context.Context, method string, path string, args url.Values) ([]byte, error) {
138+
u := c.baseURL.JoinPath(path)
139+
140+
// if method is GET then put args in URL otherwise in signed auth header
141+
if method == http.MethodGet {
142+
u.RawQuery = args.Encode()
143+
args = url.Values{}
144+
} else if method != http.MethodPost {
145+
return nil, fmt.Errorf("unsupported method: %s", method)
146+
}
147+
148+
challenge, err := c.identify(ctx, c.storageProvider, args)
149+
if err != nil {
150+
return nil, fmt.Errorf("creating SPID challenge: %w", err)
151+
}
152+
153+
req, err := http.NewRequestWithContext(ctx, method, u.String(), nil)
154+
if err != nil {
155+
return nil, err
156+
}
157+
req.Header.Set("Authorization", challenge.String())
158+
159+
log.Debugw("request", "method", method, "url", u.String(), "args", args)
160+
161+
res, err := c.httpClient.Do(req)
162+
if err != nil {
163+
log.Errorw("sending request", "method", method, "url", u.String(), "error", err)
164+
return nil, err
165+
}
166+
defer res.Body.Close()
167+
168+
body, err := io.ReadAll(res.Body)
169+
if err != nil {
170+
return nil, fmt.Errorf("reading response body: %w", err)
171+
}
172+
173+
if res.StatusCode != http.StatusOK {
174+
log.Errorw("unexpected response", "method", method, "url", u.String(), "status", res.StatusCode, "body", string(body))
175+
return nil, fmt.Errorf("unexpected response status: %d", res.StatusCode)
176+
} else {
177+
log.Debugw("response", "method", method, "url", u.String(), "status", res.StatusCode, "body", string(body))
178+
}
179+
180+
return body, nil
181+
}
182+
183+
func unmarshalResponseEnvelope[T apitypes.ResponsePayload](resp *apitypes.ResponseEnvelope[T], data []byte) error {
184+
err := json.Unmarshal(data, resp)
185+
if err != nil {
186+
return fmt.Errorf("unmarshaling response: %w", err)
187+
}
188+
if resp.ErrCode != 0 {
189+
return fmt.Errorf("API error %d (%s): %s", resp.ErrCode, resp.ErrSlug, strings.Join(resp.ErrLines, "\n"))
190+
}
191+
return nil
192+
}
193+
194+
// NewLotusIdentifyFunc creates an IdentifyFunc that uses a Lotus node for
195+
// challenge generation.
196+
func NewLotusIdentifyFunc(lotusAPI spid_lotus.LotusBeaconGetterAndWalletSignerClient) IdentifyFunc {
197+
return func(ctx context.Context, addr filaddr.Address, args url.Values) (spid.Challenge, error) {
198+
return spid_lotus.New(ctx, lotusAPI, addr, args)
199+
}
200+
}

0 commit comments

Comments
 (0)