client

package
v0.1.0-rc1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 13, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Overview

Package client is the fulfillmenttools API as the rest of fft sees it.

It wraps the generated client (which owns the paths, the models and — crucially — the per-parameter query encoding) with the five things every command needs and none should reimplement:

  • the error envelope decoded as the JSON *array* it actually is, mapped to an exit code and a hint (APIError, Check);
  • a retry that knows the difference between a request it may repeat and one it may not (Client.Do);
  • the reactive 401: refresh the token and retry, exactly once;
  • cursor pagination as one generic, because every /search endpoint in the API has the same shape (Search, SearchAll);
  • optimistic locking as one generic, because `version` travels in the body and every mutation is therefore a read-then-write (UpdateVersioned).

Why the retry is here and not in a RoundTripper

A RoundTripper is handed a request whose body is a one-shot io.ReadCloser. By the time a 401 or a 500 comes back the body has been consumed, and replaying it is impossible in the general case. A retry at that layer works in the specs, where the body is a bytes.Reader, and silently sends an empty POST in production. So the retry lives here, where a request is a Doer that can simply be called again.

Index

Constants

View Source
const (
	KeyFacilityRef      = "facilityRef"
	KeyTenantFacilityID = "tenantFacilityId"
)

The two names a facility goes by outside a path parameter: the platform's reference, and the tenant's own id (swagger:38576, 82981).

View Source
const (
	// MinSize and MaxSize bound `size`. The API rejects anything outside them with
	// an opaque 400, so fft rejects it first, with a sentence.
	MinSize = 1
	MaxSize = 250

	// DefaultSize is what the API uses when `size` is absent.
	DefaultSize = 20

	// PageSize is what [SearchAll] asks for. Well under the maximum, and 5× fewer
	// round trips than the API's own default across a tenant with a few thousand
	// entities.
	PageSize = 100

	// DefaultMaxItems is how far [SearchAll] will follow the cursor before it stops
	// and says so. A runaway cursor must not be able to hang a terminal.
	DefaultMaxItems = 10_000
)

The search API's numbers. The legacy GET list has a *different* default (25): the two are not unified into one --limit default, because a user who set --size 25 and got 20 would be right to file a bug.

Variables

This section is empty.

Functions

func Check

func Check(status int, body []byte) error

Check turns a non-2xx response into an *APIError, and returns nil for a 2xx.

func FacilityRef

func FacilityRef(id string) string

FacilityRef turns whatever the user typed into something a facility path parameter accepts.

A platform UUID passes through. Anything else is taken to be the tenant's own id and wrapped as urn:fft:facility:tenantFacilityId:<id>, which every facility path parameter in the API also accepts. So `fft facility get BER-01` works, and the user never has to look up a UUID for an id they chose themselves.

A value that is already a URN passes through too, so that piping one command's output into another cannot double-wrap it.

func FacilitySelector

func FacilitySelector(id string) (key, value string)

FacilitySelector names a facility the way a request *body* and a search *query* want it: as one of two differently-spelled fields, rather than as the URN FacilityRef builds for a path.

The two are told apart the same way FacilityRef tells them apart — by shape, because there is no other signal. A platform UUID (or an already-built URN) is a facilityRef; anything else is the tenant's own id. key is "" for an empty id.

The difference from the URN wrap is not cosmetic. A stock creation body carrying {"facilityRef": "BER-01"} is refused, because BER-01 is not a reference; a search query filtering facilityRef by "BER-01" is *accepted* and quietly matches nothing, which is worse. Same value, same intent, two spellings — not something each command should be left to work out for itself.

func Fetch

func Fetch[T any](ctx context.Context, c *Client, op string, do Doer) (T, error)

Fetch issues the request and decodes its JSON answer into T.

func RequestError

func RequestError(op string, err error) error

RequestError reports a request that never got an answer.

http.Client wraps every transport failure in a *url.Error, which prints as `Get "https://…/api/…": <cause>`. When the cause is fft's own — a token it could not mint — that prefix buries the one sentence the user needs ("sign in again") behind a URL they did not type. So an error that already knows its exit code is returned as it is, with its hint intact; a genuine network failure keeps the URL, because there the URL is the point.

func SearchAll

func SearchAll[Q, S, T any](ctx context.Context, c *Client, op Op[T], payload SearchPayload[Q, S], opts ...AllOption) iter.Seq2[T, error]

SearchAll yields every match, following the cursor from page to page.

