npm.io
7.1.0 • Published 2 weeks ago

@lokalise/api-contracts

Licence
Apache-2.0
Version
7.1.0
Deps
0
Size
132 kB
Vulns
0
Weekly
0
Stars
11

api-contracts

API contracts are shared definitions that live in a shared package and are consumed by both the client and the backend. The contract describes a route — its path, HTTP method, and request/response schemas — and serves as the single source of truth for both sides.

The backend implements the route against the contract. The client uses the same contract to make type-safe requests without duplicating configuration. This eliminates assumptions across the boundary and keeps documentation, validation, and types in sync.

Defining contracts

REST routes

import { defineApiContract, noBodyResponse } from '@lokalise/api-contracts'
import { z } from 'zod/v4'

// GET with path params
const getUser = defineApiContract({
  summary: 'Get user',
  method: 'get',
  requestPathParamsSchema: z.object({ userId: z.uuid() }),
  pathResolver: ({ userId }) => `/users/${userId}`,
  responsesByStatusCode: {
    200: z.object({ id: z.string(), name: z.string() }),
  },
})

// POST
const createUser = defineApiContract({
  summary: 'Create user',
  method: 'post',
  pathResolver: () => '/users',
  requestBodySchema: z.object({ name: z.string() }),
  responsesByStatusCode: {
    201: z.object({ id: z.string(), name: z.string() }),
  },
})

// DELETE with no response body
const deleteUser = defineApiContract({
  summary: 'Delete user',
  method: 'delete',
  requestPathParamsSchema: z.object({ userId: z.uuid() }),
  pathResolver: ({ userId }) => `/users/${userId}`,
  responsesByStatusCode: {
    204: noBodyResponse(),
  },
})
Non-JSON responses

Use blobResponse for any non-JSON response — text-based (plain text, CSV, HTML, XML, YAML, etc.) or binary (images, PDFs, etc.). It records the response content-type in the contract and hands the consumer a lazy BlobResponseHandle, so the caller decides how to consume the body — aggregate it (await body.blob() / .text() / .arrayBuffer()), pipe the raw body.stream(), or body.cancel() it. The body is one-shot: the first accessor consumes it, a second throws.

import { defineApiContract, blobResponse } from '@lokalise/api-contracts'

const exportCsv = defineApiContract({
  summary: 'Export users as CSV',
  method: 'get',
  pathResolver: () => '/export.csv',
  responsesByStatusCode: { 200: blobResponse('text/csv') },
})

const downloadPhoto = defineApiContract({
  summary: 'Download user photo',
  method: 'get',
  pathResolver: () => '/photo.png',
  responsesByStatusCode: { 200: blobResponse('image/png') },
})
Multiple content types

When a single status code can return more than one media type, map it to a content object keyed by media type. Each value is a body descriptor: a bare Zod schema (JSON), blobBody() (opaque binary or text), or sseBody() (Server-Sent Events). Add allowNoBody: true to also accept an empty body.

import { defineApiContract, blobBody, sseBody } from '@lokalise/api-contracts'
import { z } from 'zod/v4'

const downloadReport = defineApiContract({
  summary: 'Download report',
  method: 'get',
  pathResolver: () => '/report',
  responsesByStatusCode: {
    200: {
      description: 'Report in the requested format',
      content: {
        'application/json':         z.object({ rows: z.array(z.string()) }),
        'application/vnd.api+json': z.object({ data: z.object({ rows: z.array(z.string()) }) }),
        'text/csv':                 blobBody(),
        'application/pdf':          blobBody(),
        'text/event-stream':        sseBody({ row: z.object({ value: z.string() }) }),
      },
      allowNoBody: true,
    },
  },
})

Media types are matched exactly — parameters stripped, case-insensitive — so distinct keys such as application/json and application/vnd.api+json never collide, and a single status code may expose any number of media types (including several JSON variants). The shape maps 1:1 to the OpenAPI Response Object. The matched media type is not surfaced on the client response; read it from headers['content-type'] if you need to discriminate.

SSE and dual-mode routes

