Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

If you come from a client team: the wire contract

A team with a server and two client platforms reviews the same four things on every endpoint, by hand, twice: is a field null or absent; which revision of the API is this; is the answer one row or a page; what does the status field mean. They review them because the wire contract lives only in the server’s source, so every client re-derives it, and a reviewer checks each derivation.

In Vishy there are no data-transfer objects to write. vishy client projects the interface, the same declaration the verifier checked, into a TypeScript client, an OpenAPI description and a Rust crate, so both ends of the wire are projections of one text. Each of the four questions is answered once, in the language:

  • Null or absent. An option<T> is null or the value, never absent. Only an optional argument at the door may be omitted. A string(64) is a maxLength, an int(1, 366) a minimum and a maximum, and the door refuses a value outside them with 400 naming the parameter.
  • Revision. One, version N; in the program, with migrations for the store and vishy diff classifying an interface change as patch, minor or major and refusing a version number that does not cover it.
  • Row or page. One list shape everywhere: a view that returns a list answers {ok, value, total, offset} and takes limit and offset.
  • Result status. Every flow answers {ok, outcome, value?, events}; a client switches on outcome; a failed outcome is 409 with the same shape, and the client type is the union of the outcomes the flow can produce.

The SQL page’s program has three routes. vishy client book/programs/sql.vish out writes this TypeScript, unedited:

// Generated by `vishy client` from the interface. Do not edit; regenerate.
// Wire shape: 200 or 409 carry {ok, outcome, value?, events}; other statuses throw VishyError.

export interface Employee { id: number; name: string; dept: number; salary: number }
export interface Dept { id: number; name: string; budget: number; payroll: number }

export type Event = never;

export class VishyError extends Error {
  status: number; body: unknown;
  constructor(status: number, body: unknown) { super(`HTTP ${status}: ${JSON.stringify(body)}`); this.status = status; this.body = body; }
}

export type CreateDeptResult =
  | { ok: true; outcome: "Created"; events: Event[] }
  | { ok: false; outcome: "BadBudget"; events: Event[] }
  | { ok: false; outcome: "Unknown"; events: Event[] }
  | { ok: false; outcome: "Exists"; events: Event[] }
  | { ok: false; outcome: "Full"; events: Event[] }
  | { ok: false; outcome: "WrongStatus"; events: Event[] };
export type HireResult =
  | { ok: true; outcome: "Hired"; events: Event[] }
  | { ok: false; outcome: "BadSalary"; events: Event[] }
  | { ok: false; outcome: "OverBudget"; events: Event[] }
  | { ok: false; outcome: "Unknown"; events: Event[] }
  | { ok: false; outcome: "Exists"; events: Event[] }
  | { ok: false; outcome: "Full"; events: Event[] }
  | { ok: false; outcome: "WrongStatus"; events: Event[] };
export type PayrollResult = { ok: true; value: number | null };

export class VishyClient {
  baseUrl: string; token: string | undefined;
  constructor(baseUrl: string, token?: string) { this.baseUrl = baseUrl.replace(/\/+$/, ""); this.token = token; }

  /** `route POST "/depts" => create_dept`  */
  create_dept(dept: number, name: string, budget: number): Promise<CreateDeptResult> { return this.call("POST", `/depts`, { dept, name, budget }); }
  /** `route POST "/staff" => hire`  */
  hire(emp: number, name: string, dept: number, salary: number): Promise<HireResult> { return this.call("POST", `/staff`, { emp, name, dept, salary }); }
  /** `route GET "/depts/{dept}/payroll" => view payroll`  */
  payroll(dept: number): Promise<PayrollResult> { return this.call("GET", `/depts/${encodeURIComponent(String(dept))}/payroll`, {  }); }

  private async call(method: string, path: string, args: Record<string, unknown>): Promise<any> {
    const headers: Record<string, string> = { "content-type": "application/json" };
    if (this.token) headers["authorization"] = `Bearer ${this.token}`;
    const entries = Object.entries(args).filter(([, v]) => v !== undefined && v !== null);
    let url = this.baseUrl + path; let body: string | undefined;
    if (method === "GET" || method === "DELETE") {
      const q = new URLSearchParams(); for (const [k, v] of entries) q.set(k, Array.isArray(v) ? v.join(",") : String(v));
      const qs = q.toString(); if (qs) url += (url.includes("?") ? "&" : "?") + qs;
    } else { body = JSON.stringify(Object.fromEntries(entries)); }
    const res = await fetch(url, { method, headers, body });
    const json = await res.json().catch(() => null);
    if (res.status === 200 || res.status === 409) return json;
    throw new VishyError(res.status, json);
  }
}

HireResult is every outcome hire can answer, the declared ones and the four implicit ones, each with ok already decided; a switch on outcome narrows the type. PayrollResult carries number | null because the view answers an option. And the OpenAPI description of the /depts route, the program’s first POST route, from the same run:

{
 "POST": "/depts",
 "requestBody": {
  "properties": {
   "budget": {
    "format": "int64",
    "type": "integer"
   },
   "dept": {
    "format": "int64",
    "type": "integer"
   },
   "name": {
    "maxLength": 64,
    "type": "string"
   }
  },
  "required": [
   "dept",
   "name",
   "budget"
  ],
  "type": "object"
 },
 "responses": {
  "200": "the flow succeeded",
  "400": "a missing or malformed argument",
  "409": "the flow failed with a declared outcome; nothing was written"
 }
}

The bound on name is there as maxLength; the 409 says what a failed outcome means. A generator for any platform reads this file. The Rust crate in rust/ is the same projection for a Rust caller across a network.

What we do not have, plainly: no Swift or Kotlin projection. The path today is openapi.json through a standard generator for those platforms; a native projection is a toolchain addition for when a customer needs it (ROADMAP.md, “Not doing”). The toolchain appendix and the handbook say how the clients are regenerated when the interface changes.