Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,20 @@ The built-in codecs are:
- `empty()`
- `content({ mediaType: codec })`

For outbound bodies with more than one `content()` variant, pass
`bodyMediaType` explicitly. Single-variant codecs select their only variant
automatically:

```ts
await client.createDocument({
body: "plain text",
bodyMediaType: "text/plain",
});
```

An omitted or unknown media type produces a `request` failure, so declaration
order never selects a multi-variant request format.

A validator only needs a `safeParse(value)` method, so schema libraries with that contract can be
used without an adapter. Validators run for request bodies, path parameters, query parameters,
headers, and decoded response bodies. Successful validator transformations are used for
Expand Down
34 changes: 28 additions & 6 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ async function decode(response: Response, codec: Codec): Promise<unknown> {
}
return value;
}
function encode(value: unknown, codec: Codec, headers: Headers): BodyInit | undefined {
function encode(
value: unknown,
codec: Codec,
headers: Headers,
requestedMediaType?: string,
): BodyInit | undefined {
if (codec.validator) {
const parsed = codec.validator.safeParse(value);
if (!parsed.success) throw parsed.error;
Expand All @@ -98,10 +103,20 @@ function encode(value: unknown, codec: Codec, headers: Headers): BodyInit | unde
case "stream":
return value as BodyInit;
case "content": {
const [type, selected] = Object.entries(codec.variants ?? {})[0] ?? [];
if (!selected) throw new TypeError("content() has no variants");
const variants = Object.entries(codec.variants ?? {});
if (variants.length === 0) throw new TypeError("content() has no variants");
const type = requestedMediaType?.split(";", 1)[0]?.trim().toLowerCase();
const [selectedType, selected] = type
? ([type, codec.variants?.[type]] as const)
: variants.length === 1
? variants[0]!
: [];
if (type && !selected)
throw new TypeError(`No content() request variant for media type ${type}`);
if (!selected)
throw new TypeError("Multi-variant content() request bodies require bodyMediaType");
const body = encode(value, selected, headers);
if (selected.kind !== "multipart") headers.set("content-type", type);
if (selected.kind !== "multipart") headers.set("content-type", selectedType!);
return body;
}
}
Expand Down Expand Up @@ -225,6 +240,8 @@ export interface AdHocCall {
querySpec?: ParameterMap;
body?: unknown;
bodyCodec?: Codec;
/** Explicit media type used to encode a request body with a multi-variant `content()` codec. */
bodyMediaType?: string;
response?: Codec;
responses?: Readonly<Record<number, Codec>>;
errors?: Partial<Record<number | "default", Codec>>;
Expand Down Expand Up @@ -263,7 +280,7 @@ export function createFetch(options: ClientOptions = {}) {
}
let body: BodyInit | undefined;
try {
if (call.bodyCodec) body = encode(call.body, call.bodyCodec, headers);
if (call.bodyCodec) body = encode(call.body, call.bodyCodec, headers, call.bodyMediaType);
} catch (error) {
return failure("request", error, url);
}
Expand Down Expand Up @@ -397,7 +414,11 @@ type EndpointInput<D extends AnyEndpointDescriptor> = Container<
> &
Container<"query", InputParts<D>["query"]> &
Container<"headers", InputParts<D>["headers"]> &
Container<"body", InputParts<D>["body"], true> & { signal?: AbortSignal; timeout?: number };
Container<"body", InputParts<D>["body"], true> & {
bodyMediaType?: string;
signal?: AbortSignal;
timeout?: number;
};
type Successes<D> =
D extends EndpointDescriptor<any, any, any, any, infer R, any>
? { [S in keyof R & number]: SuccessResult<InferCodec<R[S]>, S> }[keyof R & number]
Expand Down Expand Up @@ -488,6 +509,7 @@ export function createClient<A extends ApiDefinition>(
querySpec: endpoint.query,
body: input.body,
bodyCodec: endpoint.body,
bodyMediaType: input.bodyMediaType,
responses: endpoint.responses,
errors: endpoint.errors,
signal: input.signal,
Expand Down
50 changes: 50 additions & 0 deletions tests/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,56 @@ describe("fetch contracts", () => {
});
expect(contentType).toBe("application/problem+json");
});
it("should explicitly select a request content variant independent of declaration order", async () => {
const requests: Array<{ contentType: string | null; body: string }> = [];
const transport = async (request: Request) => {
requests.push({
contentType: request.headers.get("content-type"),
body: await request.text(),
});
return new Response("ok", { headers: { "content-type": "text/plain" } });
};
const variants = [
content({ "application/json": json(), "text/plain": text() }),
content({ "text/plain": text(), "application/json": json() }),
];

for (const bodyCodec of variants) {
const client = createClient(
defineApi({ createDocument: post("/documents").body(bodyCodec).returns(text()) }),
{ baseUrl: "https://x.test", fetch: transport },
);
await client.createDocument({
body: "plain body",
bodyMediaType: "text/plain",
});
}

expect(requests).toEqual([
{ contentType: "text/plain", body: "plain body" },
{ contentType: "text/plain", body: "plain body" },
]);

const ambiguous = await createFetch({ fetch: transport })({
url: "https://x.test",
method: "POST",
body: "plain body",
bodyCodec: variants[0],
response: text(),
});
expect(ambiguous).toMatchObject({ ok: false, kind: "request" });

const unknown = await createFetch({ fetch: transport })({
url: "https://x.test",
method: "POST",
body: "plain body",
bodyCodec: variants[0],
bodyMediaType: "application/xml",
response: text(),
});
expect(unknown).toMatchObject({ ok: false, kind: "request" });
expect(requests).toHaveLength(2);
});
it("should execute middleware outward given declaration order when responding", async () => {
const events: string[] = [];
const layer = (name: string) => async (context: any, next: any) => {
Expand Down