@api.global/typedopenapi

Generates JSON Schema, TypeScript declarations and OpenAPI 3.1 documents from typed-request contracts and a route table.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Install

pnpm add --save-dev @api.global/typedopenapi

What it does

An API built on @api.global/typedrequest describes each method as a contract: an interface with a method, a request and a response, declared with implementsTR<ITypedRequest, …> from @api.global/typedrequest-interfaces. @api.global/typedopenapi reads those contracts from their TypeScript sources at build time and turns them into JSON Schema (draft 2020-12), and assembles an OpenAPI 3.1 document from them and a route table. The document is typed as IOpenApiSpec of @push.rocks/smartserve, which serves routes from it and validates their requests and responses against it.

Usage

JSON Schemas of the contracts

import { generateContractSchemas } from '@api.global/typedopenapi';

const { contracts, schemas } = generateContractSchemas({
  tsconfig: './tsconfig.json',
  contracts: './ts_interfaces/requests/*.ts',
});

for (const contract of contracts) {
  console.log(contract.method, contract.request, contract.response);
}
// `schemas` holds the named types the contracts share, for `components/schemas`

Every export of the contract files whose name matches contractNamePattern (default /^IReq_/) must be a contract: an interface with a string-literal method, a request and a response. Other exports are left alone, functions and constants included.

The output is JSON Schema draft 2020-12, the dialect of OpenAPI 3.1:

  • Named types the requests and responses reach become entries of schemas, and every reference reads #/components/schemas/<name>. A generic instance gets a name a component can carry: IPage<IItem> becomes IPage_IItem.
  • Recursive shared types keep all their definitions when several contracts or schemaTypes roots reach them. Each root has its own formatter traversal; all roots share the TypeScript program, parser and collision-checked schema namespace.
  • Anonymous type arguments. An instance of a generic with an anonymous type argument, such as TEnvelope<'note', { text: string }>, is named by the generic, the arguments that are literals or named types, and 12 hex digits of the SHA-256 of its content: TEnvelope_note_3f1c9a0b6d2e. ts-json-schema-generator names such an argument by an id of where the type is, the hash of its file's path relative to the working directory and its text positions, which change with a dependency's install path, the working directory and any edit above the type; the content hash changes with the content alone, descriptions included. A type the generator names by such an id alone, such as an interface that refers to itself and is not exported, becomes Anonymous_<hash>. Instances of one content that refer to schemas of the same names are one schema, wherever they are written, and an instance that refers to itself, or two that refer to each other, are named alike however the contracts reach them.
  • What a client decodes is open, what only a client sends is closed. typify writes an object closed by additionalProperties: false as a struct that refuses unknown fields, so a client generated from a closed response would refuse every value once its server adds a property. A client decodes the response of a contract it calls, the request of a contract its server calls (serverInitiated), every schemaTypes type but those of clientToServer, and every named schema these reach, nested objects included: these have no additionalProperties. Every other object is closed (additionalProperties: false), so a server that validates its requests refuses a property it does not take. A named schema reached from both sides is open on both. A type with an index signature keeps its additionalProperties schema; one of any or unknown (Record<string, any>), object and an interface that extends or an intersection that holds such a map are written as a map of any value, additionalProperties: {}, so an object written without additionalProperties is an empty interface or type literal and nothing else. A map declares every property, so a oneOf of a closed object and a map that requires a property is refused once open, whatever the map's values (see Unions), and the schemas of not and if stay as they are, where an open object would make the schema accept less. Clients must ignore properties they do not know: a new response property is an additive change.
  • Literals become an enum of their one value: type: 'ping' is { "type": "string", "enum": ["ping"] }. JSON Schema reads it as const, and code generators read it as a fixed value; typify 0.8 panics on a schema with both const and enum, and drops const alone, so a decoder it generates would accept any string. A union of string literals alone, written out or through $refs to named literal types (TStatus = TOpenStatus | 'closed'), becomes one { "type": "string", "enum": [...] } of their values, each once, in order: typify makes a struct of such an anyOf that no value decodes into. The named types it refers to stay components of their own.
  • Unions become oneOf when no value can match two alternatives, and anyOf otherwise. Alternatives exclude each other when each is an object, or a reference or union of objects, and of any two objects from different alternatives one requires a property that the other restricts to other literal values, forbids (false) or does not have while it is closed. So TFrame = IPing | IPong, with type: 'ping' and type: 'pong', is a oneOf, and so is a union of such unions; a union with a primitive, null or an object without such a property stays anyOf. A generator that sees oneOf can decode a value as the one alternative that matches it. Whether a union is a oneOf is decided on the closed objects; a oneOf that holds an open object stays only where, for every ordered pair of alternatives A and B, B requires a property that A does not declare, or both require a property whose literal values differ. Generation refuses one that does not, naming the two alternatives, and a oneOf with open objects that holds an alternative that is no object. A value then matches two alternatives only when its producer sends a property its type does not declare, which a TypeScript producer cannot do without a cast. The documents carry no OpenAPI discriminator object: neither typify nor @api.global/swiftsupport reads it, it needs one property that every alternative names directly, which a union of unions lacks, and its mapping needs a named schema for every alternative. Literals and unions are written after the components are named, so a component's name and its content hash do not depend on them.
  • Tuples become prefixItems, with items for a rest element.
  • JSDoc becomes description. @deprecated becomes deprecated: true, and its text is appended to the description (Deprecated: use saveItems). JSON Schema tags such as @pattern, @minimum, @maxLength or @format become their keywords.
  • Branded types such as string & { readonly __brand: 'ItemId' } become their primitive.
  • NonNullable<T>, which TypeScript writes as T & {}, is T without null and undefined: of an optional property's literal union (NonNullable<IContact['kind']>) its literals, and of an array the array. ts-json-schema-generator reads T & {} as an intersection with an empty object, so the generation parses it itself; string & {} beside literals stays a string that keeps them ('email' | (string & {})).
  • Required<T>, a mapped type with the -? modifier, requires every property, which ts-json-schema-generator does not; a mapped type without a modifier ({ [K in keyof T]: T[K] }) keeps the source's optional properties optional, and Partial<T> makes them so.
  • Import types that name a type, import('./data.js').IItem and import('./data.js').IPage<IItem>, are references to the type they name, which ts-json-schema-generator has no parser for. typeof import('./data.js') names no type and is refused as an unknown node.

