openapi-contract-kit 0.0.1

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.1.0] - 2026-09-11
6
+
7
+ - Initial public release.
8
+ - JSON OpenAPI 3.1 generator.
9
+ - Generated schema and endpoint runtime validators.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ibeanu Hillary (ibeanuhillary@gmail.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # openapi-contract-kit
2
+
3
+ JSON-first OpenAPI 3.1 contract generation and runtime validation for TypeScript applications.
4
+
5
+ `openapi-contract-kit` reads a JSON OpenAPI document and generates:
6
+
7
+ - TypeScript schema types
8
+ - `ShapeOf*` compatibility aliases
9
+ - Runtime schema makers
10
+ - Request validators
11
+ - Status-aware response validators
12
+ - Standalone endpoint modules
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install --save-dev openapi-contract-kit
18
+ ```
19
+
20
+ ## Quick start
21
+
22
+ Create `openapi.config.json`:
23
+
24
+ ```json
25
+ {
26
+ "specPath": "openapi.json",
27
+ "outDir": "src/api/generated",
28
+ "runtimeImport": "openapi-contract-kit/runtime"
29
+ }
30
+ ```
31
+
32
+ Add a generation script:
33
+
34
+ ```json
35
+ {
36
+ "scripts": {
37
+ "openapi:generate": "openapi-contract-kit"
38
+ }
39
+ }
40
+ ```
41
+
42
+ Generate contracts:
43
+
44
+ ```bash
45
+ npm run openapi:generate
46
+ ```
47
+
48
+ ## Generated validation
49
+
50
+ Schema makers return a discriminated result:
51
+
52
+ ```ts
53
+ const result = makeLoginRequest(input);
54
+
55
+ if (!result.ok) {
56
+ console.error(result.errors);
57
+ }
58
+ ```
59
+
60
+ Validation errors contain a path, keyword, and message. Successful validation returns the original input without mutating or cloning it.
61
+
62
+ ## Supported input
63
+
64
+ 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.
65
+
66
+ ## Current limitations
67
+
68
+ - YAML documents are not supported.
69
+ - Remote `$ref` references are not supported.
70
+ - Unsupported schema keywords fail generation.
71
+ - Path and operation parameters are not currently generated.
72
+ - Generated output is application-specific and should not be published with this package.
73
+
74
+ ## Development
75
+
76
+ ```bash
77
+ npm install
78
+ npm test
79
+ npm run pack:check
80
+ ```
81
+
82
+ ## Versioning
83
+
84
+ The package follows semantic versioning. Changes to generated contracts, public exports, CLI flags, or validation behavior are documented in `CHANGELOG.md`.
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from '../src/generator/generateOpenApiRuntime.mjs';
4
+
5
+ await main();
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "openapi-contract-kit",
3
+ "version": "0.0.1",
4
+ "description": "Generate type-safe TypeScript contracts and runtime validators from JSON OpenAPI 3.1 documents.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=20.11"
9
+ },
10
+ "main": "./src/index.mjs",
11
+ "types": "./src/index.d.ts",
12
+ "bin": {
13
+ "openapi-contract-kit": "./bin/openapi-contract-kit.mjs"
14
+ },
15
+ "exports": {
16
+ ".": {
17
+ "types": "./src/index.d.ts",
18
+ "import": "./src/index.mjs"
19
+ },
20
+ "./runtime": {
21
+ "types": "./src/runtime.d.ts",
22
+ "import": "./src/runtime.mjs"
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "files": [
27
+ "bin",
28
+ "src",
29
+ "CHANGELOG.md",
30
+ "LICENSE",
31
+ "README.md"
32
+ ],
33
+ "scripts": {
34
+ "test": "node --test test/*.test.mjs",
35
+ "pack:check": "npm pack --dry-run"
36
+ },
37
+ "devDependencies": {
38
+ "typescript": "^5.9.3"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "https://github.com/Clinsmann/openapi-contract-kit.git"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/Clinsmann/openapi-contract-kit/issues"
46
+ },
47
+ "homepage": "https://github.com/Clinsmann/openapi-contract-kit#readme",
48
+ "keywords": [
49
+ "openapi",
50
+ "openapi3",
51
+ "typescript",
52
+ "code-generation",
53
+ "runtime-validation",
54
+ "api-contracts",
55
+ "json-schema",
56
+ "cli"
57
+ ]
58
+ }
@@ -0,0 +1,92 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, resolve } from 'node:path';
3
+
4
+ const CONFIG_KEYS = new Set(['specPath', 'outDir', 'runtimeImport']);
5
+ const FLAG_NAMES = new Map([
6
+ ['--config', 'configPath'],
7
+ ['--spec', 'specPath'],
8
+ ['--out', 'outDir'],
9
+ ['--runtime-import', 'runtimeImport'],
10
+ ]);
11
+
12
+ function isRecord(value) {
13
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ }
15
+
16
+ function parseArguments(argv, cwd) {
17
+ const values = {};
18
+
19
+ for (let index = 0; index < argv.length; index += 2) {
20
+ const flag = argv[index];
21
+ const key = FLAG_NAMES.get(flag);
22
+ const value = argv[index + 1];
23
+
24
+ if (key === undefined) {
25
+ throw new Error(`Unknown argument "${flag}"`);
26
+ }
27
+ if (typeof value !== 'string' || value.length === 0) {
28
+ throw new Error(`Missing value for "${flag}"`);
29
+ }
30
+
31
+ values[key] = isAbsolute(value) ? value : resolve(cwd, value);
32
+ }
33
+
34
+ return values;
35
+ }
36
+
37
+ function requirePath(config, key, configPath) {
38
+ const value = config[key];
39
+
40
+ if (typeof value !== 'string' || value.trim().length === 0) {
41
+ throw new Error(`Config "${configPath}" requires a non-empty "${key}"`);
42
+ }
43
+
44
+ return value;
45
+ }
46
+
47
+ export async function resolveGeneratorConfig({
48
+ argv = [],
49
+ cwd = process.cwd(),
50
+ } = {}) {
51
+ const cli = parseArguments(argv, cwd);
52
+ const configPath = cli.configPath ?? resolve(cwd, 'openapi.config.json');
53
+ let config;
54
+
55
+ try {
56
+ config = JSON.parse(await readFile(configPath, 'utf8'));
57
+ } catch (error) {
58
+ const message = error instanceof Error ? error.message : String(error);
59
+ throw new Error(
60
+ `Unable to read OpenAPI config "${configPath}": ${message}`
61
+ );
62
+ }
63
+
64
+ if (!isRecord(config)) {
65
+ throw new Error(`OpenAPI config "${configPath}" must contain an object`);
66
+ }
67
+
68
+ for (const key of Object.keys(config)) {
69
+ if (!CONFIG_KEYS.has(key)) {
70
+ throw new Error(`Unknown OpenAPI config key "${key}"`);
71
+ }
72
+ }
73
+
74
+ const configDirectory = dirname(configPath);
75
+ const resolveConfigPath = (key) => {
76
+ const value = requirePath(config, key, configPath);
77
+ return isAbsolute(value) ? value : resolve(configDirectory, value);
78
+ };
79
+
80
+ const runtimeImport =
81
+ cli.runtimeImport ?? requirePath(config, 'runtimeImport', configPath);
82
+ if (runtimeImport.includes('\\') || runtimeImport.startsWith('.')) {
83
+ throw new Error(`Config "${configPath}" requires a package runtimeImport`);
84
+ }
85
+
86
+ return {
87
+ configPath,
88
+ specPath: cli.specPath ?? resolveConfigPath('specPath'),
89
+ outDir: cli.outDir ?? resolveConfigPath('outDir'),
90
+ runtimeImport,
91
+ };
92
+ }
@@ -0,0 +1,119 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { dirname, extname, resolve } from 'node:path';
3
+
4
+ export function isRecord(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+
8
+ export function escapePointerSegment(segment) {
9
+ return segment.replaceAll('~', '~0').replaceAll('/', '~1');
10
+ }
11
+
12
+ export function pointerChild(pointer, segment) {
13
+ return `${pointer}/${escapePointerSegment(String(segment))}`;
14
+ }
15
+
16
+ export function locationOf(documentPath, pointer) {
17
+ return `${documentPath}#${pointer}`;
18
+ }
19
+
20
+ export function requireRecord(value, location, label) {
21
+ if (!isRecord(value)) {
22
+ throw new Error(`${label} at ${location} must be an object`);
23
+ }
24
+
25
+ return value;
26
+ }
27
+
28
+ export class DocumentStore {
29
+ #documents = new Map();
30
+
31
+ async load(documentPath) {
32
+ const absolutePath = resolve(documentPath);
33
+ const existing = this.#documents.get(absolutePath);
34
+
35
+ if (existing !== undefined) {
36
+ return existing;
37
+ }
38
+
39
+ const pending = this.#read(absolutePath);
40
+ this.#documents.set(absolutePath, pending);
41
+ return pending;
42
+ }
43
+
44
+ async #read(documentPath) {
45
+ let source;
46
+
47
+ try {
48
+ source = await readFile(documentPath, 'utf8');
49
+ } catch (error) {
50
+ const message = error instanceof Error ? error.message : String(error);
51
+ throw new Error(
52
+ `Unable to read OpenAPI document "${documentPath}": ${message}`
53
+ );
54
+ }
55
+
56
+ try {
57
+ const extension = extname(documentPath).toLowerCase();
58
+ if (extension !== '.json') {
59
+ throw new Error('Only JSON OpenAPI documents are supported');
60
+ }
61
+ const value = JSON.parse(source);
62
+ return requireRecord(value, documentPath, 'OpenAPI document');
63
+ } catch (error) {
64
+ const message = error instanceof Error ? error.message : String(error);
65
+ throw new Error(
66
+ `Unable to parse OpenAPI document "${documentPath}": ${message}`
67
+ );
68
+ }
69
+ }
70
+
71
+ async resolveReference(reference, fromDocumentPath) {
72
+ if (typeof reference !== 'string' || reference.length === 0) {
73
+ throw new Error(`Invalid $ref at ${fromDocumentPath}`);
74
+ }
75
+ if (/^[A-Za-z][A-Za-z\d+.-]*:/u.test(reference)) {
76
+ throw new Error(`Remote $ref "${reference}" is unsupported`);
77
+ }
78
+
79
+ const hashIndex = reference.indexOf('#');
80
+ const filePart =
81
+ hashIndex === -1 ? reference : reference.slice(0, hashIndex);
82
+ const fragment = hashIndex === -1 ? '' : reference.slice(hashIndex + 1);
83
+ const documentPath =
84
+ filePart.length === 0
85
+ ? fromDocumentPath
86
+ : resolve(dirname(fromDocumentPath), decodeURIComponent(filePart));
87
+ const document = await this.load(documentPath);
88
+ const decodedFragment = decodeURIComponent(fragment);
89
+
90
+ if (decodedFragment.length > 0 && !decodedFragment.startsWith('/')) {
91
+ throw new Error(`Anchor $ref "${reference}" is unsupported`);
92
+ }
93
+
94
+ let value = document;
95
+ for (const encodedSegment of decodedFragment.split('/').slice(1)) {
96
+ const segment = encodedSegment
97
+ .replaceAll('~1', '/')
98
+ .replaceAll('~0', '~');
99
+ if (!isRecord(value) && !Array.isArray(value)) {
100
+ throw new Error(
101
+ `Unresolved $ref "${reference}" from ${fromDocumentPath}`
102
+ );
103
+ }
104
+ if (!Object.prototype.hasOwnProperty.call(value, segment)) {
105
+ throw new Error(
106
+ `Unresolved $ref "${reference}" from ${fromDocumentPath}`
107
+ );
108
+ }
109
+ value = Reflect.get(value, segment);
110
+ }
111
+
112
+ return {
113
+ canonicalKey: locationOf(documentPath, decodedFragment),
114
+ documentPath,
115
+ pointer: decodedFragment,
116
+ value,
117
+ };
118
+ }
119
+ }
@@ -0,0 +1,182 @@
1
+ import { join } from 'node:path';
2
+
3
+ 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} }`;
12
+ })
13
+ .join(' | ');
14
+ }
15
+
16
+ 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
+ );
36
+ }
37
+ lines.push(' }');
38
+ }
39
+
40
+ lines.push(
41
+ ` default:`,
42
+ ` return failure(['status'], 'status', 'Undocumented response status');`
43
+ );
44
+ return lines.join('\n');
45
+ }
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);
62
+ }
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> {
77
+ if (input !== null) {
78
+ return failure([], 'nullBody', 'Expected null');
79
+ }
80
+
81
+ return { ok: true, value: null };
82
+ }`
83
+ : `export const makeRequest = make${operation.request.schemaName};`;
84
+
85
+ return `/**
86
+ * Generated by openapi-contract-kit.
87
+ * Do not edit directly.
88
+ */
89
+
90
+ import type { Result, ValidationPath } from ${JSON.stringify(runtimeImport)};
91
+ ${typeImport}${makerImports}${makerImports.length > 0 ? '\n' : ''}
92
+ export const URL = ${JSON.stringify(operation.path)};
93
+ export const METHOD = ${JSON.stringify(operation.method)};
94
+ export const OPERATION_ID = ${JSON.stringify(operation.operationId)};
95
+
96
+ export type ShapeOfRequest = ${requestType};
97
+ export type ShapeOfSuccessResponse = ${renderResponseType(successResponses)};
98
+ export type ShapeOfErrorResponse = ${renderResponseType(errorResponses)};
99
+ export type ShapeOfResponse = ShapeOfSuccessResponse | ShapeOfErrorResponse;
100
+
101
+ type ResponseParts = {
102
+ readonly status: number;
103
+ readonly body: unknown;
104
+ };
105
+
106
+ function failure<T>(
107
+ path: ValidationPath,
108
+ keyword: string,
109
+ message: string
110
+ ): Result<T> {
111
+ return { ok: false, errors: [{ path, keyword, message }] };
112
+ }
113
+
114
+ function readResponse(input: unknown): ResponseParts | null {
115
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
116
+ return null;
117
+ }
118
+ if (
119
+ !Object.prototype.hasOwnProperty.call(input, 'status') ||
120
+ !Object.prototype.hasOwnProperty.call(input, 'body')
121
+ ) {
122
+ return null;
123
+ }
124
+
125
+ const status = Reflect.get(input, 'status');
126
+ if (typeof status !== 'number' || !Number.isInteger(status)) {
127
+ return null;
128
+ }
129
+
130
+ return { status, body: Reflect.get(input, 'body') };
131
+ }
132
+
133
+ ${requestMaker}
134
+
135
+ function makeSuccessResponseResult(input: unknown): Result<ShapeOfSuccessResponse> {
136
+ const parts = readResponse(input);
137
+ if (parts === null) {
138
+ return failure([], 'response', 'Expected { status, body }');
139
+ }
140
+
141
+ switch (parts.status) {
142
+ ${renderStatusCases(successResponses)}
143
+ }
144
+ }
145
+
146
+ function makeErrorResponseResult(input: unknown): Result<ShapeOfErrorResponse> {
147
+ const parts = readResponse(input);
148
+ if (parts === null) {
149
+ return failure([], 'response', 'Expected { status, body }');
150
+ }
151
+
152
+ switch (parts.status) {
153
+ ${renderStatusCases(errorResponses)}
154
+ }
155
+ }
156
+
157
+ function makeAnyResponse(input: unknown): Result<ShapeOfResponse> {
158
+ const parts = readResponse(input);
159
+ if (parts === null) {
160
+ return failure([], 'response', 'Expected { status, body }');
161
+ }
162
+
163
+ return parts.status >= 200 && parts.status <= 299
164
+ ? makeSuccessResponseResult(input)
165
+ : makeErrorResponseResult(input);
166
+ }
167
+
168
+ export const makeResponse = Object.assign(makeAnyResponse, {
169
+ success: makeSuccessResponseResult,
170
+ error: makeErrorResponseResult,
171
+ });
172
+ `;
173
+ }
174
+
175
+ 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
+ );
182
+ }