It stops at maxItems (DefaultMaxItems unless MaxItems says otherwise) and, if that cut anything off, yields a *TruncatedError as its last element: a truncated list that does not say it was truncated is a wrong answer that looks like a right one.

The iteration stops at the first error, which is also yielded. A caller that breaks out early simply stops fetching.

func UpdateVersioned

func UpdateVersioned[T any](ctx context.Context, get GetFn[T], put PutFn[T], mutate func(*T) error, expected *int) (T, error)

UpdateVersioned is the read-then-write that every mutation of a versioned entity has to be.

It reads the entity, applies mutate to it, and writes it back with the version it just read. If the server answers 409 — someone else wrote in between — it reads again, re-applies mutate to the *fresh* entity, and writes once more. A second 409 is not retried: at that point the entity is being written by something else faster than fft can read it, and the honest answer is to say so.

expected is the --if-version escape hatch. When it is set, the read is skipped and that version is sent as it stands: a CI job that already knows the version pays for one request instead of two, and gets a 409 rather than a silent overwrite if it was wrong. mutate is then applied to the zero value of T, so it must produce the complete entity — which is what `--file` gives it.

(The flag is --if-version, never --version: cobra owns --version on the root command, and a subcommand-local one would read as "print the version" to every user and every script.)

Types

type APIError

type APIError struct {
	// Status is the HTTP status code.
	Status int
	// Errors is the decoded envelope. It is empty when the body was not one — a
	// proxy's HTML 502, say.
	Errors []api.ErrorInner
	// Body is the raw response, kept only when it could not be decoded, so that
	// the user is told *something* rather than "HTTP 502".
	Body string
}

APIError is a non-2xx answer from fulfillmenttools.

The error envelope is a JSON *array* of ErrorInner — [{"summary":"…"}] — and not an object. Decoding it into a struct succeeds and produces {} for every error, which is how an API turns "no facility matching request X was found" into silence.

func (*APIError) Conflict

func (e *APIError) Conflict() (sent, current int64, ok bool)

Conflict reports the two versions a 409 carries: the one the request sent and the one the server holds. ok is false for anything else — including a 409 whose envelope, for once, did not carry them.

This is the gift the array envelope brings: the server tells us exactly how stale we were, so the user is told too, instead of "409 Conflict".

func (*APIError) Error

func (e *APIError) Error() string

func (*APIError) ExitCode

func (e *APIError) ExitCode() int

ExitCode implements the interface exitcode.FromError looks for, so a script can tell "you are not permitted" from "it does not exist" without parsing text.

func (*APIError) Hint

func (e *APIError) Hint() string

Hint names the command that would fix the failure, where there is one.

type AllOption

type AllOption func(*allOptions)

AllOption configures SearchAll.

func MaxItems

func MaxItems(n int) AllOption

MaxItems caps how many items SearchAll yields. A value below 1 is ignored.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is one tenant, authenticated.

The generated client underneath is reachable through Client.API for the calls that need no retry — `fft ping` against /api/status, say. Everything else goes through Client.Do, which is what turns a generated call into one that retries safely, refreshes on a 401, and fails with a message the user can act on.

func New

func New(baseURL string, opts ...Option) (*Client, error)

New returns the client for the tenant at baseURL.

func (*Client) API

func (c *Client) API() *api.ClientWithResponses

API is the generated client, for a call that does not go through Client.Do.

func (*Client) Do

func (c *Client) Do(ctx context.Context, op string, do Doer) (*Result, error)

Do issues the request, retrying it where that is safe, and turns the answer into a *Result or an error a user can act on.

op names the operation in errors ("list the facilities"), so that a failure reads as a sentence rather than as a URL.

func (*Client) DoRaw

func (c *Client) DoRaw(ctx context.Context, op string, req RawRequest) (*Result, error)

DoRaw issues a request built from spec metadata, through the same Client.Do as everything else: the same retry rules, the same 401 refresh, the same error envelope.

op names the operation in errors, as it does everywhere else in this package.

type Doer

type Doer func(ctx context.Context) (*http.Response, error)

Doer issues one attempt of a request.

It must build a *fresh* request every time it is called, because Client.Do calls it again to retry and an http.Request body is a one-shot reader. Every generated api.ClientInterface method satisfies this: each call marshals the body anew. A Doer that closes over an io.Reader does not, and would retry by sending an empty body.

type FacilitySearchPayload

type FacilitySearchPayload = SearchPayload[api.FacilitySearchQuery, api.FacilitySort]

FacilitySearchPayload is the body of POST /api/facilities/search.

type GetFn