The contracts are read with the TypeScript compiler that ts-json-schema-generator brings along, without type-checking them again: the project's own build checks them.

Contracts reached through a symlink are read at their real path: the directories of the glob before its first segment with a glob character, or a plain file path, are resolved through their links. A package that pnpm links into node_modules is one: ./node_modules/@example/interfaces/ts_interfaces/requests/*.ts is read in node_modules/.pnpm/…, where the package's own dependencies sit, so its imports of them resolve.

Generation refuses, naming the cause:

  • a tsconfig that is no file, and contracts that match no file;
  • contract files without an export named like a contract;
  • an export named like a contract that is none;
  • two different types of one name, since the named schemas share one namespace;
  • a type that JSON Schema cannot express, such as a function;
  • a serverInitiated pattern that matches no contract;
  • a type of serverToClient or clientToServer that is no type of schemaTypes, or is in both;
  • a oneOf that open objects no longer keep apart (see Unions above).

schemaTypes adds further named types to schemas, found by name among the contract files and the files they import, such as the problem type of the refusals, the payloads of webhooks or the schemas of a route described by named schemas: schemaTypes: ['IProblem']. A document holds such a type only where something refers to it (see below).

The direction a schema travels in decides whether it is open (see above). Contracts are calls a client makes unless their name matches serverInitiated, a pattern of contract names; a contract the server calls sends its request to the client and receives its response. serverToClient and clientToServer name the schemaTypes types a server sends and those only a client sends, such as the frames of a WebSocket; a type of neither list is taken as sent both ways, so it is open:

generateContractSchemas({
  tsconfig: './tsconfig.json',
  contracts: './ts_interfaces/requests/*.ts',
  schemaTypes: ['TWsServerFrame', 'TWsClientFrame'],
  serverInitiated: /^IReq_Push/,
  serverToClient: ['TWsServerFrame'],
  clientToServer: ['TWsClientFrame'],
});

A clientToServer type, or a request, stays closed only where nothing a client decodes reaches it: a named schema a client also decodes is open.

Write a file header as a plain comment (/* … */), not as JSDoc (/** … */). TypeScript attaches a JSDoc block to the declaration after it, so a header before a contract without JSDoc of its own becomes that contract's description.

TypeScript declarations of the contracts

import { generateContractDeclarations } from '@api.global/typedopenapi';

const { contracts, types } = generateContractDeclarations({
  tsconfig: './tsconfig.json',
  contracts: './ts_interfaces/requests/*.ts',
  omit: ['csrfToken'],
});
// contracts[0]: {
//   name: 'IReq_GetItem', method: 'getItem', file: 'ts_interfaces/requests/catalogue.ts',
//   documentation: 'Reads one item.',
//   request: { type: '{ organizationId: string; itemId: TItemId }' },
//   response: { type: '{ item: IItem }', documentation: 'the item' },
// }
// types.IItem: '/** An item of the catalogue. */\ninterface IItem extends IItemContent { … }'