Use sseResponse() inside responsesByStatusCode for an SSE-only response. For a route that returns either JSON or an SSE stream depending on the Accept header, use a content map (above) with both an application/json and a text/event-stream entry.

import { defineApiContract, sseResponse, sseBody } from '@lokalise/api-contracts'
import { z } from 'zod/v4'

// SSE-only
const notifications = defineApiContract({
  summary: 'Stream notifications',
  method: 'get',
  pathResolver: () => '/notifications/stream',
  responsesByStatusCode: {
    200: sseResponse({
      notification: z.object({ id: z.string(), message: z.string() }),
    }),
  },
})

// Dual-mode: JSON response or SSE stream depending on Accept header
const chatCompletion = defineApiContract({
  summary: 'Create chat completion',
  method: 'post',
  pathResolver: () => '/chat/completions',
  requestBodySchema: z.object({ message: z.string() }),
  responsesByStatusCode: {
    200: {
      content: {
        'application/json': z.object({ text: z.string() }),
        'text/event-stream': sseBody({
          chunk: z.object({ delta: z.string() }),
          done: z.object({ finish_reason: z.string() }),
        }),
      },
    },
  },
})
Wildcard and default response keys

In addition to exact status codes, responsesByStatusCode accepts OpenAPI-style range keys ('1xx''5xx') and 'default' as fallbacks.

Lookup precedence at runtime: exact code → range key → 'default'.

import { defineApiContract } from '@lokalise/api-contracts'
import { z } from 'zod/v4'

// '2xx' covers all 200–299 responses
const listItems = defineApiContract({
  summary: 'List items',
  method: 'get',
  pathResolver: () => '/items',
  responsesByStatusCode: {
    '2xx': z.object({ items: z.array(z.string()) }),
    '4xx': z.object({ message: z.string() }),
  },
})

// exact code takes precedence over the range key
const createItem = defineApiContract({
  summary: 'Create item',
  method: 'post',
  pathResolver: () => '/items',
  requestBodySchema: z.object({ name: z.string() }),
  responsesByStatusCode: {
    201: z.object({ id: z.string() }),
    '4xx': z.object({ message: z.string() }),
  },
})

// 'default' matches any status code not covered by a more specific entry
const flexible = defineApiContract({
  summary: 'Get data',
  method: 'get',
  pathResolver: () => '/data',
  responsesByStatusCode: {
    200: z.object({ data: z.unknown() }),
    default: z.object({ error: z.string() }),
  },
})

The '2xx' range key participates in SSE detection and success/error type narrowing exactly like explicit 2xx codes: InferNonSseClientResponse maps it to SuccessfulHttpStatusCode, and hasAnySuccessSseResponse returns true when it holds an SSE schema.

'default' is split into a success half (SuccessfulHttpStatusCode) and a non-success half in InferSseClientResponse / InferNonSseClientResponse so that captureAsError type narrowing stays correct regardless of the actual status code.

OpenAPI response descriptions

All response factories accept an optional ResponseOptions object as their last argument.

import { defineApiContract, noBodyResponse, blobBody, sseBody } from '@lokalise/api-contracts'
import { z } from 'zod/v4'

const contract = defineApiContract({
  summary: 'Upload file',
  method: 'post',
  pathResolver: () => '/files',
  requestBodySchema: z.object({ name: z.string() }),
  responsesByStatusCode: {
    201: z.object({ id: z.string() }).describe('Created resource'),
    204: noBodyResponse({ description: 'Deleted — no content returned' }),
    200: {
      description: 'Multiple response formats available',
      content: {
        'application/json': z.object({ id: z.string() }).describe('JSON representation'),
        'text/csv': blobBody(),
        'application/pdf': blobBody(),
        'text/event-stream': sseBody({ update: z.object({ id: z.string() }) }),
      },
    },
  },
})

A content-map entry carries a single description for the whole response; per-media descriptions aren't supported (a JSON descriptor can still carry its own via .describe()).

getSseSchemaByEventName(contract) extracts SSE event schemas from a contract:

import { getSseSchemaByEventName } from '@lokalise/api-contracts'

