@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>becomesIPage_IItem. - Recursive shared types keep all their definitions when several contracts or
schemaTypesroots 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-generatornames 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, becomesAnonymous_<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: falseas 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), everyschemaTypestype but those ofclientToServer, and every named schema these reach, nested objects included: these have noadditionalProperties. 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 itsadditionalPropertiesschema; one ofanyorunknown(Record<string, any>),objectand 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 withoutadditionalPropertiesis an empty interface or type literal and nothing else. A map declares every property, so aoneOfof a closed object and a map that requires a property is refused once open, whatever the map's values (see Unions), and the schemas ofnotandifstay 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
enumof their one value:type: 'ping'is{ "type": "string", "enum": ["ping"] }. JSON Schema reads it asconst, and code generators read it as a fixed value; typify 0.8 panics on a schema with bothconstandenum, and dropsconstalone, 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 ananyOfthat no value decodes into. The named types it refers to stay components of their own. - Unions become
oneOfwhen no value can match two alternatives, andanyOfotherwise. 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. SoTFrame = IPing | IPong, withtype: 'ping'andtype: 'pong', is aoneOf, and so is a union of such unions; a union with a primitive,nullor an object without such a property staysanyOf. A generator that seesoneOfcan decode a value as the one alternative that matches it. Whether a union is aoneOfis decided on the closed objects; aoneOfthat 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 aoneOfwith 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 OpenAPIdiscriminatorobject: neither typify nor@api.global/swiftsupportreads it, it needs one property that every alternative names directly, which a union of unions lacks, and itsmappingneeds 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, withitemsfor a rest element. - JSDoc becomes
description.@deprecatedbecomesdeprecated: true, and its text is appended to the description (Deprecated: use saveItems). JSON Schema tags such as@pattern,@minimum,@maxLengthor@formatbecome their keywords. - Branded types such as
string & { readonly __brand: 'ItemId' }become their primitive. NonNullable<T>, which TypeScript writes asT & {}, isTwithoutnullandundefined: of an optional property's literal union (NonNullable<IContact['kind']>) its literals, and of an array the array. ts-json-schema-generator readsT & {}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, andPartial<T>makes them so.- Import types that name a type,
import('./data.js').IItemandimport('./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
serverInitiatedpattern that matches no contract; - a type of
serverToClientorclientToServerthat is no type ofschemaTypes, or is in both; - a
oneOfthat 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').IItemand an imported alias all readIItem, an enum memberEPriority.High, and a default export its own name rather thandefault. 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 anextends, are declared too. typeofqueries read the type they stand for ((typeof STATUSES)[number]becomes(readonly ["open", "paid"])[number],typeof DEFAULT_SETTINGbecomesISetting), and the named types it reaches are declared: the declarations hold no values. Types of TypeScript's own libraries, such asRecord,PickorDate, are left as they are.omitleaves request properties a transport fills itself out of every request, as a route'somitdoes: a type literal loses the property, a named type that holds one becomesOmit<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.omitRequestPathsleaves 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 propertypathParamsnames, or the one of its own name. OnGETandDELETEevery other request property is a query parameter; onPOST,PUTandPATCHthose named inqueryare, and the rest is the JSON body, closed or open as the contract's request is.omitdrops 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 asapplication/json.alternativeSuccessStatusesnames further statuses the route answers a success with instead, each with the same content and headers, such assuccessStatus: 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.
contentdocuments bytes sent as they are instead of JSON, such as a file uploaded or a PDF downloaded, by their media types: eachtype/subtypein lowercase without parameters, a range such asimage/*or*/*included, and none of them JSON, which the contract's request and response are.content.responsemakes 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 itsdescription(defaultSuccess). Such a success carriesContent-Disposition(required, RFC 6266:attachment; filename="invoice.pdf"orinline) andContent-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.requestmakes the request body, required, content of its media types, onPOST,PUTandPATCH; every request property that no path parameter fills is then a query parameter, as onGET. 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'serrors. - 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, itsrequestand, unless its success iscontent.response, itsresponse: named schemas of the contracts, types ofschemaTypes. The request is split into parameters and body as a contract's is, and the success answers the response's schema;descriptionis 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. AoneOfthese reach must keep its alternatives apart once open, as for the contract schemas (see Unions). - Security.
{ scheme, scopes }becomes the operation'ssecurity,[{ [scheme]: scopes }], the scopes inresource:actionform;'public'becomessecurity: []. The schemes are those ofsecuritySchemes. - Refusals are
application/problem+json, one response per status whose schema is the problem schema withstatusfixed andcodelimited to the status's codes: the route'serrors, plus 400invalid_inputon a route with input, 401unauthorizedon a route with credentials, 403insufficient_scopeon a route with scopes, 409idempotency_key_in_flightand 422idempotency_key_reusedon anidempotentroute, 412revision_mismatchon a write withrevision, and 429rate_limitedon every route.codesrenames the conventions' codes. The problem schema is a built-in RFC 9457Problemwith acode, or the named schemaproblemSchemanames, which must havecodeandstatusproperties. - Conventions.
paginationtakeslimitandcursoras query parameters and requires the response{ items, nextCursor }.revisionsays the route's resource has a revision: its success answers it as anETag, and a write also requires the revision it was made from in anIf-Matchheader.idempotenttakes anIdempotency-Keyheader (draft-ietf-httpapi-idempotency-key-header-07):truerequires it,'optional'takes it where it is sent; it is for writes. - Revision. The
ETag(RFC 9110) is required on the success of arevisionroute, a read or a write, but not on aDELETE, which leaves no resource to tag. It is a strong entity tag, sinceIf-Matchcompares tags strongly: visible ASCII without"between double quotes, not empty (STRONG_ETAG_PATTERN), such asETag: "42"for revision 42. A client reads the resource, keeps itsETagand sends it back asIf-Matchwith its write; a write whoseIf-Matchis not the current revision is refused with 412revision_mismatch, and the client reads the resource again. The 412 carries noETag: 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 asIdempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324". A server that validates requests against the document, as@push.rocks/smartservedoes, 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, ininfo.descriptionfor instance: the draft requires it (§2.5.2). - Deprecation. A route marked
deprecated, or whose contract is@deprecated, is a deprecated operation.deprecatedtakes the dates of the deprecation instead oftrue,{ since: '2026-10-01', sunset: '2027-04-01' }, each a UTC dateYYYY-MM-DD(midnight) orYYYY-MM-DDTHH:MM:SSZ. Every response of such a route then carriesDeprecation(RFC 9745, the date the route is deprecated from as a structured Date,@1790812800; it may lie ahead) and, with asunset,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 withdeprecationHeadersOf(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/smartserveset them in the route'sadmissionthroughctx.responseHeaders, which reaches every answer after the route matched; an answer given before routing cannot carry them. A deprecation without dates, bytrueor by the contract, has no date to send, so its responses carry neither header. - Response headers are declared once, as Header Objects in
components/headersunder 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 arevisionroute,Link(optional,<URL>; rel="next"per RFC 8288, the request's URL withcursorset tonextCursor) on the success of apaginationroute,Retry-After(required, whole seconds) on every 429, andContent-DispositionandContent-Length(see Content) on the success of acontent.responseroute; a document declares only those its responses carry. The 409 of anidempotentroute carries aRetry-Aftertoo, 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.DeprecationandSunsetare 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
schemaTypesadds, and the requiredapplication/jsonrequest 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: truefor a header every delivery carries. A declaration'swebhooksnames the webhooks that carry it; without one, every webhook does. They are written into each webhook, not declared incomponents: acomponents/headersentry is a Header Object of responses, and a reusable request header is acomponents/parametersentry referred to by a Reference Object, which theparametersof an operation do not admit in@push.rocks/smartserve'sIOpenApiSpec. - The receiver's answers are the convention's
WEBHOOK_RESPONSES:2XXacknowledges the delivery, anddefault, 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'sdescriptionorinfo.description. summary,descriptionandtagsare the operation's. A webhook has nooperationIdand nosecurity.
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
operationIdbreaks; 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 replacesimage/pngis reported asimage/pngremoved andimage/*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-Typeis 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, asheader 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 responsepatternreplaced 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,multipleOfof integers anduniqueItemsare weighed; aformat, a fractionalmultipleOfor a pattern that does not compile cannot be checked against a value it applies to (any value for aformat, a number formultipleOf, a string for a pattern), and nomultipleOfcan 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 anenumof 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 betweenanyOfandoneOfis one union, and its alternatives are compared as above. When no value matches two alternatives of the side that isoneOf, the keyword means the same and is no change; otherwiseoneOfrefuses a value more than one alternative matches, which accepts less, andanyOfaccepts it, which accepts more. An object withoutadditionalProperties, one withadditionalProperties: trueand one with a schema of them that restricts no value ({}) take the same further properties, so a change between them is no change;falseor a restricting schema against any of them is reported as before. A literal written asconston one side and as anenumof 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 stringenumof 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 readwebhook article.changedandwebhook 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.
License and Legal Information
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.