Skip to main content
WritingVideosSpeakingCodeAbout

SDK Toolbox docs

Requests and bodies

PHP ^8.316 pages

Docs for v0.0.1, from GitHub.

Pages in these docs

A request implements Request<T>: a method, a path and a result(). Everything else is opt-in through small interfaces, so a request carries only what it uses.

InterfaceAdds
HasQueryquery(): array<string, mixed>
HasHeadersheaders(): array<string, string|list<string>>
HasBodybody(): Body
Idempotentan Idempotency-Key (Reliability)
ConditionalIf-Match and friends (HTTP semantics)
HasPreferencesa Prefer header (HTTP semantics)

The base URI

Every path is appended to the connector’s base URI, and the result has to stay under it.

Base URIPathRequest goes to
https://api.acme.test/v2/projectshttps://api.acme.test/v2/projects
https://api.acme.test/v2projects/1https://api.acme.test/v2/projects/1
https://api.acme.test/projectshttps://api.acme.test/projects

Plain RFC 3986 resolution would turn /projects into https://api.acme.test/projects, dropping the /v2. It would also let a path like //elsewhere.example send the request, and its credentials, to another host. So these are refused with UnsafeUriException before anything is sent:

  • absolute URLs, and //host;
  • .. segments, percent-encoded ones included;
  • a ? or # in the path (use query());
  • anything that isn’t a valid URI, such as an unencoded space.

The base URI itself can’t carry a query, a fragment or credentials.

Encode anything a caller supplies with rawurlencode() before it goes into a path. /projects/'.rawurlencode('../../admin') is one harmless segment.

Query strings

public function query(): array
{
return [
'q' => $this->search,
'include_archived' => $this->archived, // true or false
'status' => $this->status, // a backed enum sends its value
'cursor' => $this->cursor, // null is left out
];
}

PHP’s http_build_query() sends true as 1 and drops false, which most APIs read as something else. The toolbox’s QueryString encodes differently:

  • null is left out;
  • booleans are true and false;
  • backed enums send their value;
  • Stringables are cast;
  • arrays use PHP’s bracket form.

Encoding is RFC 3986, so a space is %20. Anything else (an object, a resource) throws InvalidRequestException.

Headers

Every request gets Accept: application/json, application/problem+json, and a User-Agent of your SDK’s name followed by sdk-toolbox/<version> php/<version>. Set defaults for every request on the connector:

parent::__construct(
baseUri: 'https://api.acme.test',
headers: ['Acme-Version' => '2026-10-01'],
userAgent: 'acme-php/1.0',
);

A request’s own headers() replace defaults of the same name. $connector->withHeaders([...]) returns a copy with more defaults.

Bodies

BodyContent-TypeNotes
new JsonBody($data)application/jsonAn empty array is sent as {}. Accepts arrays and JsonSerializable
new FormBody($fields)application/x-www-form-urlencodedEncoded the same way as a query string
new RawBody($stringOrStream, $type)whatever you sayFor XML, CSV, binary uploads
new Multipart(...$parts)multipart/form-data; boundary=...RFC 7578
new MergePatch($patch) or MergePatch::diff($before, $after)application/merge-patch+jsonRFC 7396
JsonPatch::new()->...application/json-patch+jsonRFC 6902

A body’s Content-Type is set for you. A request’s headers() can still override it.

Multipart

public function body(): Body
{
return new Multipart(
Part::field('title', 'Q3 report'),
Part::file('file', fopen($this->path, 'rb'), 'q3.pdf', 'application/pdf'),
);
}

Part::file() takes a stream, an open resource or a string of content. It never takes a file path, so an SDK can’t be talked into reading a path someone else supplied. Parts are written into a php://temp stream, which moves to disk past 2MB, so a large upload isn’t held in memory, and the stream can be rewound for a retry. Names and filenames are escaped so a " or a line break can’t break the framing.

JSON Merge Patch

Merge Patch is the simple way to send a partial update, with two rules that catch people out. null means “remove this member”, so a merge patch can never set a value to null, and arrays are always replaced whole. MergePatch::diff() builds a patch from two versions of an object and enforces both:

$patch = MergePatch::diff(
['title' => 'Draft', 'tags' => ['a', 'b'], 'owner' => ['id' => 1, 'name' => 'Ada']],
['title' => 'Final', 'tags' => ['a'], 'owner' => ['id' => 1]],
);
// ['title' => 'Final', 'tags' => ['a'], 'owner' => ['name' => null]]

Changing a member to null throws InvalidRequestException, naming the path, because the server would read it as “delete”.

JSON Patch

When you need operations rather than a new shape, JSON Patch says exactly what to do, and the server applies all of it or none of it. Put a test first to make the patch conditional:

JsonPatch::new()
->test('/version', 3)
->replace('/name', 'Production')
->remove('/labels/0')
->add(JsonPointer::from('settings', 'a/b'), true); // escapes to /settings/a~1b

Paths are checked as RFC 6901 JSON Pointers. JsonPointer::from() builds one from raw segments, escaping ~ and /.

Sending

MethodReturnsOn a 4xx or 5xx
send($request)what result() returnsthrows
response($request)the Response, whatever its statusreturns it
paginate($request, $strategy)a lazy Paginatorthrows on the failing page
await($request)result() of the finished operationthrows

Response wraps the PSR-7 response:

  • status: status(), successful(), failed();
  • headers and body: header(), contentType(), body(), json();
  • values and objects: get('meta.next_cursor') reads a value by dotted path, and map() and mapList() hydrate objects;
  • standards: problem(), etag(), lastModified(), rateLimit(), retryAfter(), deprecation(), preferenceApplied();
  • underneath: psr() and request() for the PSR-7 objects.

toPsr($request) gives you the PSR-7 request the connector would send, before middleware and authentication, which is useful in tests.