openapi-contract-kit 0.0.1 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +13 -5
  3. package/dist/bin/openapi-contract-kit.d.ts +2 -0
  4. package/dist/bin/openapi-contract-kit.js +3 -0
  5. package/dist/src/cli/formatOutput.d.ts +6 -0
  6. package/dist/src/cli/formatOutput.js +32 -0
  7. package/dist/src/generator/config.d.ts +3 -0
  8. package/dist/src/generator/config.js +72 -0
  9. package/dist/src/generator/documents.d.ts +11 -0
  10. package/dist/src/generator/documents.js +92 -0
  11. package/dist/src/generator/emitEndpoints.d.ts +2 -0
  12. package/{src/generator/emitEndpoints.mjs → dist/src/generator/emitEndpoints.js} +48 -80
  13. package/dist/src/generator/emitMakers.d.ts +2 -0
  14. package/dist/src/generator/emitMakers.js +328 -0
  15. package/dist/src/generator/emitTypes.d.ts +3 -0
  16. package/dist/src/generator/emitTypes.js +87 -0
  17. package/dist/src/generator/generateOpenApiRuntime.d.ts +3 -0
  18. package/dist/src/generator/generateOpenApiRuntime.js +41 -0
  19. package/dist/src/generator/model.d.ts +2 -0
  20. package/dist/src/generator/model.js +278 -0
  21. package/dist/src/generator/schemaModel.d.ts +9 -0
  22. package/dist/src/generator/schemaModel.js +408 -0
  23. package/dist/src/generator/types.d.ts +82 -0
  24. package/dist/src/generator/writeOutput.d.ts +5 -0
  25. package/dist/src/generator/writeOutput.js +141 -0
  26. package/dist/src/index.d.ts +2 -0
  27. package/dist/src/index.js +1 -0
  28. package/dist/src/runtime.d.ts +14 -0
  29. package/dist/src/runtime.js +1 -0
  30. package/package.json +30 -18
  31. package/bin/openapi-contract-kit.mjs +0 -5
  32. package/src/generator/config.mjs +0 -92
  33. package/src/generator/documents.mjs +0 -119
  34. package/src/generator/emitMakers.mjs +0 -459
  35. package/src/generator/emitTypes.mjs +0 -104
  36. package/src/generator/generateOpenApiRuntime.mjs +0 -48
  37. package/src/generator/model.mjs +0 -415
  38. package/src/generator/schemaModel.mjs +0 -497
  39. package/src/generator/writeOutput.mjs +0 -196
  40. package/src/index.d.ts +0 -16
  41. package/src/index.mjs +0 -4
  42. package/src/runtime.d.ts +0 -14
  43. /package/{src/runtime.mjs → dist/src/generator/types.js} +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.0.4] - 2026-09-12
6
+
7
+ - Lowered the supported Node.js baseline to Node 24 and added CI coverage for Node 24 and 26.
8
+
9
+ ## [0.0.3] - 2026-09-12
10
+
11
+ - Fixed GitHub Release publishing from detached tag checkouts by disabling pnpm branch checks in the publish workflow.
12
+
13
+ ## [0.0.2] - 2026-09-12
14
+
15
+ - Added linting to the development and CI workflows.
16
+ - Streamlined the test workflow around the build and unit-test commands.
17
+ - Updated the CI and publishing workflows for the current toolchain.
18
+
5
19
  ## [0.1.0] - 2026-09-11
6
20
 
7
21
  - Initial public release.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # openapi-contract-kit
2
2
 
