You are reading the 0.3.0 documentation. The current release is 0.3.1. Read this page for it →

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:

TypeWhat it holds
GetPetsByIdParamsThe parameters, grouped as path, query, header and cookie
GetPetsByIdBodyThe request body, or undefined for an operation that takes none
GetPetsByIdResponseWhat a success answers with
GetPetsByIdErrorWhat 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

SchemaTypeScript
$refThe name of the type it points at, never expanded
enumA union of literals: "cat" | "dog", 1 | 2
type: ["string", "null"], or anyOf with nullstring | null
A member not in requiredAn optional member, name?:
additionalPropertiesRecord<string, T>, or an index signature beside members
allOfAn intersection
anyOfA union
arrayT[]
integer, numbernumber
string with format: binaryBlob in a form body and a response, string elsewhere
A response of text/*string
A response of any other media type that is not JSONBlob
No contentundefined

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 as Record or Promise, or a name the client declares for itself, such as ApiError, Fields or Sent, 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+2028 and U+2029 among 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 \u escape.
  • 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.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.