@api.global/typedopenapi
Generates JSON Schema 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. - Objects are closed (
additionalProperties: false) unless the type has an index signature, so a server that validates its responses notices a property it was never meant to send. Clients must ignore properties they do not know: a new response property is an additive change. - 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.
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.
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.
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: schemaTypes: ['IProblem'].
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.
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'] },
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: true,
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 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 like every object.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. - 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, 412revision_mismatchon arevisionroute, 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 }.idempotentrequires anIdempotency-Keyheader,revisionanIf-Matchheader; both are for writes. - Deprecation. A route marked
deprecated, or whose contract is@deprecated, is a deprecated operation.
Response headers (ETag, Link, RateLimit, the request ID, Deprecation and Sunset) are not documented yet: the OpenAPI types of @push.rocks/smartserve have no Header Object.
The document does not depend on the order of the routes or of the security schemes: paths and schemas 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, 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, and a convention the contract does not follow.
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:
- 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.
- Responses: a success status removed or added breaks, since a client may refuse a success status it does not know. A refusal (4xx, 5xx) status added or removed is additive, and so is a new refusal code.
- 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. 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 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.
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).
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. 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 prints the usage and typedopenapi --version the version.
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.