vouchington-tooling 0.0.10 → 0.0.12
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/README.md +5 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.mjs +2 -0
- package/dist/openapi-document/build-openapi-document.d.mts +11 -0
- package/dist/openapi-document/build-openapi-document.mjs +122 -0
- package/dist/openapi-document/build-openapi-query.d.mts +3 -0
- package/dist/openapi-document/build-openapi-query.mjs +53 -0
- package/dist/openapi-document/build-openapi-request.d.mts +7 -0
- package/dist/openapi-document/build-openapi-request.mjs +29 -0
- package/dist/openapi-document/build-openapi-response.d.mts +9 -0
- package/dist/openapi-document/build-openapi-response.mjs +111 -0
- package/dist/openapi-document/component-registry.d.mts +9 -0
- package/dist/openapi-document/component-registry.mjs +49 -0
- package/dist/openapi-document/contract-schema-canonical.d.mts +4 -0
- package/dist/openapi-document/contract-schema-canonical.mjs +23 -0
- package/dist/openapi-document/contract-schema-intersection-merge.d.mts +13 -0
- package/dist/openapi-document/contract-schema-intersection-merge.mjs +96 -0
- package/dist/openapi-document/contract-schema-to-openapi.d.mts +7 -0
- package/dist/openapi-document/contract-schema-to-openapi.mjs +71 -0
- package/dist/openapi-document/contract-schema-types.d.mts +43 -0
- package/dist/openapi-document/contract-schema-types.mjs +1 -0
- package/dist/openapi-document/index.d.mts +19 -0
- package/dist/openapi-document/index.mjs +12 -0
- package/dist/openapi-document/openapi-error-response.d.mts +2 -0
- package/dist/openapi-document/openapi-error-response.mjs +13 -0
- package/dist/openapi-document/openapi-route-helpers.d.mts +9 -0
- package/dist/openapi-document/openapi-route-helpers.mjs +61 -0
- package/dist/openapi-document/openapi-types.d.mts +80 -0
- package/dist/openapi-document/openapi-types.mjs +1 -0
- package/dist/openapi-document/operation-types.d.mts +35 -0
- package/dist/openapi-document/operation-types.mjs +11 -0
- package/dist/openapi-document/query-types.d.mts +35 -0
- package/dist/openapi-document/query-types.mjs +1 -0
- package/dist/openapi-document/schema-node-builders.d.mts +3 -0
- package/dist/openapi-document/schema-node-builders.mjs +6 -0
- package/dist/openapi-document/write-openapi.d.mts +9 -0
- package/dist/openapi-document/write-openapi.mjs +19 -0
- package/dist/pg-schema-snapshot/build-snapshot.d.mts +9 -0
- package/dist/pg-schema-snapshot/build-snapshot.mjs +61 -0
- package/dist/pg-schema-snapshot/catalog-objects.d.mts +34 -0
- package/dist/pg-schema-snapshot/catalog-objects.mjs +64 -0
- package/dist/pg-schema-snapshot/catalog-queries.d.mts +20 -0
- package/dist/pg-schema-snapshot/catalog-queries.mjs +31 -0
- package/dist/pg-schema-snapshot/catalog-shared.d.mts +3 -0
- package/dist/pg-schema-snapshot/catalog-shared.mjs +19 -0
- package/dist/pg-schema-snapshot/catalog-table-constraints.d.mts +14 -0
- package/dist/pg-schema-snapshot/catalog-table-constraints.mjs +54 -0
- package/dist/pg-schema-snapshot/catalog-table-indexes.d.mts +30 -0
- package/dist/pg-schema-snapshot/catalog-table-indexes.mjs +81 -0
- package/dist/pg-schema-snapshot/catalog-tables.d.mts +22 -0
- package/dist/pg-schema-snapshot/catalog-tables.mjs +48 -0
- package/dist/pg-schema-snapshot/file-safety.d.mts +4 -0
- package/dist/pg-schema-snapshot/file-safety.mjs +71 -0
- package/dist/pg-schema-snapshot/generate.d.mts +18 -0
- package/dist/pg-schema-snapshot/generate.mjs +95 -0
- package/dist/pg-schema-snapshot/index-rename-detect.d.mts +40 -0
- package/dist/pg-schema-snapshot/index-rename-detect.mjs +71 -0
- package/dist/pg-schema-snapshot/index.d.mts +10 -0
- package/dist/pg-schema-snapshot/index.mjs +6 -0
- package/dist/pg-schema-snapshot/markdown-files.d.mts +1 -0
- package/dist/pg-schema-snapshot/markdown-files.mjs +26 -0
- package/dist/pg-schema-snapshot/render-markdown.d.mts +8 -0
- package/dist/pg-schema-snapshot/render-markdown.mjs +113 -0
- package/dist/pg-schema-snapshot/render-tables.d.mts +2 -0
- package/dist/pg-schema-snapshot/render-tables.mjs +55 -0
- package/dist/pg-schema-snapshot/snapshot-build-utils.d.mts +2 -0
- package/dist/pg-schema-snapshot/snapshot-build-utils.mjs +15 -0
- package/dist/pg-schema-snapshot/snapshot.test-helpers.d.mts +11 -0
- package/dist/pg-schema-snapshot/snapshot.test-helpers.mjs +101 -0
- package/dist/pg-schema-snapshot/table-snapshot-columns.d.mts +3 -0
- package/dist/pg-schema-snapshot/table-snapshot-columns.mjs +33 -0
- package/dist/pg-schema-snapshot/table-snapshot-constraints.d.mts +7 -0
- package/dist/pg-schema-snapshot/table-snapshot-constraints.mjs +55 -0
- package/dist/pg-schema-snapshot/table-snapshot.d.mts +10 -0
- package/dist/pg-schema-snapshot/table-snapshot.mjs +99 -0
- package/dist/pg-schema-snapshot/types.d.mts +103 -0
- package/dist/pg-schema-snapshot/types.mjs +1 -0
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -64,4 +64,9 @@ import {
|
|
|
64
64
|
import { createArtifactClassifier, runCleanup } from 'vouchington-tooling/gha-artifacts-cleanup'
|
|
65
65
|
import { validateOptionalHttpOrigin } from 'vouchington-tooling/http-origin'
|
|
66
66
|
import { boundPendingLine, splitCompleteLines } from 'vouchington-tooling/process-line-buffer'
|
|
67
|
+
import {
|
|
68
|
+
generateSchemaSnapshot,
|
|
69
|
+
renderSchemaMarkdown,
|
|
70
|
+
} from 'vouchington-tooling/pg-schema-snapshot'
|
|
71
|
+
import { buildOpenApiDocument, writeOpenApi } from 'vouchington-tooling/openapi-document'
|
|
67
72
|
```
|
package/dist/index.d.mts
CHANGED
|
@@ -16,3 +16,7 @@ export { createArtifactClassifier, parseArtifactPatternsJson, planRunDeletions,
|
|
|
16
16
|
export type { ArtifactClassification, ArtifactClassifier, ArtifactPatterns, CleanupRequest, DeletionSummary, } from './gha-artifacts-cleanup/index.mts';
|
|
17
17
|
export { validateOptionalHttpOrigin } from './http-origin/index.mts';
|
|
18
18
|
export { boundPendingLine, DEFAULT_MAX_PENDING_LINE_LENGTH, DEFAULT_TRUNCATED_LINE_MARKER, splitCompleteLines, } from './process-line-buffer/index.mts';
|
|
19
|
+
export { buildSchemaSnapshot, detectRenamedIndexes, generateSchemaSnapshot, indexShapeKey, readSchemaCatalog, renderSchemaMarkdown, stableStringify, writeSchemaSnapshot, } from './pg-schema-snapshot/index.mts';
|
|
20
|
+
export type { CatalogQuery, PartitionPolicy, SchemaCatalog, SchemaGrowthMaps, SchemaSnapshot, SchemaTableSnapshot, } from './pg-schema-snapshot/index.mts';
|
|
21
|
+
export { buildOpenApiDocument, hashContractSchema, nodeToOpenApi, writeOpenApi, } from './openapi-document/index.mts';
|
|
22
|
+
export type { BuildOpenApiDocumentInput, ContractSchema, OpenApiDocument, RequestContract, ResponseContract, } from './openapi-document/index.mts';
|
package/dist/index.mjs
CHANGED
|
@@ -9,3 +9,5 @@ export { decodeSelectedFiles, encodeSelectedFiles, formatMultilineOutput, SELECT
|
|
|
9
9
|
export { createArtifactClassifier, parseArtifactPatternsJson, planRunDeletions, runCleanup, sweepCleanup, } from './gha-artifacts-cleanup/index.mjs';
|
|
10
10
|
export { validateOptionalHttpOrigin } from './http-origin/index.mjs';
|
|
11
11
|
export { boundPendingLine, DEFAULT_MAX_PENDING_LINE_LENGTH, DEFAULT_TRUNCATED_LINE_MARKER, splitCompleteLines, } from './process-line-buffer/index.mjs';
|
|
12
|
+
export { buildSchemaSnapshot, detectRenamedIndexes, generateSchemaSnapshot, indexShapeKey, readSchemaCatalog, renderSchemaMarkdown, stableStringify, writeSchemaSnapshot, } from './pg-schema-snapshot/index.mjs';
|
|
13
|
+
export { buildOpenApiDocument, hashContractSchema, nodeToOpenApi, writeOpenApi, } from './openapi-document/index.mjs';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type QueryOperationContract, type RegisteredRoute, type RequestContract, type ResponseContract } from './operation-types.mts';
|
|
2
|
+
import type { OpenApiDocument } from './openapi-types.mts';
|
|
3
|
+
export type BuildOpenApiDocumentInput = {
|
|
4
|
+
title: string;
|
|
5
|
+
version?: string;
|
|
6
|
+
responseContracts: Record<string, ResponseContract>;
|
|
7
|
+
requestContracts?: Record<string, RequestContract>;
|
|
8
|
+
queryContracts?: Readonly<Record<string, QueryOperationContract>>;
|
|
9
|
+
registeredRoutes?: readonly RegisteredRoute[];
|
|
10
|
+
};
|
|
11
|
+
export declare function buildOpenApiDocument(input: BuildOpenApiDocumentInput): OpenApiDocument;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { queryParameters } from './build-openapi-query.mjs';
|
|
2
|
+
import { buildOperationRequestBody } from './build-openapi-request.mjs';
|
|
3
|
+
import { buildOperationResponse } from './build-openapi-response.mjs';
|
|
4
|
+
import { createComponentRegistry } from './component-registry.mjs';
|
|
5
|
+
import { responseStatusCodesForContract, routeShape, } from './operation-types.mjs';
|
|
6
|
+
import { registerOpenApiErrorResponse } from './openapi-error-response.mjs';
|
|
7
|
+
import { canonicalTemplatesByShape, catalogResponse, operationId, pathParameters, } from './openapi-route-helpers.mjs';
|
|
8
|
+
export function buildOpenApiDocument(input) {
|
|
9
|
+
const registry = createComponentRegistry();
|
|
10
|
+
registerOpenApiErrorResponse(registry);
|
|
11
|
+
const requestContracts = input.requestContracts ?? {};
|
|
12
|
+
const queryContracts = input.queryContracts ?? {};
|
|
13
|
+
const registeredRoutes = input.registeredRoutes ?? [];
|
|
14
|
+
const contractGroups = groupContractsByRoute(input.responseContracts);
|
|
15
|
+
const contractGroupsByShape = uniqueShapeGroups(contractGroups);
|
|
16
|
+
const catalogByShape = new Map(registeredRoutes.map((route) => [`${route.method}:${routeShape(route.routeTemplate)}`, route]));
|
|
17
|
+
const operationKeys = new Set([...contractGroupsByShape.keys(), ...catalogByShape.keys()]);
|
|
18
|
+
const canonicalTemplates = canonicalTemplatesByShape([
|
|
19
|
+
...[...contractGroups.values()].map((variants) => variants[0].routeTemplate),
|
|
20
|
+
...registeredRoutes.map((route) => route.routeTemplate),
|
|
21
|
+
]);
|
|
22
|
+
const paths = {};
|
|
23
|
+
const unavailableRoutes = [];
|
|
24
|
+
const unavailableRequestRoutes = [];
|
|
25
|
+
for (const shapeKey of [...operationKeys].toSorted()) {
|
|
26
|
+
const separator = shapeKey.indexOf(':');
|
|
27
|
+
const method = shapeKey.slice(0, separator);
|
|
28
|
+
const catalogRoute = catalogByShape.get(shapeKey);
|
|
29
|
+
const variants = contractGroupsByShape.get(shapeKey);
|
|
30
|
+
const routeTemplate = catalogRoute?.routeTemplate ?? variants[0].routeTemplate;
|
|
31
|
+
const groupKey = `${method}:${routeTemplate}`;
|
|
32
|
+
assertFixedNoContent(catalogRoute, variants, groupKey);
|
|
33
|
+
const rendered = renderOperation(catalogRoute, variants, registry);
|
|
34
|
+
if (rendered.unavailable)
|
|
35
|
+
unavailableRoutes.push(groupKey);
|
|
36
|
+
const { requestBody, unavailable: requestUnavailable } = buildOperationRequestBody(requestContracts[groupKey], registry);
|
|
37
|
+
if (requestUnavailable)
|
|
38
|
+
unavailableRequestRoutes.push(groupKey);
|
|
39
|
+
const canonicalTemplate = canonicalTemplates.get(routeShape(routeTemplate));
|
|
40
|
+
const parameters = [
|
|
41
|
+
...pathParameters(canonicalTemplate),
|
|
42
|
+
...queryParameters(queryContracts[groupKey]?.parameters),
|
|
43
|
+
];
|
|
44
|
+
const operation = {
|
|
45
|
+
operationId: operationId(method, routeTemplate),
|
|
46
|
+
...(parameters.length > 0 ? { parameters } : {}),
|
|
47
|
+
...(requestBody ? { requestBody } : {}),
|
|
48
|
+
responses: { ...rendered.responses, default: { $ref: '#/components/responses/Error' } },
|
|
49
|
+
...(rendered.unavailable
|
|
50
|
+
? {
|
|
51
|
+
'x-schema-unavailable': true,
|
|
52
|
+
/* v8 ignore next -- catalog and conversion failures always include a reason */
|
|
53
|
+
...(rendered.unavailableReason
|
|
54
|
+
? { 'x-schema-unavailable-reason': rendered.unavailableReason }
|
|
55
|
+
: {}),
|
|
56
|
+
}
|
|
57
|
+
: {}),
|
|
58
|
+
};
|
|
59
|
+
const path = canonicalTemplate.replace(/:(\w+)/g, '{$1}');
|
|
60
|
+
paths[path] ??= {};
|
|
61
|
+
paths[path][method.toLowerCase()] = operation;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
openapi: '3.1.0',
|
|
65
|
+
info: { title: input.title, version: input.version ?? '1.0.0' },
|
|
66
|
+
paths,
|
|
67
|
+
components: {
|
|
68
|
+
schemas: registry.schemas(),
|
|
69
|
+
responses: {
|
|
70
|
+
Error: {
|
|
71
|
+
description: 'Error',
|
|
72
|
+
content: {
|
|
73
|
+
'application/json': {
|
|
74
|
+
schema: { $ref: `#/components/schemas/${registry.refName('ErrorBody')}` },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
'x-unavailable-routes': unavailableRoutes.toSorted(),
|
|
81
|
+
'x-unavailable-request-routes': unavailableRequestRoutes.toSorted(),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function groupContractsByRoute(contracts) {
|
|
85
|
+
const groups = new Map();
|
|
86
|
+
for (const contract of Object.values(contracts)) {
|
|
87
|
+
const groupKey = `${contract.method}:${contract.routeTemplate}`;
|
|
88
|
+
const variants = groups.get(groupKey) ?? [];
|
|
89
|
+
variants.push(contract);
|
|
90
|
+
groups.set(groupKey, variants);
|
|
91
|
+
}
|
|
92
|
+
return groups;
|
|
93
|
+
}
|
|
94
|
+
function uniqueShapeGroups(groups) {
|
|
95
|
+
const contractGroupsByShape = new Map();
|
|
96
|
+
for (const [, variants] of groups) {
|
|
97
|
+
const { method, routeTemplate } = variants[0];
|
|
98
|
+
const shapeKey = `${method}:${routeShape(routeTemplate)}`;
|
|
99
|
+
const existing = contractGroupsByShape.get(shapeKey);
|
|
100
|
+
if (existing)
|
|
101
|
+
throw new Error(`response contracts contain duplicate normalized route ${shapeKey}: ${existing[0].routeTemplate} and ${routeTemplate}`);
|
|
102
|
+
contractGroupsByShape.set(shapeKey, variants);
|
|
103
|
+
}
|
|
104
|
+
return contractGroupsByShape;
|
|
105
|
+
}
|
|
106
|
+
function assertFixedNoContent(catalogRoute, variants, groupKey) {
|
|
107
|
+
if (catalogRoute?.kind !== 'fixed-no-content' || !variants)
|
|
108
|
+
return;
|
|
109
|
+
if (variants.every((variant) => variant.bodyKind === 'none' &&
|
|
110
|
+
responseStatusCodesForContract(variant).every((status) => status === catalogRoute.fixedStatus)))
|
|
111
|
+
return;
|
|
112
|
+
throw new Error(`OpenAPI fixed-no-content metadata conflicts with extracted response variants for ${groupKey}`);
|
|
113
|
+
}
|
|
114
|
+
function renderOperation(catalogRoute, variants, registry) {
|
|
115
|
+
if (catalogRoute?.kind === 'sse' || catalogRoute?.kind === 'fixed-no-content')
|
|
116
|
+
return catalogResponse(catalogRoute.kind, catalogRoute.fixedStatus);
|
|
117
|
+
if (variants)
|
|
118
|
+
return buildOperationResponse(variants, registry);
|
|
119
|
+
if (catalogRoute?.kind === 'error-only')
|
|
120
|
+
return catalogResponse('error-only');
|
|
121
|
+
return catalogResponse('ordinary');
|
|
122
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export function queryParameters(contract) {
|
|
2
|
+
if (!contract)
|
|
3
|
+
return [];
|
|
4
|
+
return Object.entries(contract)
|
|
5
|
+
.toSorted(([left], [right]) => left.localeCompare(right))
|
|
6
|
+
.map(([name, descriptor]) => queryParameter(name, descriptor));
|
|
7
|
+
}
|
|
8
|
+
function queryParameter(name, descriptor) {
|
|
9
|
+
return {
|
|
10
|
+
name,
|
|
11
|
+
in: 'query',
|
|
12
|
+
required: false,
|
|
13
|
+
...(descriptor.description ? { description: descriptor.description } : {}),
|
|
14
|
+
...(descriptor.kind === 'csv-array'
|
|
15
|
+
? { style: descriptor.style, explode: descriptor.explode }
|
|
16
|
+
: {}),
|
|
17
|
+
schema: querySchema(descriptor),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function querySchema(descriptor) {
|
|
21
|
+
switch (descriptor.kind) {
|
|
22
|
+
case 'string':
|
|
23
|
+
return { type: 'string', ...(descriptor.format ? { format: descriptor.format } : {}) };
|
|
24
|
+
case 'uuid-or-uri':
|
|
25
|
+
return {
|
|
26
|
+
anyOf: [
|
|
27
|
+
{ type: 'string', format: 'uuid' },
|
|
28
|
+
{ type: 'string', format: 'uri' },
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
case 'boolean':
|
|
32
|
+
return { type: 'boolean' };
|
|
33
|
+
case 'nullable-boolean':
|
|
34
|
+
return { anyOf: [{ type: 'boolean' }, { type: 'string', const: 'null' }] };
|
|
35
|
+
case 'number':
|
|
36
|
+
return { type: 'number' };
|
|
37
|
+
case 'integer':
|
|
38
|
+
return {
|
|
39
|
+
type: 'integer',
|
|
40
|
+
minimum: descriptor.minimum,
|
|
41
|
+
maximum: descriptor.maximum,
|
|
42
|
+
...(descriptor.default === undefined ? {} : { default: descriptor.default }),
|
|
43
|
+
};
|
|
44
|
+
case 'enum':
|
|
45
|
+
return {
|
|
46
|
+
type: 'string',
|
|
47
|
+
enum: [...descriptor.values],
|
|
48
|
+
...(descriptor.default === undefined ? {} : { default: descriptor.default }),
|
|
49
|
+
};
|
|
50
|
+
case 'csv-array':
|
|
51
|
+
return { type: 'array', items: querySchema(descriptor.items) };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ComponentRegistry } from './component-registry.mts';
|
|
2
|
+
import type { RequestContract } from './operation-types.mts';
|
|
3
|
+
import type { OpenApiRequestBody } from './openapi-types.mts';
|
|
4
|
+
export declare function buildOperationRequestBody(contract: RequestContract | undefined, registry: ComponentRegistry): {
|
|
5
|
+
requestBody: OpenApiRequestBody | undefined;
|
|
6
|
+
unavailable: boolean;
|
|
7
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { nodeToOpenApi } from './contract-schema-to-openapi.mjs';
|
|
2
|
+
export function buildOperationRequestBody(contract, registry) {
|
|
3
|
+
if (!contract)
|
|
4
|
+
return { requestBody: undefined, unavailable: false };
|
|
5
|
+
if (contract.unavailableReason) {
|
|
6
|
+
return { unavailable: true, requestBody: unavailableRequestBody(contract.unavailableReason) };
|
|
7
|
+
}
|
|
8
|
+
let schema;
|
|
9
|
+
try {
|
|
10
|
+
schema = nodeToOpenApi(contract.schema.root, {
|
|
11
|
+
definitions: contract.schema.definitions,
|
|
12
|
+
refName: registry.refName,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
/* v8 ignore next -- thrown values are Errors from nodeToOpenApi */
|
|
17
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
18
|
+
return { unavailable: true, requestBody: unavailableRequestBody(reason) };
|
|
19
|
+
}
|
|
20
|
+
registry.register(contract.source, contract.schema.definitions);
|
|
21
|
+
return { unavailable: false, requestBody: { content: { 'application/json': { schema } } } };
|
|
22
|
+
}
|
|
23
|
+
function unavailableRequestBody(reason) {
|
|
24
|
+
return {
|
|
25
|
+
content: { 'application/json': { schema: {} } },
|
|
26
|
+
'x-request-schema-unavailable': true,
|
|
27
|
+
'x-request-schema-unavailable-reason': reason,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ComponentRegistry } from './component-registry.mts';
|
|
2
|
+
import { type ResponseContract } from './operation-types.mts';
|
|
3
|
+
import type { OpenApiResponseOrRef, OpenApiSchema } from './openapi-types.mts';
|
|
4
|
+
export declare function buildOperationResponse(variants: ResponseContract[], registry: ComponentRegistry): {
|
|
5
|
+
responses: Record<number, OpenApiResponseOrRef>;
|
|
6
|
+
unavailable: boolean;
|
|
7
|
+
unavailableReason?: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function mergeVariantSchemas(schemas: OpenApiSchema[]): OpenApiSchema;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { STATUS_CODES } from 'node:http';
|
|
2
|
+
import { nodeToOpenApi } from './contract-schema-to-openapi.mjs';
|
|
3
|
+
import { responseStatusCodesForContract } from './operation-types.mjs';
|
|
4
|
+
export function buildOperationResponse(variants, registry) {
|
|
5
|
+
const content = new Map();
|
|
6
|
+
const bodyless = new Map();
|
|
7
|
+
const operationFailures = [];
|
|
8
|
+
for (const contract of variants) {
|
|
9
|
+
const converted = convertContract(contract, registry);
|
|
10
|
+
if (converted.failureReason)
|
|
11
|
+
operationFailures.push(converted.failureReason);
|
|
12
|
+
if (contract.statusKnowledge === 'unknown' && !converted.failureReason)
|
|
13
|
+
operationFailures.push('response status is not statically known');
|
|
14
|
+
for (const status of responseStatusCodesForContract(contract)) {
|
|
15
|
+
if (bodyKindFor(contract) === 'none') {
|
|
16
|
+
const reasons = bodyless.get(status) ?? [];
|
|
17
|
+
if (converted.failureReason)
|
|
18
|
+
reasons.push(converted.failureReason);
|
|
19
|
+
bodyless.set(status, reasons);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const mediaType = mediaTypeFor(contract);
|
|
23
|
+
if (!mediaType) {
|
|
24
|
+
operationFailures.push('response media type is not statically known');
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const byMedia = content.get(status) ?? new Map();
|
|
28
|
+
const bucket = byMedia.get(mediaType) ?? { schemas: [], failureReasons: [] };
|
|
29
|
+
bucket.schemas.push(converted.schema ?? {});
|
|
30
|
+
if (converted.failureReason)
|
|
31
|
+
bucket.failureReasons.push(converted.failureReason);
|
|
32
|
+
byMedia.set(mediaType, bucket);
|
|
33
|
+
content.set(status, byMedia);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const responses = {};
|
|
37
|
+
const statuses = new Set([...bodyless.keys(), ...content.keys()]);
|
|
38
|
+
for (const status of [...statuses].toSorted((left, right) => left - right)) {
|
|
39
|
+
responses[status] = renderStatusResponse(status, content.get(status), bodyless, operationFailures);
|
|
40
|
+
}
|
|
41
|
+
const reasons = [...new Set(operationFailures)];
|
|
42
|
+
return {
|
|
43
|
+
responses,
|
|
44
|
+
unavailable: reasons.length > 0,
|
|
45
|
+
...(reasons.length > 0 ? { unavailableReason: reasons.join('; ') } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function renderStatusResponse(status, byMedia, bodyless, operationFailures) {
|
|
49
|
+
const hasBodyless = bodyless.has(status);
|
|
50
|
+
const conflictReason = byMedia && hasBodyless
|
|
51
|
+
? `status ${status} has both body and no-body variants`
|
|
52
|
+
: byMedia && (status === 204 || status === 205)
|
|
53
|
+
? `status ${status} cannot carry a response body`
|
|
54
|
+
: undefined;
|
|
55
|
+
if (conflictReason)
|
|
56
|
+
operationFailures.push(conflictReason);
|
|
57
|
+
const bucketFailures = [
|
|
58
|
+
...(bodyless.get(status) ?? []),
|
|
59
|
+
...[...(byMedia?.values() ?? [])].flatMap((bucket) => bucket.failureReasons),
|
|
60
|
+
...(conflictReason ? [conflictReason] : []),
|
|
61
|
+
];
|
|
62
|
+
return {
|
|
63
|
+
description: STATUS_CODES[status] ?? 'Response',
|
|
64
|
+
...(!conflictReason && byMedia
|
|
65
|
+
? {
|
|
66
|
+
content: Object.fromEntries([...byMedia.entries()]
|
|
67
|
+
.toSorted(([left], [right]) => left.localeCompare(right))
|
|
68
|
+
.map(([mediaType, bucket]) => [
|
|
69
|
+
mediaType,
|
|
70
|
+
{ schema: mergeVariantSchemas(bucket.schemas) },
|
|
71
|
+
])),
|
|
72
|
+
}
|
|
73
|
+
: {}),
|
|
74
|
+
...(bucketFailures.length > 0
|
|
75
|
+
? {
|
|
76
|
+
'x-schema-unavailable': true,
|
|
77
|
+
'x-schema-unavailable-reason': [...new Set(bucketFailures)].join('; '),
|
|
78
|
+
}
|
|
79
|
+
: {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function convertContract(contract, registry) {
|
|
83
|
+
if (contract.unavailableReason)
|
|
84
|
+
return { failureReason: contract.unavailableReason };
|
|
85
|
+
let schema;
|
|
86
|
+
try {
|
|
87
|
+
schema = nodeToOpenApi(contract.schema.root, {
|
|
88
|
+
definitions: contract.schema.definitions,
|
|
89
|
+
refName: registry.refName,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
/* v8 ignore start */
|
|
94
|
+
return { failureReason: error instanceof Error ? error.message : String(error) };
|
|
95
|
+
/* v8 ignore stop */
|
|
96
|
+
}
|
|
97
|
+
registry.register(contract.source, contract.schema.definitions);
|
|
98
|
+
return { schema };
|
|
99
|
+
}
|
|
100
|
+
function mediaTypeFor(contract) {
|
|
101
|
+
if (contract.mediaTypeKnowledge === 'unknown')
|
|
102
|
+
return undefined;
|
|
103
|
+
return contract.mediaType ?? 'application/json';
|
|
104
|
+
}
|
|
105
|
+
function bodyKindFor(contract) {
|
|
106
|
+
return contract.bodyKind ?? (contract.schema.root.type === 'null' ? 'none' : 'content');
|
|
107
|
+
}
|
|
108
|
+
export function mergeVariantSchemas(schemas) {
|
|
109
|
+
const unique = [...new Map(schemas.map((schema) => [JSON.stringify(schema), schema])).values()];
|
|
110
|
+
return unique.length === 1 ? unique[0] : { anyOf: unique };
|
|
111
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ContractSchemaNode } from './contract-schema-types.mts';
|
|
2
|
+
import type { OpenApiSchema } from './openapi-types.mts';
|
|
3
|
+
export type ComponentRegistry = {
|
|
4
|
+
refName: (rawName: string) => string;
|
|
5
|
+
register: (source: string, definitions: Record<string, ContractSchemaNode>) => void;
|
|
6
|
+
schemas: () => Record<string, OpenApiSchema>;
|
|
7
|
+
};
|
|
8
|
+
export declare function sanitizeComponentName(rawName: string): string;
|
|
9
|
+
export declare function createComponentRegistry(): ComponentRegistry;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { nodeToOpenApi } from './contract-schema-to-openapi.mjs';
|
|
3
|
+
export function sanitizeComponentName(rawName) {
|
|
4
|
+
return rawName
|
|
5
|
+
.replace(/[<,]/g, '_')
|
|
6
|
+
.replace(/>/g, '')
|
|
7
|
+
.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
8
|
+
}
|
|
9
|
+
export function createComponentRegistry() {
|
|
10
|
+
const sanitizedNames = new Map();
|
|
11
|
+
const entries = new Map();
|
|
12
|
+
function refName(rawName) {
|
|
13
|
+
const existing = sanitizedNames.get(rawName);
|
|
14
|
+
if (existing)
|
|
15
|
+
return existing;
|
|
16
|
+
const sanitized = sanitizeComponentName(rawName);
|
|
17
|
+
sanitizedNames.set(rawName, sanitized);
|
|
18
|
+
return sanitized;
|
|
19
|
+
}
|
|
20
|
+
function register(source, definitions) {
|
|
21
|
+
const rawNames = Object.keys(definitions).toSorted((left, right) => left.localeCompare(right));
|
|
22
|
+
const pending = new Map();
|
|
23
|
+
for (const rawName of rawNames) {
|
|
24
|
+
const sanitized = refName(rawName);
|
|
25
|
+
const schema = nodeToOpenApi(definitions[rawName], { definitions, refName });
|
|
26
|
+
const hash = createHash('sha256').update(JSON.stringify(schema)).digest('hex');
|
|
27
|
+
const existing = entries.get(sanitized) ?? pending.get(sanitized);
|
|
28
|
+
if (existing) {
|
|
29
|
+
if (existing.hash !== hash) {
|
|
30
|
+
throw new Error([
|
|
31
|
+
`OpenAPI component "${sanitized}" (raw name "${rawName}") maps to conflicting shapes.`,
|
|
32
|
+
`Existing source: ${existing.source}`,
|
|
33
|
+
`Conflicting source: ${source}`,
|
|
34
|
+
].join('\n'));
|
|
35
|
+
}
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
pending.set(sanitized, { hash, source, schema });
|
|
39
|
+
}
|
|
40
|
+
for (const [sanitized, entry] of pending)
|
|
41
|
+
entries.set(sanitized, entry);
|
|
42
|
+
}
|
|
43
|
+
function schemas() {
|
|
44
|
+
return Object.fromEntries([...entries.entries()]
|
|
45
|
+
.toSorted(([left], [right]) => left.localeCompare(right))
|
|
46
|
+
.map(([name, entry]) => [name, entry.schema]));
|
|
47
|
+
}
|
|
48
|
+
return { refName, register, schemas };
|
|
49
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ContractSchema, ContractSchemaNode } from './contract-schema-types.mts';
|
|
2
|
+
export declare function canonicalContractSchema(schema: ContractSchema): string;
|
|
3
|
+
export declare function hashContractSchema(schema: ContractSchema): string;
|
|
4
|
+
export declare function canonicalContractSchemaNode(node: ContractSchemaNode): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export function canonicalContractSchema(schema) {
|
|
3
|
+
return JSON.stringify(sortSchemaValue(schema));
|
|
4
|
+
}
|
|
5
|
+
export function hashContractSchema(schema) {
|
|
6
|
+
return createHash('sha256').update(canonicalContractSchema(schema)).digest('hex');
|
|
7
|
+
}
|
|
8
|
+
function sortSchemaValue(value, parentKey) {
|
|
9
|
+
if (Array.isArray(value)) {
|
|
10
|
+
const sorted = value.map((item) => sortSchemaValue(item));
|
|
11
|
+
return parentKey === 'variants'
|
|
12
|
+
? sorted.toSorted((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)))
|
|
13
|
+
: sorted;
|
|
14
|
+
}
|
|
15
|
+
if (value == null || typeof value !== 'object')
|
|
16
|
+
return value;
|
|
17
|
+
return Object.fromEntries(Object.entries(value)
|
|
18
|
+
.toSorted(([left], [right]) => left.localeCompare(right))
|
|
19
|
+
.map(([key, nested]) => [key, sortSchemaValue(nested, key)]));
|
|
20
|
+
}
|
|
21
|
+
export function canonicalContractSchemaNode(node) {
|
|
22
|
+
return JSON.stringify(sortSchemaValue(node));
|
|
23
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ContractSchemaNode } from './contract-schema-types.mts';
|
|
2
|
+
import type { OpenApiConverterContext } from './contract-schema-to-openapi.mts';
|
|
3
|
+
import type { OpenApiSchema } from './openapi-types.mts';
|
|
4
|
+
/**
|
|
5
|
+
* Structurally merges an all-object intersection instead of emitting raw `allOf`: the
|
|
6
|
+
* extractor defaults `additionalProperties:false`, so `allOf` of two closed objects is
|
|
7
|
+
* unsatisfiable. Falls back to raw `allOf` only when a member isn't an object (directly or
|
|
8
|
+
* via `ref`), where that unsatisfiability concern doesn't apply. `nodeToOpenApi` is passed in
|
|
9
|
+
* (rather than imported) so this module doesn't form an import cycle with the dispatcher.
|
|
10
|
+
*/
|
|
11
|
+
export declare function intersectionToOpenApi(node: Extract<ContractSchemaNode, {
|
|
12
|
+
type: 'intersection';
|
|
13
|
+
}>, ctx: OpenApiConverterContext, nodeToOpenApi: (node: ContractSchemaNode, ctx: OpenApiConverterContext) => OpenApiSchema): OpenApiSchema;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { hashContractSchema } from './contract-schema-canonical.mjs';
|
|
2
|
+
/**
|
|
3
|
+
* Structurally merges an all-object intersection instead of emitting raw `allOf`: the
|
|
4
|
+
* extractor defaults `additionalProperties:false`, so `allOf` of two closed objects is
|
|
5
|
+
* unsatisfiable. Falls back to raw `allOf` only when a member isn't an object (directly or
|
|
6
|
+
* via `ref`), where that unsatisfiability concern doesn't apply. `nodeToOpenApi` is passed in
|
|
7
|
+
* (rather than imported) so this module doesn't form an import cycle with the dispatcher.
|
|
8
|
+
*/
|
|
9
|
+
export function intersectionToOpenApi(node, ctx, nodeToOpenApi) {
|
|
10
|
+
const resolved = node.variants.map((variant) => resolveObjectVariant(variant, ctx.definitions));
|
|
11
|
+
if (resolved.some((variant) => variant === undefined)) {
|
|
12
|
+
return { allOf: node.variants.map((variant) => nodeToOpenApi(variant, ctx)) };
|
|
13
|
+
}
|
|
14
|
+
const properties = {};
|
|
15
|
+
for (const variant of resolved) {
|
|
16
|
+
for (const [key, property] of Object.entries(variant.properties)) {
|
|
17
|
+
const existing = properties[key];
|
|
18
|
+
if (existing &&
|
|
19
|
+
hashSchemaNode(existing.schema, ctx) !== hashSchemaNode(property.schema, ctx)) {
|
|
20
|
+
// A member re-declaring a shared key with a strictly narrower type is a common, valid
|
|
21
|
+
// TypeScript intersection idiom (e.g. `Omit`-free overriding of an optional/nullable
|
|
22
|
+
// field to a guaranteed-present one) — TypeScript itself resolves the property to the
|
|
23
|
+
// narrower type, not a conflict. Prefer whichever side is the narrower one; only two
|
|
24
|
+
// genuinely incompatible schemas (neither a subset of the other) are a real conflict.
|
|
25
|
+
if (isNarrowerVariant(property.schema, existing.schema, ctx)) {
|
|
26
|
+
properties[key] = {
|
|
27
|
+
schema: property.schema,
|
|
28
|
+
required: existing.required || property.required,
|
|
29
|
+
};
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
/* v8 ignore next 6 -- symmetric of the previous branch; covered by order-swapped fixtures */
|
|
33
|
+
if (isNarrowerVariant(existing.schema, property.schema, ctx)) {
|
|
34
|
+
properties[key] = {
|
|
35
|
+
schema: existing.schema,
|
|
36
|
+
required: existing.required || property.required,
|
|
37
|
+
};
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`Cannot merge intersection: property "${key}" has conflicting schemas across members`);
|
|
41
|
+
}
|
|
42
|
+
properties[key] = {
|
|
43
|
+
schema: property.schema,
|
|
44
|
+
required: existing?.required || property.required,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const sortedKeys = Object.keys(properties).toSorted();
|
|
49
|
+
const required = sortedKeys.filter((key) => properties[key].required);
|
|
50
|
+
return {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: Object.fromEntries(sortedKeys.map((key) => [key, nodeToOpenApi(properties[key].schema, ctx)])),
|
|
53
|
+
...(required.length > 0 ? { required } : {}),
|
|
54
|
+
additionalProperties: false,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// Bounds intersection-merge ref resolution so a cyclic or pathologically deep `definitions`
|
|
58
|
+
// chain can't hang the converter; falls back to raw `allOf` if the object is still unresolved
|
|
59
|
+
// past this many hops (comfortably above the extractor's actual definition-nesting depths).
|
|
60
|
+
const MAX_REF_RESOLUTION_HOPS = 5;
|
|
61
|
+
function resolveObjectVariant(node, definitions) {
|
|
62
|
+
const resolved = resolveRef(node, definitions);
|
|
63
|
+
return resolved.type === 'object' ? resolved : undefined;
|
|
64
|
+
}
|
|
65
|
+
/** Follows a `ref` chain (bounded by `MAX_REF_RESOLUTION_HOPS`) to its underlying node, whatever shape that turns out to be. Returns the node unchanged if it isn't a `ref`, or if the chain doesn't resolve within the hop budget. */
|
|
66
|
+
function resolveRef(node, definitions) {
|
|
67
|
+
let current = node;
|
|
68
|
+
for (let hop = 0; hop < MAX_REF_RESOLUTION_HOPS && current.type === 'ref'; hop++) {
|
|
69
|
+
const next = definitions[current.name];
|
|
70
|
+
if (!next)
|
|
71
|
+
return current;
|
|
72
|
+
current = next;
|
|
73
|
+
}
|
|
74
|
+
return current;
|
|
75
|
+
}
|
|
76
|
+
function hashSchemaNode(node, ctx) {
|
|
77
|
+
return hashContractSchema({ root: node, definitions: ctx.definitions });
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* True when every value `narrow` accepts is also accepted by `wide` — i.e. `narrow`'s own variant
|
|
81
|
+
* set (a non-union schema is its own single-variant set) is a subset of `wide`'s. Covers the
|
|
82
|
+
* realistic case this converter needs to handle (an intersection member narrowing a shared
|
|
83
|
+
* optional/nullable property to a guaranteed-present one, e.g. `string | null` narrowed to
|
|
84
|
+
* `string`) without attempting full structural subtyping for arbitrary object shapes.
|
|
85
|
+
*
|
|
86
|
+
* Both sides are `ref`-resolved first: a shared property declared with its own named union alias
|
|
87
|
+
* (e.g. `post_type: PostType`) extracts as a `ref` to that alias, not an inline `union` node, so
|
|
88
|
+
* checking `.type === 'union'` on the raw node would miss it entirely.
|
|
89
|
+
*/
|
|
90
|
+
function isNarrowerVariant(narrow, wide, ctx) {
|
|
91
|
+
const resolvedNarrow = resolveRef(narrow, ctx.definitions);
|
|
92
|
+
const resolvedWide = resolveRef(wide, ctx.definitions);
|
|
93
|
+
const narrowVariants = resolvedNarrow.type === 'union' ? resolvedNarrow.variants : [resolvedNarrow];
|
|
94
|
+
const wideHashes = new Set((resolvedWide.type === 'union' ? resolvedWide.variants : [resolvedWide]).map((variant) => hashSchemaNode(variant, ctx)));
|
|
95
|
+
return narrowVariants.every((variant) => wideHashes.has(hashSchemaNode(variant, ctx)));
|
|
96
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ContractSchemaNode } from './contract-schema-types.mts';
|
|
2
|
+
import type { OpenApiSchema } from './openapi-types.mts';
|
|
3
|
+
export type OpenApiConverterContext = {
|
|
4
|
+
definitions: Record<string, ContractSchemaNode>;
|
|
5
|
+
refName: (name: string) => string;
|
|
6
|
+
};
|
|
7
|
+
export declare function nodeToOpenApi(node: ContractSchemaNode, ctx: OpenApiConverterContext): OpenApiSchema;
|