The schemas tell a validator what a contract takes; the declarations tell a reader, such as a language model that calls the API or the author of a client, what the contract says, as its authors wrote it. For each contract they hold its request and response as written, with the JSDoc of their members and of the properties themselves, the contract's JSDoc with its tags (@example, @deprecated), deprecated where it is set, and the file that declares it, its real path relative to the tsconfig's real directory. types holds the declarations of the named types the requests and responses reach, and those these reach, each with its JSDoc and without export or declare; an interface declared in parts holds every part.

  • One namespace. A reference to a named type reads the type's own name however the source qualifies or imports it: data.IItem, import('./data.js').IItem and an imported alias all read IItem, an enum member EPriority.High, and a default export its own name rather than default. Two different types of one name are refused, as the named schemas refuse them, so every named schema has a declaration of its name; a generic is declared once with its type parameters (interface IPage<T>) where the schemas name each instance (IPage_IItem). Types the schemas inline, such as the base of an extends, are declared too.
  • typeof queries read the type they stand for ((typeof STATUSES)[number] becomes (readonly ["open", "paid"])[number], typeof DEFAULT_SETTING becomes ISetting), and the named types it reaches are declared: the declarations hold no values. Types of TypeScript's own libraries, such as Record, Pick or Date, are left as they are.
  • omit leaves request properties a transport fills itself out of every request, as a route's omit does: a type literal loses the property, a named type that holds one becomes Omit<IOrderRef, 'csrfToken'>, a member of an intersection that holds nothing else is dropped, and a request that holds nothing else is {}. The named type itself keeps its declaration.
  • omitRequestPaths leaves properties out of particular methods' requests, including nested inputs, without changing a shared declaration, a sibling property or a response:
const declarations = generateContractDeclarations({
  tsconfig: './tsconfig.json',
  contracts: './ts_interfaces/requests/*.ts',
  omit: ['csrfToken'],
  omitRequestPaths: {
    saveConnection: [['connection', 'password'], ['organizationId']],
    saveConnections: [['connections', '*', 'token']],
  },
});

Each TRequestOmissionPath is an array of property names, not a dot-separated string: ['connection.password'] names one literal property, and ['connection', 'password'] enters the object. * enters every array or tuple element and must be followed by a property path. A path must exist in at least one union variant; variants without it stay as they are. The source checker validates references, intersections, generic instances and finite paths through recursive types. Optional and readonly properties, discriminated union variants, array/tuple modifiers and unaffected required fields stay intact. Numeric property names use string segments, such as ['1', 'password'].

A projected request is written without the omitted properties, so its declaration does not show them. A type literal loses the members a path ends at, and the members a path enters get their types projected in place. Intersections, unions, parenthesized types, arrays, readonly arrays and tuples are projected part by part. As with omit, an intersection part that keeps nothing is dropped and a request that keeps nothing is {}. A named type, or any other type a path reaches into, keeps its own declaration and is projected where it is used:

  • Omit<IConnection, 'password'> when it is one object type without an index signature that loses only its own properties;
  • TContractRequestWithoutPaths<IConnectionRequest, [["connection","password"]]>, listing the paths that reach into it, otherwise.
// request: IAuthenticatedRequest & { organizationId: string; costCentreId: string; expectedRevision: number },
// where IAuthenticatedRequest holds only csrfToken
// omit: ['csrfToken', 'expectedRevision'], omitRequestPaths: { archiveCostCentre: [['organizationId']] }
{ costCentreId: string }

The two distributive mapped helper declarations, TContractRequestWithoutPaths and TContractRequestWithoutPath, are included in types only when a projection uses them. Consumers must carry them along with the original named declarations. A part that is left out declares none of the types it alone reaches. The global omit properties are left out by the same projection. Methods without configured paths keep the previous rendering. Paths are deduplicated and sorted. Generation refuses unknown methods, malformed or missing paths, prototype-related segments (__proto__, prototype, constructor), a terminal *, unresolved types (any, unknown, unbound type parameters) and index signatures on the path that would let an omitted key back in. A source type colliding with a generated helper name is refused rather than overwritten.

This option projects declarations only. It does not filter runtime requests or change generateContractSchemas(); a transport or tool catalog must apply the same policy to its input schemas and validate, drop or fill the omitted inputs through its trusted call boundary.

Generation refuses, naming the cause, what generateContractSchemas() refuses about the inputs and the contracts, and a contract that inherits its request or response instead of declaring it, two different types of one name, a named type that is no interface, type alias or enum, such as a class, and a typeof query whose type the checker can only name by an import path.

The OpenAPI 3.1 document

import { generateContractSchemas, generateOpenApiDocument, type IRouteDefinition } from '@api.global/typedopenapi';

const routes: IRouteDefinition[] = [
  {
    verb: 'GET',
    path: '/organizations/{orgId}/articles/{articleId}',
    method: 'getArticle',
    pathParams: { orgId: 'organizationId' },
    omit: ['csrfToken'],
    security: { scheme: 'bearerToken', scopes: ['articles:read'] },
    revision: true,
    errors: { 404: ['article_not_found'] },
  },
  {
    verb: 'PUT',
    path: '/organizations/{orgId}/articles/{articleId}',
    method: 'saveArticle',
    pathParams: { orgId: 'organizationId' },
    omit: ['csrfToken'],
    security: { scheme: 'bearerToken', scopes: ['articles:write'] },
    idempotent: 'optional',
    revision: true,
    errors: { 409: ['sku_taken'] },
  },
];

const document = generateOpenApiDocument({
  info: { title: 'Books', version: '1.0.0' },
  securitySchemes: { bearerToken: { type: 'http', scheme: 'bearer' } },
  contracts: generateContractSchemas({ tsconfig: './tsconfig.json', contracts: './ts_interfaces/requests/*.ts' }),
  routes,
});

Each route calls one typed method, or is described by named schemas (see below), and becomes one operation, its operationId the method (or operationId where two routes call one method):

  • Parameters. Each {name} of the path fills the request property pathParams names, or the one of its own name. On GET and DELETE every other request property is a query parameter; on POST, PUT and PATCH those named in query are, and the rest is the JSON body, closed or open as the contract's request is. omit drops properties the REST adapter fills itself, such as a CSRF token. A path or query parameter must be a scalar: a string, number, integer, boolean, or an enum of them. A property's JSDoc becomes the parameter's description.
  • The success (successStatus, default 200) answers the contract's response as application/json. alternativeSuccessStatuses names further statuses the route answers a success with instead, each with the same content and headers, such as successStatus: 201, alternativeSuccessStatuses: [200] for a write that answers 201 for what it creates and 200 for what it finds already there. The conventions' headers of a success (ETag, Link, Content-Disposition, Content-Length) are on every one of them.
  • Content. content documents bytes sent as they are instead of JSON, such as a file uploaded or a PDF downloaded, by their media types: each type/subtype in lowercase without parameters, a range such as image/* or */* included, and none of them JSON, which the contract's request and response are. content.response makes the success answer content of its media types instead of the contract's response, each media type without a schema (OpenAPI 3.1 lets binary content omit it): { 'application/pdf': {} }, described by its description (default Success). Such a success carries Content-Disposition (required, RFC 6266: attachment; filename="invoice.pdf" or inline) and Content-Length (optional: the server sends it where it knows the length and sends the bytes as they are, not compressed). The contract still gives the parameters, the scope and the refusals: a download calls a typed read of what it answers (its file name, media type and size), and the server answers the bytes it names. content.request makes the request body, required, content of its media types, on POST, PUT and PATCH; every request property that no path parameter fills is then a query parameter, as on GET. Refusals of content, such as 413 for a body too large or 415 for a media type the route does not take, are the route's errors.
  • Routes described by named schemas. A route that calls no typed method, such as an upload whose bytes do not travel as a typed request, names its operationId, its request and, unless its success is content.response, its response: named schemas of the contracts, types of schemaTypes. The request is split into parameters and body as a contract's is, and the success answers the response's schema; description is the operation's.
  • What a client decodes is open. The success responses, the problem schema of the refusals, the webhook payloads and every named schema these reach have no additionalProperties: false, nested objects and the page { items, nextCursor } included, so a client generated from the document takes a property the API adds later. A request body stays as its contract has it. A oneOf these reach must keep its alternatives apart once open, as for the contract schemas (see Unions).
  • Security. { scheme, scopes } becomes the operation's security, [{ [scheme]: scopes }], the scopes in resource:action form; 'public' becomes security: []. The schemes are those of securitySchemes.
  • Refusals are application/problem+json, one response per status whose schema is the problem schema with status fixed and code limited to the status's codes: the route's errors, plus 400 invalid_input on a route with input, 401 unauthorized on a route with credentials, 403 insufficient_scope on a route with scopes, 409 idempotency_key_in_flight and 422 idempotency_key_reused on an idempotent route, 412 revision_mismatch on a write with revision, and 429 rate_limited on every route. codes renames the conventions' codes. The problem schema is a built-in RFC 9457 Problem with a code, or the named schema problemSchema names, which must have code and status properties.
  • Conventions. pagination takes limit and cursor as query parameters and requires the response { items, nextCursor }. revision says the route's resource has a revision: its success answers it as an ETag, and a write also requires the revision it was made from in an If-Match header. idempotent takes an Idempotency-Key header (draft-ietf-httpapi-idempotency-key-header-07): true requires it, 'optional' takes it where it is sent; it is for writes.
  • Revision. The ETag (RFC 9110) is required on the success of a revision route, a read or a write, but not on a DELETE, which leaves no resource to tag. It is a strong entity tag, since If-Match compares tags strongly: visible ASCII without " between double quotes, not empty (STRONG_ETAG_PATTERN), such as ETag: "42" for revision 42. A client reads the resource, keeps its ETag and sends it back as If-Match with its write; a write whose If-Match is not the current revision is refused with 412 revision_mismatch, and the client reads the resource again. The 412 carries no ETag: it carries no representation, and the client needs the current one to redo its change.
  • Idempotency-Key. The draft makes the key an RFC 8941 String (§2.1): printable ASCII between double quotes, " and \ escaped by a backslash. The header's schema narrows that String to what a key needs: not empty, without parameters, at most 255 characters with the quotes (IDEMPOTENCY_KEY_PATTERN, IDEMPOTENCY_KEY_MAX_LENGTH), such as Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324". A server that validates requests against the document, as @push.rocks/smartserve does, refuses any other value with 400 before the route runs. The route's refusals are the draft's (§2.7): 409 for a key whose first request is still being carried out, and 422 for a key sent with another request than its first. What the server does with a key (its scope, how long it keeps an answer) is its own to publish, in info.description for instance: the draft requires it (§2.5.2).
  • Deprecation. A route marked deprecated, or whose contract is @deprecated, is a deprecated operation. deprecated takes the dates of the deprecation instead of true, { since: '2026-10-01', sunset: '2027-04-01' }, each a UTC date YYYY-MM-DD (midnight) or YYYY-MM-DDTHH:MM:SSZ. Every response of such a route then carries Deprecation (RFC 9745, the date the route is deprecated from as a structured Date, @1790812800; it may lie ahead) and, with a sunset, Sunset (RFC 8594, the date the route is expected to stop answering as an HTTP-date, Thu, 01 Apr 2027 00:00:00 GMT), both required and fixed to the route's values. The sunset may not be earlier than the deprecation (RFC 9745). The server sends what the document describes with deprecationHeadersOf(route), which returns the two values for the route table's entry (none for a route deprecated without dates), on every response of the route, refusals included: with @push.rocks/smartserve set them in the route's admission through ctx.responseHeaders, which reaches every answer after the route matched; an answer given before routing cannot carry them. A deprecation without dates, by true or by the contract, has no date to send, so its responses carry neither header.
  • Response headers are declared once, as Header Objects in components/headers under the header's name, and each response that carries one refers to it ({ $ref: '#/components/headers/Retry-After' }). The conventions bring five (CONVENTION_RESPONSE_HEADERS): ETag (required, see above) on the success of a revision route, Link (optional, <URL>; rel="next" per RFC 8288, the request's URL with cursor set to nextCursor) on the success of a pagination route, Retry-After (required, whole seconds) on every 429, and Content-Disposition and Content-Length (see Content) on the success of a content.response route; a document declares only those its responses carry. The 409 of an idempotent route carries a Retry-After too, for a key in flight; it is optional there, since the route's other conflicts do not send it, so it is written into the response (IN_FLIGHT_RETRY_AFTER_HEADER) rather than referring to the 429's component, which requires it. Deprecation and Sunset are written into the responses too, since their values are the route's.

Further response headers, such as the rate-limit fields an API sends to every credential, are declared in responseHeaders, by name: the Header Object, the routes whose responses carry it (routes: 'all', the default, 'credentialed', the routes whose security is not 'public', 'idempotent', the routes whose idempotent is set, for a marker of an answer given again for a key, 'revision', the routes with a revision, 'deprecated', the deprecated operations, with dates or without, or 'content', the routes whose success answers content, for X-Content-Type-Options or Cache-Control of a download, say), and either the only statuses that carry it (statuses) or the statuses that do not (exceptStatuses). Without either, every response of those routes carries it.

const document = generateOpenApiDocument({
  // … info, securitySchemes, contracts, routes
  responseHeaders: {
    'RateLimit-Policy': {
      header: { description: 'The quota policy of the credential.', required: false, schema: { type: 'string' } },
      routes: 'credentialed',
      exceptStatuses: [401],
    },
    'X-Request-Id': { header: { required: true, schema: { type: 'string' } } },
  },
});

A response's headers are sorted by name. Generation refuses, naming the header: a name a header component cannot carry (letters, digits, ., - and _ only), Content-Type (OpenAPI ignores it), a header of the conventions (Deprecation and Sunset included, and Content-Disposition and Content-Length where a route answers content; a document without one declares them as any other header), two names that differ only in case, statuses together with exceptStatuses, a status that is no HTTP status, and a header no response carries.

Not documented yet: no request ID header of its own (responseHeaders declares one for every route), no If-None-Match and 304 on a revision read, and no Link to a deprecation's documentation (RFC 9745's deprecation relation), since the conventions' Link is the next page's.

A download and an upload as content routes: the download calls the typed read of what it answers, and the upload, which calls no typed method, is described by named schemas.

const routes: TRouteDefinition[] = [
  {
    verb: 'GET',
    path: '/organizations/{orgId}/documents/{documentId}/content',
    method: 'getDocumentContent', // answers the file name, media type and size the server sends the bytes by
    pathParams: { orgId: 'organizationId' },
    security: { scheme: 'bearerToken', scopes: ['documents:read'] },
    errors: { 404: ['document_not_found'] },
    content: { response: { mediaTypes: ['application/pdf', 'image/*'], description: 'The document as it was uploaded.' } },
  },
  {
    verb: 'POST',
    path: '/organizations/{orgId}/documents',
    operationId: 'uploadDocument',
    request: 'IUploadDocumentRequest', // { organizationId, filename }: `filename` becomes a query parameter
    response: 'IUploadDocumentResult', // a type of `schemaTypes`, like the request
    pathParams: { orgId: 'organizationId' },
    security: { scheme: 'bearerToken', scopes: ['documents:write'] },
    successStatus: 201,
    alternativeSuccessStatuses: [200], // the same bytes again answer the document already held
    errors: { 413: ['file_too_large'], 415: ['unsupported_content'] },
    content: { request: { mediaTypes: ['application/pdf', 'image/*'], description: 'The file, in its own media type.' } },
  },
];

