@gusnips/sdkgen
v0.3.0
Published
The pieces of an SDK generator: copy your API's types with their comments, write a schema as a TypeScript type, and keep the output current.
Maintainers
Readme
@gusnips/sdkgen
The parts of a script that writes a TypeScript SDK for your API. You keep the script: it picks the names, the doc text and anything written by hand. This package writes the rest, including the parts that are easy to get wrong.
bun add -d @gusnips/sdkgen prettierimport { typeOf } from "@gusnips/sdkgen";
typeOf({ type: ["string", "null"] });
// → "string | null"typeOf turns a JSON Schema into the TypeScript type it describes. A zod schema works too, through
inputJsonSchema, and each field keeps its description as a doc comment:
import { z } from "zod";
import { fieldsOf, inputJsonSchema } from "@gusnips/sdkgen";
fieldsOf(inputJsonSchema(z.object({ to: z.string().describe("Who gets it.") })));
// → " /** Who gets it. */\n to: string;\n"What is in it
| Function | What it does |
| ------------------------- | ---------------------------------------------------------------------------------- |
| liftContract | Copies the types your API answers with into one file, comments included. |
| typeOf | Writes a JSON Schema as a TypeScript type. |
| fieldsOf | Writes an object schema's fields, one per line, each description as a doc comment. |
| paramsInterface | Writes export interface SendParams { … }, and says if every field is optional. |
| docComment | Writes /** … */, wrapped at 96 columns. |
| wrapLines | Breaks prose into lines for a comment you lay out yourself. |
| typeNames | Lists the type names in an expression: Page<Job>[] gives Page and Job. |
| camelCase, pascalCase | send_message gives sendMessage and SendMessage. |
| inputJsonSchema | Turns a zod schema into JSON Schema. JSON Schema passes through as it is. |
| writeGenerated | Formats and writes the files, or lists the ones that are out of date. |
| sdkMethods | Writes one SDK method per operation, and the params type each one takes. |
| transportSource | Returns the code every method runs on: the request, the retries, the error. |
| retrySource | Returns the rule for when a failed call is worth another try. |
Copy your API's types
An SDK is published on its own, so it cannot import your API's code. The types it returns have to be copied into it.
import { liftContract } from "@gusnips/sdkgen";
const contract = liftContract({
root: repoRoot,
sources: ["packages/shared/src/http.ts", "packages/shared/src/dto.ts"],
roots: ["ApiError", "MessageDto"],
});That returns one file's text. It has each name in roots with its doc comment, then every type
those mention, in the order they appear in sources. If a name is declared nowhere, it stops and
lists the files it searched.
Two options change what comes out:
inlineTuples: truewrites(typeof STATUSES)[number]as"queued" | "sent" | "failed"and leaves the array out. Use it when the SDK has no use for the array. It is off by default, because an SDK that re-exports its contract publishes that array, and each member's doc comment goes with it.exportOnlyRoots: truekeepsexporton the names inrootsonly. The types they mention are still written, so the file compiles, but they are not part of the SDK's public names.
It reads your files as text, not with the TypeScript compiler, because the compiler's .d.ts
output drops the comments. It finds export interface, export type, export const and
export function at the start of a line. Two limits:
- A name of one or two letters, like
TorK, is taken to be a type parameter and not followed. If a real type has a name that short, the SDK will not compile, so you find out. - A quote inside a regex literal, like
/"/, is read as the start of a string.
Write the methods
Give each operation an sdk field that says what its method is called and what it returns. An
operation without one gets no method.
import { sdkMethods } from "@gusnips/sdkgen";
const { members } = sdkMethods([
{
name: "health",
method: "get",
path: "/health",
summary: "Check the API is up.",
sdk: { method: "health", returns: "HealthDto" },
},
]);members is the method as text, ready to go inside a class:
/**
* Check the API is up.
*/
health(opts?: Omit<RequestOptions, "idempotencyKey">): Promise<HealthDto> {
return this.request({ method: "GET", path: "/health" }, undefined, opts);
}The operations are the same list you hand buildOpenApi from @gusnips/server, so you write them
once. sdk.method is a name like sendMessage, or numbers.pair to put the method in a numbers
group. sdk.returns is the type data holds: MessageDto, NumberDto[], or void for a 204.
From @gusnips/server 0.8.24, buildOpenApi checks the sdk field and writes it into the
reference as x-sdk.
Each method calls this.request(spec, params, opts). Your class declares it and sends the call
through the transport, below. sdkMethods also returns:
params: anexport interface …Paramsfor each method that takes arguments, written from the operation'sinputschema. A path slot is filled from the field of its name, or the oneparamsmaps to it.paramTypesandreturnTypes: the names your file has to import.routes: where each generated method sends its call, keyed by the method. Here that is{ health: { method: "GET", path: "/health", pathParams: [] } }. A slot inpathcarries the argument's name, andpathParamslists those arguments in path order. Use it for a docs page that shows a REST call beside the SDK call that makes it.
Four options:
namespaces: the groups, in the order the client lists them. A group missing from the list is an error, so a typo cannot start a new one.doc(op): the method's doc comment, one string per paragraph. Default: the summary, then the description.specExtra(op): more fields on the spec, for your ownrequestto read.inject: methods you write by hand, such as one that polls a job. They go beside the generated ones and must not take one of their names.
It stops with an error that names the operation when a method name has more than one dot, two methods want one name, a status with no body (204, 205, 304) returns something, or a path slot is filled by a field the caller may leave out.
Send the calls
Two more files go into the SDK. transportSource() is the code that sends each call: it fills the
path, tries again when that is safe, and turns a failure into your SDK's own error.
retrySource() is the rule it asks, from @gusnips/http. Both are plain TypeScript that import
nothing else, so the SDK installs nothing.
import { retrySource, transportSource } from "@gusnips/sdkgen";
const files = {
"packages/sdk/src/generated/retry.ts": retrySource(),
"packages/sdk/src/generated/transport.ts": transportSource(),
};Your client hands its settings to send, which returns { data, meta }:
import { GeneratedOperations } from "./generated/operations.ts";
import {
send,
type RequestOptions,
type RequestSpec,
type Transport,
} from "./generated/transport.ts";
export class Example extends GeneratedOperations {
private readonly transport: Transport;
constructor(apiKey: string) {
super();
this.transport = {
baseUrl: "https://api.example.com/v1",
headers: { authorization: `Bearer ${apiKey}` },
error: (failure) => new ExampleError(failure),
};
}
protected async request<T>(spec: RequestSpec, params?: object, opts?: RequestOptions) {
return (await send<T>(this.transport, spec, params, opts)).data;
}
}| Setting | Default | What it does |
| ------------------------- | --------- | --------------------------------------------------------------------------- |
| baseUrl | required | Where the API lives, with its base path. |
| error(failure) | required | Builds your SDK's error. The transport throws what it returns. |
| headers | none | Sent on every call. |
| fetch | global | (url, init) => Promise<Response>, such as a fake one in tests. |
| timeoutMs(spec, params) | 30,000 ms | How long one try waits for an answer. A call's opts.timeoutMs wins. |
| maxRetries | 2 | Extra tries after a failure worth repeating. |
| durableCodes | none | Error codes that waiting does not fix, such as a spent monthly quota. |
| mintKeys | false | Makes up an idempotency key for a call that takes one, so it can try again. |
A call works when the API answers a 2xx with the envelope, a JSON object with data and no
error, and the method returns that data. A 204 or a 205 works too, and returns undefined
because it has no body. Any other 2xx, such as an empty body, {} or { "error": … }, is a
failure: your error gets it with the status and the text, and it is not tried again. Its
failure.error is always empty, even when the body has an error with a code. A 2xx is not the
API refusing, so your SDK never throws a NOT_FOUND that came with a 200.
failure has the status (0 when no answer came back), the API's error, the Retry-After wait,
the request id, the answer's headers, and the idempotency key the call went out with. A call that
may have run can be sent again with that key, and the API answers from the first run.
A request your SDK sends itself, such as a stream it opens with its own fetch, gets the same
failure from failureOf. Read the body first, because a body can be read only once:
if (!response.ok) {
const text = await response.text();
throw new ExampleError(failureOf({ method: "GET", path }, response, text));
}Pass timeoutMs too if that request had a deadline. A stream usually has none, and then
failure.timeoutMs is unset. It is always set when failure.timedOut is true.
A failed call is tried again:
- After a 408, 425 or 429, whatever it is. Those say the API did not run it.
- After no answer, a 5xx, or a 409 that says when to come back, only if running it twice is
safe. A GET is. A write is when its operation reads an
Idempotency-Keyand the call has one, or when itssdk.repeatableis true. A write without a key is not sent twice, because it may already have run. - Never when the answer says waiting will not help: a code in
durableCodes,details.retryAfterSecs: null, or a wait longer than 10 seconds.
It waits what the Retry-After header says, in seconds or as a date, then what
details.retryAfterSecs says. With neither, it waits about 1 second, then 2.
Every method's last argument takes timeoutMs for that one call, and signal to stop it. A
timeoutMs, here or from the setting, is a whole number of milliseconds from 1 to 2,147,483,647,
the longest wait a timer holds. Anything else throws a RangeError before the call goes out. A call
that reads an Idempotency-Key also takes idempotencyKey. A call stopped by its signal throws
the signal's reason rather than your SDK's error, because nothing failed, and it is not tried
again, even partway through a wait. signal needs AbortSignal.any, which Node has from 18.17 and 20.3.
Keep the SDK current
import { writeGenerated } from "@gusnips/sdkgen";
const files = { "packages/sdk/src/generated/contract.ts": contract };
const { stale } = await writeGenerated(files, {
root: repoRoot,
check: process.argv.includes("--check"),
});
if (stale.length > 0) {
console.error(`Out of date:\n ${stale.join("\n ")}\nRun \`bun run sdk:gen\` and commit.`);
process.exitCode = 1;
}files maps each path to its text. A relative path starts from root, and stale and written
list paths the way files spells them. Each file is formatted with the prettier config for its
path before it is compared or written, so prettier --check and --check always agree. With
check, nothing is written. A file it cannot read, for a reason other than not existing, is an
error and not "out of date".
Rules
- It never guesses a type. A schema it does not understand, such as
allOfor a$ref, throws with the schema in the message. A wrongunknownin a published SDK hides the mistake from everyone who installs it. It writesunknownonly where the schema itself allows any value, as{}does. - A field name that is not an identifier is quoted:
"content-type"?: string. - A
*/in a description is broken up, so it cannot end the doc comment early. - A zod schema needs zod 4.4 or later. Older versions cannot describe themselves as JSON Schema, and the error says so.
- A call with a missing path value throws a TypeError before anything is sent, rather than
calling
/numbers//pair. - An idempotency key on a call that takes none throws. The API would ignore it, so it could not
stop the call running twice. The method's type refuses it first: only a call that takes a key
accepts
idempotencyKeyin its options.
Why it exists
Five APIs each wrote their own SDK generator, about 2,500 lines in all. They were not copies. Each had fixed something the others had not, and every one still had these bugs:
- A backslash at the end of a
//comment hid the next line, so the declaration under it was never found. - A string with a backslash before its line break moved every later line by one, so the generator copied the wrong text for every declaration after it in the file.
export interface Empty {}on one line swallowed the declaration after it.- A comment written
/*/ended on the character that opened it.
This package is the merge, with each fix pinned by a test. All five generators were then rewritten on it and wrote every file byte for byte as before, each 267 to 281 lines shorter.
MIT