3
+ [![CI](https://github.com/Clinsmann/openapi-contract-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/Clinsmann/openapi-contract-kit/actions/workflows/ci.yml)
4
+
3
5
  JSON-first OpenAPI 3.1 contract generation and runtime validation for TypeScript applications.
4
6
 
5
7
  `openapi-contract-kit` reads a JSON OpenAPI document and generates:
@@ -14,9 +16,11 @@ JSON-first OpenAPI 3.1 contract generation and runtime validation for TypeScript
14
16
  ## Installation
15
17
 
16
18
  ```bash
17
- npm install --save-dev openapi-contract-kit
19
+ pnpm add -D openapi-contract-kit
18
20
  ```
19
21
 
22
+ Requires Node.js 24 or newer and pnpm 12.3.4.
23
+
20
24
  ## Quick start
21
25
 
22
26
  Create `openapi.config.json`:
@@ -42,7 +46,7 @@ Add a generation script:
42
46
  Generate contracts:
43
47
 
44
48
  ```bash
45
- npm run openapi:generate
49
+ pnpm run openapi:generate
46
50
  ```
47
51
 
48
52
  ## Generated validation
@@ -59,6 +63,8 @@ if (!result.ok) {
59
63
 
60
64
  Validation errors contain a path, keyword, and message. Successful validation returns the original input without mutating or cloning it.
61
65
 
66
+ The focused fixture and acceptance tests cover structural validation: object shape, declared fields, primitive types, arrays, nullability, references, unions, and additional-property behavior. They do not require form-level checks such as email format, string length, patterns, or numeric ranges. Those constraints remain available when declared in consumer schemas.
67
+
62
68
  ## Supported input
63
69
 
64
70
  The generator supports JSON OpenAPI 3.1 documents with objects, primitive types, nullable values, arrays, enums, constants, unions, local `$ref` references, additional-property rules, string and numeric constraints, and email format validation.
@@ -74,9 +80,11 @@ The generator supports JSON OpenAPI 3.1 documents with objects, primitive types,
74
80
  ## Development
75
81
 
76
82
  ```bash
77
- npm install
78
- npm test
79
- npm run pack:check
83
+ pnpm install
84
+ pnpm run lint
85
+ pnpm run typecheck
86
+ pnpm test
87
+ pnpm run pack:check
80
88
  ```
81
89
 
82
90
  ## Versioning
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/generator/generateOpenApiRuntime.js';
3
+ await main();
@@ -0,0 +1,6 @@
1
+ import type { GenerateOpenApiRuntimeResult } from '../generator/types.js';
2
+ export type CliOutputStream = {
3
+ readonly isTTY?: boolean;
4
+ };
5
+ export declare function formatSuccessOutput(result: GenerateOpenApiRuntimeResult, stream: CliOutputStream): string;
6
+ export declare function formatFailureOutput(message: string, stream: CliOutputStream): string;
@@ -0,0 +1,32 @@
1
+ import { styleText } from 'node:util';
2
+ function canUseColor(stream) {
3
+ return (stream.isTTY === true &&
4
+ process.env.NO_COLOR === undefined &&
5
+ process.env.TERM !== 'dumb');
6
+ }
7
+ function styleCliText(format, text, stream) {
8
+ return canUseColor(stream)
9
+ ? styleText(format, text, { validateStream: false })
10
+ : text;
11
+ }
12
+ function formatCount(count, singular) {
13
+ return `${count} ${singular}${count === 1 ? '' : 's'}`;
14
+ }
15
+ export function formatSuccessOutput(result, stream) {
16
+ if (stream.isTTY !== true) {
17
+ return `Generated ${result.schemaCount} schemas and ${result.operationCount} endpoints.\n`;
18
+ }
19
+ const marker = styleCliText('green', '✔', stream);
20
+ const heading = styleCliText('bold', 'OpenAPI generation complete', stream);
21
+ const schemas = styleCliText('dim', `Schemas: ${formatCount(result.schemaCount, 'schema')}`, stream);
22
+ const endpoints = styleCliText('dim', `Endpoints: ${formatCount(result.operationCount, 'endpoint')}`, stream);
23
+ return `${marker} ${heading}\n\n ${schemas}\n ${endpoints}\n`;
24
+ }
25
+ export function formatFailureOutput(message, stream) {
26
+ if (stream.isTTY !== true) {
27
+ return `OpenAPI generation failed: ${message}\n`;
28
+ }
29
+ const marker = styleCliText('red', '✖', stream);
30
+ const heading = styleCliText('bold', 'OpenAPI generation failed', stream);
31
+ return `${marker} ${heading}\n\n ${message}\n`;
32
+ }
@@ -0,0 +1,3 @@
1
+ import type { GenerateOpenApiRuntimeOptions, GeneratorConfig, UnknownRecord } from './types.js';
2
+ export declare function isRecord(value: unknown): value is UnknownRecord;
3
+ export declare function resolveGeneratorConfig(options?: GenerateOpenApiRuntimeOptions): Promise<GeneratorConfig>;
@@ -0,0 +1,72 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, resolve } from 'node:path';
3
+ const CONFIG_KEYS = new Set(['specPath', 'outDir', 'runtimeImport']);
4
+ const FLAG_NAMES = new Map([
5
+ ['--config', 'configPath'],
6
+ ['--spec', 'specPath'],
7
+ ['--out', 'outDir'],
8
+ ['--runtime-import', 'runtimeImport'],
9
+ ]);
10
+ export function isRecord(value) {
11
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
12
+ }
13
+ function parseArguments(argv, cwd) {
14
+ const values = {};
15
+ for (let index = 0; index < argv.length; index += 2) {
16
+ const flag = argv[index];
17
+ const key = flag === undefined ? undefined : FLAG_NAMES.get(flag);
18
+ const value = argv[index + 1];
19
+ if (key === undefined) {
20
+ throw new Error(`Unknown argument "${flag ?? ''}"`);
21
+ }
22
+ if (typeof value !== 'string' || value.length === 0) {
23
+ throw new Error(`Missing value for "${flag}"`);
24
+ }
25
+ values[key] = isAbsolute(value) ? value : resolve(cwd, value);
26
+ }
27
+ return values;
28
+ }
29
+ function requirePath(config, key, configPath) {
30
+ const value = config[key];
31
+ if (typeof value !== 'string' || value.trim().length === 0) {
32
+ throw new Error(`Config "${configPath}" requires a non-empty "${key}"`);
33
+ }
34
+ return value;
35
+ }
36
+ export async function resolveGeneratorConfig(options = {}) {
37
+ const argv = options.argv ?? [];
38
+ const cwd = options.cwd ?? process.cwd();
39
+ const cli = parseArguments(argv, cwd);
40
+ const configPath = cli.configPath ?? resolve(cwd, 'openapi.config.json');
41
+ let config;
42
+ try {
43
+ config = JSON.parse(await readFile(configPath, 'utf8'));
44
+ }
45
+ catch (error) {
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ throw new Error(`Unable to read OpenAPI config "${configPath}": ${message}`);
48
+ }
49
+ if (!isRecord(config)) {
50
+ throw new Error(`OpenAPI config "${configPath}" must contain an object`);
51
+ }
52
+ for (const key of Object.keys(config)) {
53
+ if (!CONFIG_KEYS.has(key)) {
54
+ throw new Error(`Unknown OpenAPI config key "${key}"`);
55
+ }
56
+ }
57
+ const configDirectory = dirname(configPath);
58
+ const resolveConfigPath = (key) => {
59
+ const value = requirePath(config, key, configPath);
60
+ return isAbsolute(value) ? value : resolve(configDirectory, value);
61
+ };
62
+ const runtimeImport = cli.runtimeImport ?? requirePath(config, 'runtimeImport', configPath);
63
+ if (runtimeImport.includes('\\') || runtimeImport.startsWith('.')) {
64
+ throw new Error(`Config "${configPath}" requires a package runtimeImport`);
65
+ }
66
+ return {
67
+ configPath,
68
+ specPath: cli.specPath ?? resolveConfigPath('specPath'),
69
+ outDir: cli.outDir ?? resolveConfigPath('outDir'),
70
+ runtimeImport,
71
+ };
72
+ }
@@ -0,0 +1,11 @@
1
+ import type { ResolvedReference, UnknownRecord } from './types.js';
2
+ export declare function isRecord(value: unknown): value is UnknownRecord;
3
+ export declare function escapePointerSegment(segment: string): string;
4
+ export declare function pointerChild(pointer: string, segment: string | number): string;
5
+ export declare function locationOf(documentPath: string, pointer: string): string;
6
+ export declare function requireRecord(value: unknown, location: string, label: string): UnknownRecord;
7
+ export declare class DocumentStore {
8
+ #private;
9
+ load(documentPath: string): Promise<UnknownRecord>;
10
+ resolveReference(reference: unknown, fromDocumentPath: string): Promise<ResolvedReference>;
11
+ }
@@ -0,0 +1,92 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { dirname, extname, resolve } from 'node:path';
3
+ export function isRecord(value) {
4
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
5
+ }
6
+ export function escapePointerSegment(segment) {
7
+ return segment.replaceAll('~', '~0').replaceAll('/', '~1');
8
+ }
9
+ export function pointerChild(pointer, segment) {
10
+ return `${pointer}/${escapePointerSegment(String(segment))}`;
11
+ }
12
+ export function locationOf(documentPath, pointer) {
13
+ return `${documentPath}#${pointer}`;
14
+ }
15
+ export function requireRecord(value, location, label) {
16
+ if (!isRecord(value)) {
17
+ throw new Error(`${label} at ${location} must be an object`);
18
+ }
19
+ return value;
20
+ }
21
+ export class DocumentStore {
22
+ #documents = new Map();
23
+ async load(documentPath) {
24
+ const absolutePath = resolve(documentPath);
25
+ const existing = this.#documents.get(absolutePath);
26
+ if (existing !== undefined) {
27
+ return existing;
28
+ }
29
+ const pending = this.#read(absolutePath);
30
+ this.#documents.set(absolutePath, pending);
31
+ return pending;
32
+ }
33
+ async #read(documentPath) {
34
+ let source;
35
+ try {
36
+ source = await readFile(documentPath, 'utf8');
37
+ }
38
+ catch (error) {
39
+ const message = error instanceof Error ? error.message : String(error);
40
+ throw new Error(`Unable to read OpenAPI document "${documentPath}": ${message}`);
41
+ }
42
+ try {
43
+ const extension = extname(documentPath).toLowerCase();
44
+ if (extension !== '.json') {
45
+ throw new Error('Only JSON OpenAPI documents are supported');
46
+ }
47
+ return requireRecord(JSON.parse(source), documentPath, 'OpenAPI document');
48
+ }
49
+ catch (error) {
50
+ const message = error instanceof Error ? error.message : String(error);
51
+ throw new Error(`Unable to parse OpenAPI document "${documentPath}": ${message}`);
52
+ }
53
+ }
54
+ async resolveReference(reference, fromDocumentPath) {
55
+ if (typeof reference !== 'string' || reference.length === 0) {
56
+ throw new Error(`Invalid $ref at ${fromDocumentPath}`);
57
+ }
58
+ if (/^[A-Za-z][A-Za-z\d+.-]*:/u.test(reference)) {
59
+ throw new Error(`Remote $ref "${reference}" is unsupported`);
60
+ }
61
+ const hashIndex = reference.indexOf('#');
62
+ const filePart = hashIndex === -1 ? reference : reference.slice(0, hashIndex);
63
+ const fragment = hashIndex === -1 ? '' : reference.slice(hashIndex + 1);
64
+ const documentPath = filePart.length === 0
65
+ ? fromDocumentPath
66
+ : resolve(dirname(fromDocumentPath), decodeURIComponent(filePart));
67
+ const document = await this.load(documentPath);
68
+ const decodedFragment = decodeURIComponent(fragment);
69
+ if (decodedFragment.length > 0 && !decodedFragment.startsWith('/')) {
70
+ throw new Error(`Anchor $ref "${reference}" is unsupported`);
71
+ }
72
+ let value = document;
73
+ for (const encodedSegment of decodedFragment.split('/').slice(1)) {
74
+ const segment = encodedSegment
75
+ .replaceAll('~1', '/')
76
+ .replaceAll('~0', '~');
77
+ if (!isRecord(value) && !Array.isArray(value)) {
78
+ throw new Error(`Unresolved $ref "${reference}" from ${fromDocumentPath}`);
79
+ }
80
+ if (!Object.prototype.hasOwnProperty.call(value, segment)) {
81
+ throw new Error(`Unresolved $ref "${reference}" from ${fromDocumentPath}`);
82
+ }
83
+ value = Reflect.get(value, segment);
84
+ }
85
+ return {
86
+ canonicalKey: locationOf(documentPath, decodedFragment),
87
+ documentPath,
88
+ pointer: decodedFragment,
89
+ value,
90
+ };
91
+ }
92
+ }
@@ -0,0 +1,2 @@
1
+ import type { GeneratorConfig, OpenApiModel } from './types.js';
2
+ export declare function emitEndpointModules(model: OpenApiModel, config: GeneratorConfig): Map<string, string>;
@@ -1,93 +1,64 @@
1
- import { join } from 'node:path';
2
-
3
1
  function renderResponseType(responses) {
4
- if (responses.length === 0) {
5
- return 'never';
6
- }
7
-
8
- return responses
9
- .map(({ schemaName, status }) => {
10
- const bodyType = schemaName ?? 'null';
11
- return `{ readonly status: ${status}; readonly body: ${bodyType} }`;
2
+ if (responses.length === 0) {
3
+ return 'never';
4
+ }
5
+ return responses
6
+ .map(({ schemaName, status }) => {
7
+ const bodyType = schemaName ?? 'null';
8
+ return `{ readonly status: ${status}; readonly body: ${bodyType} }`;
12
9
  })
13
- .join(' | ');
10
+ .join(' | ');
14
11
  }
15
-
16
12
  function renderStatusCases(responses) {
17
- const lines = [];
18
-
19
- for (const response of responses) {
20
- lines.push(` case ${response.status}: {`);
21
- if (response.schemaName === null) {
22
- lines.push(
23
- ` if (parts.body !== null) {`,
24
- ` return failure(['body'], 'nullBody', 'Expected a null response body');`,
25
- ` }`,
26
- ` return { ok: true, value: { status: ${response.status}, body: null } };`
27
- );
28
- } else {
29
- lines.push(
30
- ` const result = make${response.schemaName}(parts.body);`,
31
- ` if (!result.ok) {`,
32
- ` return result;`,
33
- ` }`,
34
- ` return { ok: true, value: { status: ${response.status}, body: result.value } };`
35
- );
13
+ const lines = [];
14
+ for (const response of responses) {
15
+ lines.push(` case ${response.status}: {`);
16
+ if (response.schemaName === null) {
17
+ lines.push(` if (parts.body !== null) {`, ` return failure(['body'], 'nullBody', 'Expected a null response body');`, ` }`, ` return { ok: true, value: { status: ${response.status}, body: null } };`);
18
+ }
19
+ else {
20
+ lines.push(` const result = make${response.schemaName}(parts.body);`, ` if (!result.ok) {`, ` return result;`, ` }`, ` return { ok: true, value: { status: ${response.status}, body: result.value } };`);
21
+ }
22
+ lines.push(' }');
36
23
  }
37
- lines.push(' }');
38
- }
39
-
40
- lines.push(
41
- ` default:`,
42
- ` return failure(['status'], 'status', 'Undocumented response status');`
43
- );
44
- return lines.join('\n');
24
+ lines.push(` default:`, ` return failure(['status'], 'status', 'Undocumented response status');`);
25
+ return lines.join('\n');
45
26
  }
46
-
47
- function emitEndpoint(operation, { outDir, runtimeImport }) {
48
- const successResponses = operation.responses.filter(
49
- (response) => response.isSuccess
50
- );
51
- const errorResponses = operation.responses.filter(
52
- (response) => !response.isSuccess
53
- );
54
- const schemaNames = new Set();
55
-
56
- if (operation.request.schemaName !== null) {
57
- schemaNames.add(operation.request.schemaName);
58
- }
59
- for (const response of operation.responses) {
60
- if (response.schemaName !== null) {
61
- schemaNames.add(response.schemaName);
27
+ function emitEndpoint(operation, config) {
28
+ const successResponses = operation.responses.filter((response) => response.isSuccess);
29
+ const errorResponses = operation.responses.filter((response) => !response.isSuccess);
30
+ const schemaNames = new Set();
31
+ if (operation.request.schemaName !== null) {
32
+ schemaNames.add(operation.request.schemaName);
62
33
  }
63
- }
64
-
65
- const sortedSchemaNames = [...schemaNames].sort();
66
- const typeImport =
67
- sortedSchemaNames.length === 0
68
- ? ''
69
- : `import type { ${sortedSchemaNames.join(', ')} } from '../quickpay-api';\n`;
70
- const makerImports = sortedSchemaNames
71
- .map((name) => `import { make${name} } from '../schemas/${name}';`)
72
- .join('\n');
73
- const requestType = operation.request.schemaName ?? 'null';
74
- const requestMaker =
75
- operation.request.schemaName === null
76
- ? `export function makeRequest(input: unknown): Result<null> {
34
+ for (const response of operation.responses) {
35
+ if (response.schemaName !== null) {
36
+ schemaNames.add(response.schemaName);
37
+ }
38
+ }
39
+ const sortedSchemaNames = [...schemaNames].sort();
40
+ const typeImport = sortedSchemaNames.length === 0
41
+ ? ''
42
+ : `import type { ${sortedSchemaNames.join(', ')} } from '../quickpay-api';\n`;
43
+ const makerImports = sortedSchemaNames
44
+ .map((name) => `import { make${name} } from '../schemas/${name}';`)
45
+ .join('\n');
46
+ const requestType = operation.request.schemaName ?? 'null';
47
+ const requestMaker = operation.request.schemaName === null
48
+ ? `export function makeRequest(input: unknown): Result<null> {
77
49
  if (input !== null) {
78
50
  return failure([], 'nullBody', 'Expected null');
79
51
  }
80
52
 
81
53
  return { ok: true, value: null };
82
54
  }`
83
- : `export const makeRequest = make${operation.request.schemaName};`;
84
-
85
- return `/**
55
+ : `export const makeRequest = make${operation.request.schemaName};`;
56
+ return `/**
86
57
  * Generated by openapi-contract-kit.
87
58
  * Do not edit directly.
88
59
  */
89
60
 
90
- import type { Result, ValidationPath } from ${JSON.stringify(runtimeImport)};
61
+ import type { Result, ValidationPath } from ${JSON.stringify(config.runtimeImport)};
91
62
  ${typeImport}${makerImports}${makerImports.length > 0 ? '\n' : ''}
92
63
  export const URL = ${JSON.stringify(operation.path)};
93
64
  export const METHOD = ${JSON.stringify(operation.method)};
@@ -171,12 +142,9 @@ export const makeResponse = Object.assign(makeAnyResponse, {
171
142
  });
172
143
  `;
173
144
  }
174
-
175
145
  export function emitEndpointModules(model, config) {
176
- return new Map(
177
- model.operations.map((operation) => [
178
- `endpoints/${operation.name}.ts`,
179
- emitEndpoint(operation, config),
180
- ])
181
- );
146
+ return new Map(model.operations.map((operation) => [
147
+ `endpoints/${operation.name}.ts`,
148
+ emitEndpoint(operation, config),
149
+ ]));
182
150
  }
@@ -0,0 +1,2 @@
1
+ import type { GeneratorConfig, OpenApiModel } from './types.js';
2
+ export declare function emitSchemaMakers(model: OpenApiModel, config: GeneratorConfig): Map<string, string>;