The download's success is { description: 'The document as it was uploaded.', headers: { 'Content-Disposition': { $ref: … }, 'Content-Length': { $ref: … } }, content: { 'application/pdf': {}, 'image/*': {} } }, and the upload's body { description: 'The file, in its own media type.', required: true, content: { 'application/pdf': {}, 'image/*': {} } }, with 201 and 200 both answering IUploadDocumentResult as application/json. @push.rocks/smartserve serves such a document as it is: it validates the parameters, leaves a body that is not JSON to the route unread, and checks a success that is not JSON by its status alone. The media types a route takes are the server's to check (a 415), from the same route table.

components.schemas holds only the named schemas the paths, the webhooks and the declared headers refer to, and those these refer to in turn. A named schema of the contracts nothing refers to, such as a type of a method no route calls, or a type of schemaTypes no route, webhook or header names, is left out. This always applies, with no option to keep the others: a caller who needs every named type takes generateContractSchemas().schemas, which still holds every one. diffOpenApiDocuments() compares what the operations and webhooks reach, so a schema left out is no change.

The document does not depend on the order of the routes or of the security schemes: paths, schemas and headers are sorted by name, a path's operations follow get, post, put, patch, delete, and only tags keep the given order. A reordered route table is therefore no change of the document. The document is a copy and shares no object with its options.

Generation refuses, naming the route: a method that is no contract, a route that names a method and a request or response too, or neither a method nor a request, a route described by named schemas without its operationId, without a response and a content.response, with both, or with a request or response that is no named schema, two routes of one verb and path or one operation ID, two paths that differ only in their parameters' names (/items/{id} and /items/{itemId}), a request property a route names that the request lacks, a parameter that is no scalar, an idempotent that is neither true nor 'optional', idempotent on a GET, a deprecation date that is no UTC date of that form or a sunset before the deprecation, an alternative success status that is no 200, 201 or 202, is the successStatus or is named twice, a content with neither a request nor a response, a content.request on GET or DELETE or together with query, a content.response on a pagination route, media types that are none, are named twice, are not in lowercase, carry parameters or are JSON, and a convention the contract does not follow.

Webhooks

An API that sends requests of its own, such as event notifications, describes them in the document's webhooks (OpenAPI 3.1), a map of named Path Items of the requests the API sends. webhooks declares them by name, the event type, each with the named schema of its payload; webhookHeaders declares the request headers their deliveries carry, such as the signature headers of Standard Webhooks.

const document = generateOpenApiDocument({
  // … info, securitySchemes, routes
  contracts: generateContractSchemas({
    tsconfig: './tsconfig.json',
    contracts: './ts_interfaces/requests/*.ts',
    schemaTypes: ['TArticleChangedEvent', 'TWebhookPingEvent'],
  }),
  webhooks: {
    'article.changed': { payload: 'TArticleChangedEvent', description: 'Sent once per article written.', tags: ['Articles'] },
    'webhook.ping': { payload: 'TWebhookPingEvent', summary: 'A test of the endpoint' },
  },
  webhookHeaders: {
    'webhook-id': { header: { description: 'The event\'s id, the same on every attempt.', required: true, schema: { type: 'string' } } },
    'webhook-timestamp': { header: { description: 'When the attempt was sent, in seconds since the epoch.', required: true, schema: { type: 'integer' } } },
    'webhook-signature': { header: { description: 'The signatures of the delivery: `v1,<base64>`.', required: true, schema: { type: 'string' } } },
  },
});
// ts_interfaces/data/webhooks.ts, reached from a contract file
export interface IWebhookEvent<TType extends keyof IWebhookEventData> {
  id: string;
  type: TType;
  timestamp: string;
  organizationId: string;
  data: IWebhookEventData[TType];
}
/** An article was created, replaced or archived. */
export type TArticleChangedEvent = IWebhookEvent<'article.changed'>;

Each webhook is a POST:

  • The payload is a named schema of the contracts, which schemaTypes adds, and the required application/json request body refers to it ({ $ref: '#/components/schemas/TArticleChangedEvent' }). The body's description is the payload's JSDoc, or, for an alias without one, that of the type it names. A generic instance gets its own named schema (IWebhookEvent_article.changed), and the alias refers to it, so name one alias per event type.
  • The headers are header parameters of the request ({ name, in: 'header', … }), sorted by name, each the Header Object its declaration gives: required: true for a header every delivery carries. A declaration's webhooks names the webhooks that carry it; without one, every webhook does. They are written into each webhook, not declared in components: a components/headers entry is a Header Object of responses, and a reusable request header is a components/parameters entry referred to by a Reference Object, which the parameters of an operation do not admit in @push.rocks/smartserve's IOpenApiSpec.
  • The receiver's answers are the convention's WEBHOOK_RESPONSES: 2XX acknowledges the delivery, and default, any other answer, fails the attempt, and the delivery is sent again. How often, and for how long, is the API's to publish, in the webhook's description or info.description.
  • summary, description and tags are the operation's. A webhook has no operationId and no security.