type GetFn[T any] func(ctx context.Context) (entity T, version int, err error)

GetFn reads the entity to be updated, and the version the server currently holds for it.

The version comes back separately rather than being read off the entity, because T is a type parameter and Go cannot reach a field through one. The call site knows the field — it is one line — and this way UpdateVersioned works for every versioned entity in the API without an interface the generated models would have to implement.

type ListingSearchPayload

type ListingSearchPayload = SearchPayload[api.ListingSearchQuery, api.ListingSort]

ListingSearchPayload is the body of POST /api/listings/search.

type Op

type Op[T any] struct {
	// Name names the operation in errors ("search the facilities").
	Name string

	// Items is the response field holding the entities: "facilities", "stocks",
	// "listings". It is the one part of the response shape that is not uniform.
	Items string

	// Do issues one page.
	Do func(ctx context.Context, raw api.ClientInterface, body []byte) (*http.Response, error)
}

Op binds the generic search to one entity's endpoint.

Do is the generated client's method, so the path and the encoding stay in the generated code. It takes the body as bytes rather than as an io.Reader because Client.Do may call it twice: a Reader would be empty the second time.

func FacilitySearch

func FacilitySearch[T any]() Op[T]

FacilitySearch is POST /api/facilities/search, decoding each facility into T.

func ListingSearch

func ListingSearch[T any]() Op[T]

ListingSearch is POST /api/listings/search, decoding each listing into T.

func StockSearch

func StockSearch[T any]() Op[T]

StockSearch is POST /api/stocks/search, decoding each stock into T.

type Option

type Option func(*options)

Option configures the client.

func WithDebug

func WithDebug(w io.Writer) Option

WithDebug logs every request and response to w, redacted. This is --debug.

func WithHTTPClient

func WithHTTPClient(hc *http.Client) Option

WithHTTPClient replaces the underlying HTTP client.

func WithRetry

func WithRetry(r Retry) Option

WithRetry replaces the retry policy. Fields left zero keep their default.

func WithTokenSource

func WithTokenSource(src auth.TokenSource) Option

WithTokenSource authenticates every request with src.

Without it the client is unauthenticated, which is exactly what `fft ping` wants: /api/status is the one endpoint that answers without a token, so it can prove connectivity even when the credentials are the thing that is broken.

type Page

type Page[T any] struct {
	// Items are the entities on this page.
	Items []T

	// PageInfo carries the cursor to the next page, and whether there is one.
	PageInfo api.PageInfo

	// Total is the number of matches — *absent* unless the search asked for it with
	// options.withTotal. A pointer, because "the API did not count" and "the API
	// counted zero" are different answers and a command that renders `Total: 0` for
	// the first one is lying.
	Total *int
}

Page is one page of search results.

func Search[Q, S, T any](ctx context.Context, c *Client, op Op[T], payload SearchPayload[Q, S]) (Page[T], error)

Search fetches one page.

type PutFn

type PutFn[T any] func(ctx context.Context, entity T, version int) (T, error)

PutFn writes the entity back, carrying version in the *body*.

There is no ETag and no If-Match anywhere in the API: optimistic locking is a required `version` field in the request body. That is why the version is a parameter here and not a header somewhere below.

type QueryParam

type QueryParam struct {
	// Name is the parameter's name as the API spells it.
	Name string

	// Values are the values to send. One for a scalar; any number for an array.
	Values []string

	// Explode chooses the encoding, and it must come from the *parameter's* own
	// `explode` in the spec — never from a policy applied to all of them.
	//
	//	true  repeats the name:     status=OPEN&status=CLOSED
	//	false joins with a comma:   status=OPEN%2CCLOSED
	//
	// The comma really does go over the wire percent-encoded: the values become one
	// string, and url.Values.Encode escapes the separator along with everything else.
	// It is what oapi-codegen's runtime already sends for the five tags it covers, and
	// it was confirmed against the live tenant on 2026-07-13 — getStockSummaries
	// filtered by facilityStatus=ONLINE%2COFFLINE answers 384,279 of 383,484 rows,
	// which is the union of the two statuses and emphatically not the zero an
	// unrecognised filter would have returned.
	//
	// The API does not reject the wrong *choice*, though, and that is the danger. It
	// answers 200 and filters on something else — a comma-joined value sent to an
	// exploded parameter is matched as the literal string "ONLINE,OFFLINE", which no
	// row has, and the user is told there are none. 17 of the spec's 77 array query
	// parameters want the joined form and 60 want the repeated one, which is why this
	// is a field and not a constant.
	Explode bool
}