getSseSchemaByEventName(notifications)
// { notification: ZodObject<...> }

getSseSchemaByEventName(chatCompletion)
// { chunk: ZodObject<...>, done: ZodObject<...> }
All fields
type ApiContractOptions = {
  // Required
  method: 'get' | 'post' | 'put' | 'patch' | 'delete'
  pathResolver: (pathParams: Record<string, string>) => string
  // Human-readable summary; surfaced in fe/be http-client errors for debugging.
  summary: string
  // Accepts exact codes, OpenAPI-style range keys ('1xx'–'5xx'), and a catch-all 'default'.
  // Lookup precedence at runtime: exact code → range key → 'default'.
  responsesByStatusCode: Partial<
    Record<
      HttpStatusCode | '1xx' | '2xx' | '3xx' | '4xx' | '5xx' | 'default',
      z.ZodType | ResponseEntry
    >
  >

  // Path params — links pathResolver parameter type to the schema
  requestPathParamsSchema?: z.ZodObject<z.ZodRawShape>

  // Request
  requestBodySchema?: z.ZodType | ContractNoBody  // required for POST / PUT / PATCH, forbidden otherwise
  requestQuerySchema?: z.ZodObject<z.ZodRawShape>
  requestHeaderSchema?: z.ZodObject<z.ZodRawShape>

  // Response
  responseHeaderSchema?: z.ZodObject<z.ZodRawShape>

  // Documentation
  description?: string
  tags?: readonly string[]
  metadata?: Record<string, unknown>
}
Header schemas
const contract = defineApiContract({
  summary: 'Get data',
  method: 'get',
  pathResolver: () => '/api/data',
  requestHeaderSchema: z.object({
    authorization: z.string(),
    'x-api-key': z.string(),
  }),
  responseHeaderSchema: z.object({
    'x-ratelimit-remaining': z.string(),
    'cache-control': z.string(),
  }),
  responsesByStatusCode: {
    200: dataSchema,
  },
})
Type utilities

InferNonSseSuccessResponses<T> — TypeScript output type of all non-SSE 2xx responses. JSON schemas → z.output<T>, a blob entry → BlobResponseHandle, a no-body entry → undefined, an SSE entry → never (excluded). Content-map entries are unpacked before mapping.

import type { InferNonSseSuccessResponses } from '@lokalise/api-contracts'

type UserResponse = InferNonSseSuccessResponses<typeof getUser['responsesByStatusCode']>
// { id: string; name: string }

type CsvResponse = InferNonSseSuccessResponses<typeof exportCsv['responsesByStatusCode']>
// BlobResponseHandle

InferJsonSuccessResponses<T> — union of Zod schema types for all JSON 2xx entries. Blob, SSE, and no-body entries are excluded.

InferSseSuccessResponses<T> — extracts the SSE event schema map type from a responsesByStatusCode map. Returns never when no SSE schemas are present.

HasAnySseSuccessResponse<T>true if any 2xx entry (exact code or '2xx' range key) is an SSE response (a sseResponse() / content-map SSE descriptor).

HasAnyJsonSuccessResponse<T>true if any 2xx entry is a JSON Zod schema or a content-map JSON descriptor.

HasAnyNonSseSuccessResponse<T>true if any 2xx entry is a non-SSE response (JSON, blob, or no-body).

ContractResponseMode<T> — classifies a contract into 'dual' (SSE + non-SSE), 'sse' (SSE-only), or 'non-sse' (JSON/blob/no-body).

AvailableResponseModes<T> — union of mode literals available for a contract: 'json' | 'sse' | 'blob' | 'noContent'.

SseEventOf<S> — discriminated union of SSE events inferred from a schemaByEventName map. Aligns with the browser MessageEvent shape: { type, data, lastEventId, retry }.

import type { SseEventOf, InferSseSuccessResponses } from '@lokalise/api-contracts'

type NotificationEvents = InferSseSuccessResponses<typeof notifications['responsesByStatusCode']>
type NotificationEvent = SseEventOf<NotificationEvents>
// { type: 'notification'; data: { id: string; message: string }; lastEventId: string; retry: number | undefined }
Client types