The webhooks are sorted by name and add no path and no header component; without webhooks the document has no webhooks. Generation refuses, naming the webhook, a webhook without a name and a payload that is no named schema; and, naming the header, a name that is no header name (an RFC 9110 token), Accept, Content-Type and Authorization (OpenAPI ignores them as header parameters), two names that differ only in case, a webhook a header names that the document lacks, and a header no webhook carries.

A document of the contract schemas

import { generateContractSchemas, generateContractSchemaDocument } from '@api.global/typedopenapi';

const document = generateContractSchemaDocument({
  info: { title: 'Books contracts' },
  contracts: generateContractSchemas({
    tsconfig: './tsconfig.json',
    contracts: './ts_interfaces/requests/*.ts',
    schemaTypes: ['TWsClientFrame'],
  }),
});

generateContractSchemaDocument() writes every contract and every named schema into one OpenAPI 3.1 document without routes, for code generators that work on the contracts rather than on HTTP operations. Each contract becomes a component named after its interface (IReq_…), an object that requires method (an enum of the method), request and response, open as a contract's response is, since a client decodes one of its parts, with the contract's description and deprecated; the named schemas sit beside them. paths is empty. The components are sorted, and info.version defaults to sha256-<hex> of the SHA-256 of the components, so it changes exactly when a schema does. Generation refuses a contract whose interface name is also the name of a named schema.

The evolution diff

import { readFile } from 'node:fs/promises';
import { diffOpenApiDocuments } from '@api.global/typedopenapi';

const committed = JSON.parse(await readFile('openapi.snapshot.json', 'utf8'));
const { breaking, additive } = diffOpenApiDocuments(committed, document);
// outside a major: expect(breaking).toEqual([])