QueryParam is one query parameter, encoded the way its own spec entry says.

type RawRequest

type RawRequest struct {
	// Method is the HTTP method, upper-case.
	Method string

	// Path is the path template, placeholders and all: /api/pickjobs/{pickJobId}.
	// [RawRequest.URL] fills them from PathParams, so that the escaping happens in
	// one place instead of at every call site.
	Path string

	// PathParams fills the template's placeholders.
	PathParams map[string]string

	// Query is the query string, each parameter carrying its own encoding.
	Query []QueryParam

	// Header adds request headers. Content-Type, Accept and Authorization are set by
	// the client and should not be given here.
	Header map[string]string

	// Body is the request body, or nil for none.
	Body []byte
}

RawRequest is a request described by spec metadata rather than by a typed method.

func (RawRequest) URL

func (r RawRequest) URL(base string) (string, error)

URL resolves the request against a base URL.

Every placeholder must have a value and no value may be empty: an unfilled {pickJobId} would be sent literally and a blank one would collapse the path to /api/pickjobs/, which is a different endpoint that answers 200. Both are refused here rather than sent.

type Result

type Result struct {
	Status int
	Header http.Header
	Body   []byte
}

Result is a 2xx answer: everything a caller could want from it, with the body already read and the connection already returned to the pool.

type Retry

type Retry struct {
	// MaxAttempts is how many requests may be sent in total, the first included.
	MaxAttempts int

	// Base is the first backoff interval; it doubles from there.
	Base time.Duration

	// Max caps one backoff interval.
	Max time.Duration

	// MaxWait is the longest Retry-After fft will honour. A rate limit measured in
	// minutes is not something a CLI should sit and wait out — it is something the
	// user should be told about.
	MaxWait time.Duration

	// Sleep waits, or returns the context's error if the wait is cancelled. It is a
	// seam: nil means a real sleep, and specs replace it so that a spec exercising
	// a Retry-After of one second does not take one second.
	Sleep func(ctx context.Context, d time.Duration) error
}

Retry is the retry policy. A zero Retry means the defaults.

The idempotency rule

A 429 is retried for every method: the request provably did not happen. A 5xx or a dropped connection is retried only for GET, PUT and DELETE — a POST that answered 500 may still have created the facility, and re-sending it would create a second one. There is no way to tell from the outside, so fft does not guess: it surfaces the error and lets the user look.

type SearchPayload

type SearchPayload[Q, S any] struct {
	// Query is the entity's filter. Its zero value marshals to {}, which the API
	// accepts and reads as "everything" — confirmed against the live tenant.
	Query Q `json:"query"`

	// After is the cursor from the previous page's pageInfo.endCursor. [SearchAll]
	// drives it; a caller paging by hand sets it.
	After *string `json:"after,omitempty"`

	// Size is how many items to return: 1–250, 20 if absent.
	Size *int `json:"size,omitempty"`

	// Sort holds *exactly one* element, or is nil. The API's schema says
	// minItems: 1, maxItems: 1, and an empty array comes back as an opaque 400 —
	// so an empty (but non-nil) Sort is refused here, before the request is sent.
	Sort []S `json:"sort,omitempty"`

	// Options asks for the total. Without withTotal:true the response has no
	// `total` field at all — which is why [Page.Total] is a pointer.
	Options *api.SearchOptions `json:"options,omitempty"`
}

SearchPayload is the body of every POST /{entity}/search in the API.

This is the leverage point: the shape is identical for facilities, listings, stocks, pickjobs and orders — only the query and the sort differ, and those are the two type parameters. One generic therefore carries cursor pagination for the whole API, and a new entity costs an Op and a type alias.

func (SearchPayload[Q, S]) WithTotal

func (p SearchPayload[Q, S]) WithTotal() SearchPayload[Q, S]

WithTotal asks the API to count the matches, and returns the payload so it can be set inline.

type StockSearchPayload

type StockSearchPayload = SearchPayload[api.StockSearchQuery, api.StockSort]

StockSearchPayload is the body of POST /api/stocks/search.

type TruncatedError

type TruncatedError struct {
	MaxItems int
	Op       string
}

TruncatedError says that a search matched more than SearchAll was allowed to yield. It is not a failure of the request — the items already yielded are real — it is the reason there are no more.

func (*TruncatedError) Error

func (e *TruncatedError) Error() string

func (*TruncatedError) Hint

func (e *TruncatedError) Hint() string

Hint tells the user how to see the rest.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL