@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> becomes IPage_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, 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.

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 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 like every object. 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.
  • 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, 412 revision_mismatch on a revision route, 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 }. idempotent requires an Idempotency-Key header, revision an If-Match header; 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 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.
  • 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.

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
343 KiB
Languages
TypeScript 99.3%
JavaScript 0.7%