vouchington-tooling 0.0.9 → 0.0.11
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 +21 -1
- package/dist/cli/commands/gha-artifacts-cleanup.d.mts +6 -0
- package/dist/cli/commands/gha-artifacts-cleanup.mjs +50 -0
- package/dist/cli/commands/http-origin.d.mts +2 -0
- package/dist/cli/commands/http-origin.mjs +14 -0
- package/dist/cli/index.mjs +11 -0
- package/dist/cli/parse-gha-artifacts-cleanup.d.mts +15 -0
- package/dist/cli/parse-gha-artifacts-cleanup.mjs +74 -0
- package/dist/cli/parse.d.mts +7 -2
- package/dist/cli/parse.mjs +36 -0
- package/dist/cli/usage.d.mts +1 -1
- package/dist/cli/usage.mjs +9 -0
- package/dist/gha-artifacts-cleanup/classify.d.mts +14 -0
- package/dist/gha-artifacts-cleanup/classify.mjs +32 -0
- package/dist/gha-artifacts-cleanup/commands.d.mts +23 -0
- package/dist/gha-artifacts-cleanup/commands.mjs +55 -0
- package/dist/gha-artifacts-cleanup/github.d.mts +16 -0
- package/dist/gha-artifacts-cleanup/github.mjs +100 -0
- package/dist/gha-artifacts-cleanup/index.d.mts +8 -0
- package/dist/gha-artifacts-cleanup/index.mjs +4 -0
- package/dist/gha-artifacts-cleanup/plan.d.mts +17 -0
- package/dist/gha-artifacts-cleanup/plan.mjs +40 -0
- package/dist/gha-selected-files/index.d.mts +6 -0
- package/dist/gha-selected-files/index.mjs +38 -0
- package/dist/http-origin/index.d.mts +1 -0
- package/dist/http-origin/index.mjs +23 -0
- package/dist/index.d.mts +11 -2
- package/dist/index.mjs +7 -1
- 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/dist/process-line-buffer/index.d.mts +7 -0
- package/dist/process-line-buffer/index.mjs +28 -0
- package/dist/shared-context/{fake-git.test-helpers.d.mts → fake-git.d.mts} +1 -0
- package/dist/shared-context/{fake-git.test-helpers.mjs → fake-git.mjs} +4 -1
- package/dist/shared-context/index.d.mts +2 -0
- package/dist/shared-context/index.mjs +1 -0
- package/package.json +37 -1
- package/scripts/gha/diagnose-port-collision.sh +232 -0
- package/scripts/gha/prepare-trivy-db.sh +32 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function summarize(deleted) {
|
|
2
|
+
return {
|
|
3
|
+
deletedCount: deleted.length,
|
|
4
|
+
bytesFreed: deleted.reduce((total, artifact) => total + artifact.size_in_bytes, 0),
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
export function planRunDeletions(artifacts, classify) {
|
|
8
|
+
return artifacts.filter((artifact) => !artifact.expired && classify(artifact.name) === 'delete');
|
|
9
|
+
}
|
|
10
|
+
export function isSweepCandidate(artifact, cutoffIso, classify) {
|
|
11
|
+
return (!artifact.expired && artifact.created_at < cutoffIso && classify(artifact.name) === 'delete');
|
|
12
|
+
}
|
|
13
|
+
const MAX_CONSECUTIVE_EXPIRED_PAGES = 5;
|
|
14
|
+
const MAX_PAGES = 150;
|
|
15
|
+
export function shouldStopPaging(pageArtifacts, state) {
|
|
16
|
+
if (state.page >= MAX_PAGES)
|
|
17
|
+
return true;
|
|
18
|
+
if (pageArtifacts.length === 0)
|
|
19
|
+
return true;
|
|
20
|
+
return state.consecutiveExpiredPages >= MAX_CONSECUTIVE_EXPIRED_PAGES;
|
|
21
|
+
}
|
|
22
|
+
export function nextPagingState(pageArtifacts, state) {
|
|
23
|
+
const pageFullyExpired = pageArtifacts.every((artifact) => artifact.expired);
|
|
24
|
+
return {
|
|
25
|
+
page: state.page + 1,
|
|
26
|
+
consecutiveExpiredPages: pageFullyExpired ? state.consecutiveExpiredPages + 1 : 0,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export async function planSweepDeletions(candidates, getConclusion) {
|
|
30
|
+
const toDelete = [];
|
|
31
|
+
for (const artifact of candidates) {
|
|
32
|
+
const runId = artifact.workflow_run?.id;
|
|
33
|
+
if (runId == null)
|
|
34
|
+
continue;
|
|
35
|
+
const conclusion = await getConclusion(runId);
|
|
36
|
+
if (conclusion === 'success' || conclusion === 'cancelled')
|
|
37
|
+
toDelete.push(artifact);
|
|
38
|
+
}
|
|
39
|
+
return toDelete;
|
|
40
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function encodeSelectedFiles(files: readonly string[]): string;
|
|
2
|
+
export declare const SELECTED_FILES_ENV_MAX_BYTES = 120000;
|
|
3
|
+
export declare function selectedFilesExceedEnvBudget(files: readonly string[]): boolean;
|
|
4
|
+
export declare function decodeSelectedFiles(raw: string | undefined | null): string[];
|
|
5
|
+
export declare function formatMultilineOutput(key: string, value: string, createId?: () => string): string;
|
|
6
|
+
export declare function writeSelectedFilesOutput(key: string, files: readonly string[], createId?: () => string): void;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { appendFileSync } from 'node:fs';
|
|
3
|
+
export function encodeSelectedFiles(files) {
|
|
4
|
+
return files.join('\n');
|
|
5
|
+
}
|
|
6
|
+
// Linux MAX_ARG_STRLEN is 131072. GitHub Actions interpolates selected-file
|
|
7
|
+
// lists into a step env block, so the encoded list plus the variable prefix
|
|
8
|
+
// must fit in one exec argument.
|
|
9
|
+
export const SELECTED_FILES_ENV_MAX_BYTES = 120_000;
|
|
10
|
+
export function selectedFilesExceedEnvBudget(files) {
|
|
11
|
+
return Buffer.byteLength(encodeSelectedFiles(files), 'utf8') > SELECTED_FILES_ENV_MAX_BYTES;
|
|
12
|
+
}
|
|
13
|
+
export function decodeSelectedFiles(raw) {
|
|
14
|
+
if (!raw)
|
|
15
|
+
return [];
|
|
16
|
+
return raw.split('\n').filter((file) => file.trim().length > 0);
|
|
17
|
+
}
|
|
18
|
+
export function formatMultilineOutput(key, value, createId = randomUUID) {
|
|
19
|
+
const delimiter = collisionFreeDelimiter(key, value, createId);
|
|
20
|
+
const body = value === '' ? '' : `${value}\n`;
|
|
21
|
+
return `${key}<<${delimiter}\n${body}${delimiter}\n`;
|
|
22
|
+
}
|
|
23
|
+
export function writeSelectedFilesOutput(key, files, createId = randomUUID) {
|
|
24
|
+
const target = process.env.GITHUB_OUTPUT;
|
|
25
|
+
if (target) {
|
|
26
|
+
appendFileSync(target, formatMultilineOutput(key, encodeSelectedFiles(files), createId));
|
|
27
|
+
}
|
|
28
|
+
console.log(`[select] ${key} (${files.length} file${files.length === 1 ? '' : 's'})`);
|
|
29
|
+
}
|
|
30
|
+
function collisionFreeDelimiter(key, value, createId) {
|
|
31
|
+
const prefix = key.toUpperCase().replace(/[^A-Z0-9]/g, '_');
|
|
32
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
33
|
+
const candidate = `${prefix}_${createId().toUpperCase().replaceAll('-', '_')}`;
|
|
34
|
+
if (!value.includes(candidate))
|
|
35
|
+
return candidate;
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`could not create a collision-free GitHub output delimiter for ${key} after 10 attempts`);
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function validateOptionalHttpOrigin(value: string, fieldName?: string): void;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function validateOptionalHttpOrigin(value, fieldName = 'origin') {
|
|
2
|
+
if (!value)
|
|
3
|
+
return;
|
|
4
|
+
let url;
|
|
5
|
+
try {
|
|
6
|
+
url = new URL(value);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
throw invalidOriginError(fieldName);
|
|
10
|
+
}
|
|
11
|
+
if (!['http:', 'https:'].includes(url.protocol) ||
|
|
12
|
+
url.username !== '' ||
|
|
13
|
+
url.password !== '' ||
|
|
14
|
+
url.pathname !== '/' ||
|
|
15
|
+
url.search !== '' ||
|
|
16
|
+
url.hash !== '' ||
|
|
17
|
+
![url.origin, `${url.origin}/`].includes(value)) {
|
|
18
|
+
throw invalidOriginError(fieldName);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function invalidOriginError(fieldName) {
|
|
22
|
+
return new Error(`${fieldName} must be empty or a pure HTTP(S) origin without credentials, path, query, or fragment`);
|
|
23
|
+
}
|
package/dist/index.d.mts
CHANGED
|
@@ -9,5 +9,14 @@ export { createVitestBlobManifest, inspectVitestBlobBundle, parseVitestBlobManif
|
|
|
9
9
|
export type { InspectedVitestBlobBundle, VitestBlobIdentity, VitestBlobManifest, } from './vitest-blob-manifest/index.mts';
|
|
10
10
|
export { findWorkspaceLinkMismatches, formatReleaseAgeFailure, INSTALL_TERMINATION_FAILED, isReleaseAgeViolation, parseInstallOptions, parseReleaseAgeViolations, runInstallLifecycle, } from './pnpm-install/index.mts';
|
|
11
11
|
export type { InstallOptions, Lifecycle } from './pnpm-install/index.mts';
|
|
12
|
-
export { buildContextFromTrackedFiles, buildSharedContext, gitEnv, runNamedChecks, } from './shared-context/index.mts';
|
|
13
|
-
export type { NamedCheck, SharedContext } from './shared-context/index.mts';
|
|
12
|
+
export { buildContextFromTrackedFiles, buildSharedContext, clearFakeGitEnv, gitEnv, installFakeGit, runNamedChecks, setFakeGitTrackedFiles, } from './shared-context/index.mts';
|
|
13
|
+
export type { FakeGitOptions, NamedCheck, SharedContext } from './shared-context/index.mts';
|
|
14
|
+
export { decodeSelectedFiles, encodeSelectedFiles, formatMultilineOutput, SELECTED_FILES_ENV_MAX_BYTES, selectedFilesExceedEnvBudget, writeSelectedFilesOutput, } from './gha-selected-files/index.mts';
|
|
15
|
+
export { createArtifactClassifier, parseArtifactPatternsJson, planRunDeletions, runCleanup, sweepCleanup, } from './gha-artifacts-cleanup/index.mts';
|
|
16
|
+
export type { ArtifactClassification, ArtifactClassifier, ArtifactPatterns, CleanupRequest, DeletionSummary, } from './gha-artifacts-cleanup/index.mts';
|
|
17
|
+
export { validateOptionalHttpOrigin } from './http-origin/index.mts';
|
|
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
|
@@ -4,4 +4,10 @@ export { dollarQuoteEnd, lineOf, maskSqlQuotedText, readDollarQuoteDelimiter, re
|
|
|
4
4
|
export { auditCiJobRuntime, parseWorkflowNameMatch } from './gha-runtime-audit/index.mjs';
|
|
5
5
|
export { createVitestBlobManifest, inspectVitestBlobBundle, parseVitestBlobManifest, serializeVitestBlobManifest, VITEST_BLOB_MANIFEST_FILENAME, VITEST_BLOB_MANIFEST_VERSION, vitestBlobBundlePaths, writeVitestBlobManifest, } from './vitest-blob-manifest/index.mjs';
|
|
6
6
|
export { findWorkspaceLinkMismatches, formatReleaseAgeFailure, INSTALL_TERMINATION_FAILED, isReleaseAgeViolation, parseInstallOptions, parseReleaseAgeViolations, runInstallLifecycle, } from './pnpm-install/index.mjs';
|
|
7
|
-
export { buildContextFromTrackedFiles, buildSharedContext, gitEnv, runNamedChecks, } from './shared-context/index.mjs';
|
|
7
|
+
export { buildContextFromTrackedFiles, buildSharedContext, clearFakeGitEnv, gitEnv, installFakeGit, runNamedChecks, setFakeGitTrackedFiles, } from './shared-context/index.mjs';
|
|
8
|
+
export { decodeSelectedFiles, encodeSelectedFiles, formatMultilineOutput, SELECTED_FILES_ENV_MAX_BYTES, selectedFilesExceedEnvBudget, writeSelectedFilesOutput, } from './gha-selected-files/index.mjs';
|
|
9
|
+
export { createArtifactClassifier, parseArtifactPatternsJson, planRunDeletions, runCleanup, sweepCleanup, } from './gha-artifacts-cleanup/index.mjs';
|
|
10
|
+
export { validateOptionalHttpOrigin } from './http-origin/index.mjs';
|
|
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;
|