These types are primarily consumed by HTTP client implementations.

ClientRequestParams<TApiContract, TIsStreaming> — infers the request parameter object for a contract. Includes pathParams, body, queryParams, headers (required when the corresponding schema is defined), pathPrefix (always optional), and streaming (required for dual-mode contracts, forbidden otherwise).

InferSseClientResponse<TApiContract> — discriminated union of { statusCode, headers, body } for SSE mode. Exact 2xx codes and the '2xx' range key yield AsyncIterable<SseEventOf<...>>; error codes, other range keys, and 'default' yield the declared body type. 'default' is split into a SuccessfulHttpStatusCode half and a non-success half.

InferNonSseClientResponse<TApiContract> — same shape as above for non-SSE mode. Exact 2xx codes and the '2xx' range key yield JSON / BlobResponseHandle / null (SSE excluded); error codes, other range keys, and 'default' yield the declared body type as-is. 'default' is split the same way.

DefaultStreaming<T>true for SSE-only contracts, false for everything else.

import type { ClientRequestParams, InferNonSseClientResponse } from '@lokalise/api-contracts'

type GetUserParams = ClientRequestParams<typeof getUser, false>
// { pathParams: { userId: string }; pathPrefix?: string }

type GetUserResponse = InferNonSseClientResponse<typeof getUser>
// { statusCode: 200; headers: Record<string, string>; body: { id: string; name: string } }
Contract type aliases

ApiContract — union of all contract variants (GetApiContract | DeleteApiContract | PayloadApiContract). Use this to type function parameters that accept any contract.

GetApiContract, DeleteApiContract, PayloadApiContract — individual contract variants if you need to narrow the type.

RequestPathParamsSchema, RequestQuerySchema, RequestHeaderSchema, ResponseHeaderSchema — type aliases for z.ZodObject. Use these to constrain schema arguments in generic helpers.

Utility functions

mapApiContractToPath — Express/Fastify-style path pattern.

import { mapApiContractToPath } from '@lokalise/api-contracts'

mapApiContractToPath(getUser) // "/users/:userId"

describeApiContract — human-readable "METHOD /path" string.

import { describeApiContract } from '@lokalise/api-contracts'

describeApiContract(getUser) // "GET /users/:userId"

hasAnySuccessSseResponsetrue when any 2xx entry (exact code or '2xx' range key) is an SSE response (including inside a content map).

import { hasAnySuccessSseResponse } from '@lokalise/api-contracts'

hasAnySuccessSseResponse(notifications)   // true
hasAnySuccessSseResponse(getUser)         // false
hasAnySuccessSseResponse(chatCompletion)  // true (dual-mode)

getSseSchemaByEventName — extracts SSE event schemas from a contract. Returns null when no SSE schemas are present.

import { getSseSchemaByEventName } from '@lokalise/api-contracts'

getSseSchemaByEventName(notifications) // { notification: ZodObject<...> }
getSseSchemaByEventName(getUser)       // null

Module augmentation

If you require more precise type definitions for the metadata field, you can utilize TypeScript's module augmentation mechanism to enforce stricter typing:

// file -> apiContracts.d.ts
import '@lokalise/api-contracts';

declare module '@lokalise/api-contracts' {
  interface CommonRouteDefinitionMetadata {
    myTestProp?: string[];
    mySecondTestProp?: number;
  }
}

HTTP clients

To make contract-based requests, use a compatible HTTP client (@lokalise/frontend-http-client or @lokalise/backend-http-client).

For Fastify backends, use @lokalise/fastify-api-contracts to simplify route definition using contracts as the single source of truth.

Future: request body content type

Currently, HTTP clients default to application/json when a request body is present. The planned improvement is a requestBodyContentType field on defineApiContract:

defineApiContract({
  summary: 'Upload avatar',
  method: 'post',
  pathResolver: () => '/upload',
  requestBodySchema: z.object({ file: z.unknown() }),
  requestBodyContentType: 'multipart/form-data',
  responsesByStatusCode: { 200: z.object({ url: z.string() }) },
})

Keywords