TypeScript
The OpenAPI document already says everything a frontend needs to know about the API: every
model, every parameter, every response. The tsgen package turns it into a TypeScript file,
so the code that calls the API is checked against the same types the Go handlers are written
against, and a change to a model shows up as a type error in the frontend rather than as an
undefined at run time.
import "muzak.dev/framework/tsgen"
tsgen is a package of its own, linked only into the program that imports it, and like the
rest of Muzak it uses nothing outside the standard library.
Generating the file
tsgen.Generate takes the document and returns the file. The quickest way to run it is
muzak ts, which reads the document from a file or a running application and writes what
Generate returns; see Command Line:
muzak ts -url http://localhost:8080/openapi.json -client -o web/src/api.ts
In a repository that commits the file, a small command beside the application does the same from the application itself, with no server running, and a test keeps the file current:
func main() {
// app.New is the constructor cmd/main.go serves, so the file describes
// exactly the application that runs.
doc, err := app.New().Document()
if err != nil {
log.Fatal(err)
}
ts, err := tsgen.Generate(doc, tsgen.Options{Client: true})
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("web/src/api.ts", ts, 0o644); err != nil {
log.Fatal(err)
}
}
A document another Muzak service publishes works the same way: read it with
muzak.ReadDocument and pass it in. ReadDocument reads what Muzak writes and refuses the
rest with an error saying what and where, so a document that is not OpenAPI 3.1, or that
uses a keyword Muzak never writes, such as oneOf, or an extension, is refused rather than
half translated.
The output is deterministic. The same document gives the same bytes on every run, whatever order Go walks its maps in, so the file can be committed and its diff reviewed with the Go change that caused it. The OpenAPI page covers committing the document itself.
What is written
Every component schema becomes an exported declaration named after it: an interface for an
object, a type alias for anything else.
export interface Pet {
children: Pet[];
/**
* What the pet answers to.
*/
name: string;
owner?: Owner | null;
tags?: string[];
}
Members are written in sorted order, as the document holds them.
Every operation becomes four types, named after its operationId:
| Type | What it holds |
|---|---|
GetPetsByIdParams | The parameters, grouped as path, query, header and cookie |
GetPetsByIdBody | The request body, or undefined for an operation that takes none |
GetPetsByIdResponse | What a success answers with |
GetPetsByIdError | What any other status answers with |
export interface ListPetsParams {
query: {
cursor?: string | null;
kind: string;
limit?: number;
};
}
A group none of whose parameters is required is optional itself. A parameter the document
describes twice, as two fields that bind one query parameter describe it, is one member whose
type is the intersection of both, required if either requires it. An Operations interface
maps every operation to its method, path and four types, for a client of your own to be typed
by.
How JSON Schema reads in TypeScript
| Schema | TypeScript |
|---|---|
$ref | The name of the type it points at, never expanded |
enum | A union of literals: "cat" | "dog", 1 | 2 |
type: ["string", "null"], or anyOf with null | string | null |
A member not in required | An optional member, name?: |
additionalProperties | Record<string, T>, or an index signature beside members |
allOf | An intersection |
anyOf | A union |
array | T[] |
integer, number | number |
string with format: binary | Blob in a form body and a response, string elsewhere |
A response of text/* | string |
| A response of any other media type that is not JSON | Blob |
| No content | undefined |
A reference is always written by name, which is what makes a recursive type such as a tree work, and what keeps the file as long as the document and no longer.
The client
With Options.Client, the file also holds a client built on fetch: createClient
returns one method per operation, named after its operationId, and ApiError is what a
method rejects with when the status is not a success.
import { createClient, ApiError } from "./api";
const api = createClient({
baseUrl: "https://api.example.com",
headers: { Authorization: "Bearer " + token },
});
try {
const pets = await api.listPets({ query: { kind: "cat", limit: 10 } });
const pet = await api.postPets({}, { name: "Tom", kind: "cat" });
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
// err.body is the error envelope, parsed
}
}
Each method takes the operation's Params, its Body when it has one, and a RequestInit
for anything else fetch accepts, such as an AbortSignal. JSON is sent as JSON, a
multipart form as FormData, with a Blob member as a file, a form offered only URL-encoded
as URLSearchParams, and the answer is parsed by its kind: JSON, text, a Blob, or nothing.
A path parameter is escaped one segment at a time, and one that is empty, . or .., or that
holds a /, throws a TypeError rather than reaching another route, as the Go client
refuses it. The document writes a trailing {name...} wildcard as an ordinary parameter, so
the generated client cannot tell one apart and holds every parameter to one segment; send a
value with / in it to a wildcard with a request of your own.
The path's own text is held to the same rule. The client joins it to baseUrl and hands the
whole to URL, which would read a ? or # as the start of a query or a fragment, a \ as
a /, and drop a tab or a line break, so each of those, and every other control character,
is written percent-encoded. A dot segment cannot be escaped that way, since URL decodes
%2e too, and could walk a request out of the baseUrl, so a path holding one makes
Generate return an error when Client is set.
Cookie parameters are typed but not sent: a browser sends its cookies itself and refuses a
Cookie header from a script.
Leave Client off to write the types alone and send requests with whatever client the
frontend already uses, typed by Operations.
Generating from a document you did not write
tsgen is safe to run over a document from anywhere, and its output is safe to compile:
- Every name is reduced to an identifier made of ASCII letters, digits and
_, renamed when it is a reserved word, a global the file uses, such asRecordorPromise, or a name the client declares for itself, such asApiError,FieldsorSent, and numbered when two names reduce to the same identifier. Names are reserved whether or not the client is written, so a component is named the same either way. - A description cannot end the comment it is written into.
*/is written*\/, and every line terminator JavaScript knows,U+2028andU+2029among them, starts a new comment line. - An enum value, a property name or a path cannot end the string literal it is written into:
quotes and backslashes are escaped, and everything outside printable ASCII is written as a
\uescape. - Control characters, the Unicode controls that reorder text, as a "Trojan Source" comment does, and bytes that are not UTF-8 are written as escapes, never as themselves.
- Generation is bounded, and linear in what a document repeats: names that reduce to one identifier, enum values, required names, union members, responses and path parameters cost no more for being repeated. A schema nested more than 64 levels deep is an error, and so is a document built in Go whose schemas, shared in memory, would be walked more than a million times. A reference to a schema the document does not define, or to one outside it, is an error rather than a type that does not compile.
Limits
- A trailing
{name...}wildcard is held to one segment by the generated client, as above. - Cookie parameters are typed but never sent.
- A property named after a member every JavaScript object has, such as
toString, is kept as it is, since it is a valid property name, but TypeScript then refuses to assign an object literal to the interface. Rename the member in the Go model if a frontend needs to build one.
Where to go next
OpenAPI covers the document the file is generated from,
Command Line the muzak ts command that writes it, and
Typed Endpoints the same API called from Go.