diffOpenApiDocuments(previous, next) lists the changes from one document to the next, each with its location and a message, sorted, as breaking or additive for a client written against the earlier document. It reaches the named schemas through the operations and webhooks, so it refuses a document that has components but no paths and no webhooks, such as one of generateContractSchemaDocument(), rather than report no change whatever changed:

  • Operations are matched by method and path; a renamed path parameter is the same path. A removed operation breaks, an added one is additive. A changed operationId breaks; a newly deprecated operation is additive. Security that asks for more (credentials on a public operation, a new scope, another scheme) breaks; asking for less is additive.
  • Parameters and request bodies: removed, newly required, or accepting less breaks; new optional ones, or accepting more, are additive. A media type of a request body removed breaks, one added is additive, so a JSON body turned into content breaks, and so do the query parameters its properties become where they are required.
  • Responses: a success status removed or added breaks, since a client may refuse a success status it does not know, an alternative success status included. A refusal (4xx, 5xx) status added or removed is additive, and so is a new refusal code. A media type of a response removed or added breaks, so a success turned into content, or back into JSON, breaks.
  • Media types are matched by their exact key. A range is no exception, so image/* that replaces image/png is reported as image/png removed and image/* added, and a request body breaks though the range takes what the type took. A media type without a schema, such as content, takes any value: a schema it gains accepts less, which breaks a request body and is additive for a response, and a schema it loses accepts more, additive for a request body and breaking for a response. Any request schema that becomes the empty one, a body's or a property's, takes every value it took before and is additive whatever it held (the schema no longer restricts the value).
  • Response headers are matched by name, regardless of case, and a reference is compared by the header it names; Content-Type is ignored, as OpenAPI ignores it. A header removed or no longer required breaks, and so does a header schema that promises less; a header added, newly required or deprecated is additive, since clients ignore headers they do not know. A header component reached from several responses is reported once, as header Retry-After in responses.
  • Schemas are followed through $refs. A request schema that accepts less breaks: a property removed or newly required, a required property added, a type or value removed, a bound tightened, a pattern added or changed. A response schema that promises less breaks: a property removed or no longer required, a type added, a bound loosened, an object opened (further properties are allowed). A document of this version compared with one of 0.11.0 or earlier, whose responses were closed, reports so for each response object it opens: a client generated from the closed schema refuses a property the open one allows. A new response property, a new or removed response enum value, a narrower response type or a tighter response bound is additive, so clients must ignore properties and handle values they do not know. A constraint that changes is weighed against the values a side lists (enum, const): one removed or loosened that every value the next schema lists meets allows no further value (pattern "^[A-Z]{3}$" is removed, which allows no further value), one added or tightened that every value the previous schema listed meets refuses none, and either is additive. So a response pattern replaced by an enum whose values all match it only narrows, and is additive; on a request the enum accepts less and breaks. The pattern (as an ECMA-262 expression in Unicode mode), the type, numeric, length, item and property-count bounds, multipleOf of integers and uniqueItems are weighed; a format, a fractional multipleOf or a pattern that does not compile cannot be checked against a value it applies to (any value for a format, a number for multipleOf, a string for a pattern), and no multipleOf can be checked against a fraction. Where the side it is weighed against lists such a value, it keeps the classification of the constraint alone and the message ends in ; the diff cannot check it against the listed values; where that side lists no values, it is classified as before, without the note. Alternatives of a union (anyOf, oneOf) unchanged on both sides are matched. Of a discriminated union the rest match by their discriminator values, and those left over are removed or added; of any other union the rest pair up in order. Each matched pair is compared, so a change of a schema an alternative names is reported at that schema. An alternative removed breaks a request and is additive for a response; one added is additive for a request and breaks a response, unless the union is discriminated. A union is discriminated when every alternative is an object (type: 'object') that requires a property holding a single value (const, or an enum of one) and no two alternatives of the previous union share a value of it. An alternative added to such a union that holds a value it did not have is additive (1 anyOf alternative(s) added, each with a value of "channel" the union did not have): a client sees a value and properties it does not know, which it must handle as above. An added alternative that reuses a value, a primitive, or an object without such a property breaks a response. A union whose keyword changes between anyOf and oneOf is one union, and its alternatives are compared as above. When no value matches two alternatives of the side that is oneOf, the keyword means the same and is no change; otherwise oneOf refuses a value more than one alternative matches, which accepts less, and anyOf accepts it, which accepts more. An object without additionalProperties, one with additionalProperties: true and one with a schema of them that restricts no value ({}) take the same further properties, so a change between them is no change; false or a restricting schema against any of them is reported as before. A literal written as const on one side and as an enum of its value on the other is no change, and so is a union of string literals, written out or through $refs, on one side and the string enum of its values on the other; a value one of them lacks is reported as an enum value removed or added. A composition the diff cannot pair up breaks, and so does a change of any other keyword that restricts values (not, propertyNames, patternProperties, if/then, contains …); annotations (description, title, examples, default, $comment, deprecated, readOnly, writeOnly) are no change.
  • Webhooks are matched by name, and their operations by method. The API sends a webhook's request and the receiver reads it, so the request is compared as a route's response is, and the receiver's answer as a route's request is. A webhook removed breaks, one added is additive. A header parameter or the body removed, or no longer required, breaks, since a receiver may rely on it; one added, or newly required, is additive. The payload is a response schema: a property removed or no longer required, a type added or a bound loosened breaks; a property added, required ones included, since more data arrives, a value removed, a narrower type or a tighter bound is additive, and a changed constraint is weighed against the listed values as above. So receivers must ignore properties, event types and values they do not know. A further media type of the body breaks, as for a response. Of the receiver's answers, a success status removed breaks and one added is additive, since the API takes it; a header the answer must carry breaks. Security a webhook no longer asks of the API breaks the receiver that relies on it, and more is additive. A named schema the payload reaches is reported as a response's, schema TArticleChangedEvent in responses, at the first named schema the body refers to; locations of a webhook read webhook article.changed and webhook article.changed POST request body.

A named schema reached from several operations is reported once, as schema IItem in requests or schema IItem in responses, with the property path (schema IItem in responses: .sku). A named schema renamed with its content unchanged is no change; a change made with the rename is reported where the schema is reached.

The command line

typedopenapi generate --config typedopenapi.config.ts --out dist_openapi/openapi.json
typedopenapi diff openapi.snapshot.json dist_openapi/openapi.json

generate writes the document atomically (a temporary file next to --out, renamed over it). It reads a configuration file that exports an ITypedOpenApiConfig as its default: the options of generateOpenApiDocument(), with contracts naming where the contracts are (the options of generateContractSchemas()). contracts.tsconfig and contracts.contracts are relative to the configuration file. A .json configuration holds the same object, without a contractNamePattern or a serverInitiated, which are patterns JSON cannot hold. A .ts configuration is loaded with Node.js's type stripping, which needs Node.js 22.18 or later and strip-only TypeScript: types and import type only, no enums, namespaces or parameter properties. A configuration Node.js cannot load exits 2, naming the file and the reason.

// typedopenapi.config.ts
import type { ITypedOpenApiConfig } from '@api.global/typedopenapi';

const config: ITypedOpenApiConfig = {
  contracts: { tsconfig: './tsconfig.json', contracts: './ts_interfaces/requests/*.ts' },
  info: { title: 'Books', version: '1.0.0' },
  securitySchemes: { bearerToken: { type: 'http', scheme: 'bearer' } },
  routes: [
    /* IRouteDefinition entries */
  ],
};

export default config;

diff prints each change as breaking: <location> | <message> or additive: …, then the counts; --json prints the IDocumentDiff instead. Exit codes: 0 done, 1 breaking changes (unless --allow-breaking), 2 a wrong call or an input it cannot read, whose cause goes to stderr. typedopenapi help and --help print the usage and typedopenapi --version the version; an option the command does not take is a wrong call.

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license.md file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
No description provided
Readme
2.7 MiB
Languages
TypeScript 99.8%
JavaScript 0.2%