@wildo-ai/saas-technical-doc 1.1.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/LICENSE +34 -0
- package/dist/esm/.builder.pid +9 -0
- package/dist/esm/build/csp-emit.d.ts +9 -0
- package/dist/esm/build/csp-emit.d.ts.map +1 -0
- package/dist/esm/build/csp-emit.js +8 -0
- package/dist/esm/build/csp-emit.js.map +1 -0
- package/dist/esm/build/load-materialized-frontend-providers.d.ts +9 -0
- package/dist/esm/build/load-materialized-frontend-providers.d.ts.map +1 -0
- package/dist/esm/build/load-materialized-frontend-providers.js +9 -0
- package/dist/esm/build/load-materialized-frontend-providers.js.map +1 -0
- package/dist/esm/companion/application-documentation/application-organization-role-documentation.d.ts +66 -0
- package/dist/esm/companion/application-documentation/application-organization-role-documentation.d.ts.map +1 -0
- package/dist/esm/companion/application-documentation/application-organization-role-documentation.js +195 -0
- package/dist/esm/companion/application-documentation/application-organization-role-documentation.js.map +1 -0
- package/dist/esm/companion/application-documentation/application-organization-unit-resource-documentation.d.ts +36 -0
- package/dist/esm/companion/application-documentation/application-organization-unit-resource-documentation.d.ts.map +1 -0
- package/dist/esm/companion/application-documentation/application-organization-unit-resource-documentation.js +71 -0
- package/dist/esm/companion/application-documentation/application-organization-unit-resource-documentation.js.map +1 -0
- package/dist/esm/companion/application-documentation/technical-documentation-engine-content-bundle.d.ts +37 -0
- package/dist/esm/companion/application-documentation/technical-documentation-engine-content-bundle.d.ts.map +1 -0
- package/dist/esm/companion/application-documentation/technical-documentation-engine-content-bundle.js +1865 -0
- package/dist/esm/companion/application-documentation/technical-documentation-engine-content-bundle.js.map +1 -0
- package/dist/esm/companion/application-documentation/technical-documentation-operational-evidence.d.ts +22 -0
- package/dist/esm/companion/application-documentation/technical-documentation-operational-evidence.d.ts.map +1 -0
- package/dist/esm/companion/application-documentation/technical-documentation-operational-evidence.js +31 -0
- package/dist/esm/companion/application-documentation/technical-documentation-operational-evidence.js.map +1 -0
- package/dist/esm/companion/application-documentation/technical-documentation-private-derivation.d.ts +31 -0
- package/dist/esm/companion/application-documentation/technical-documentation-private-derivation.d.ts.map +1 -0
- package/dist/esm/companion/application-documentation/technical-documentation-private-derivation.js +33 -0
- package/dist/esm/companion/application-documentation/technical-documentation-private-derivation.js.map +1 -0
- package/dist/esm/companion/application-documentation/technical-documentation-publication-policy.d.ts +13 -0
- package/dist/esm/companion/application-documentation/technical-documentation-publication-policy.d.ts.map +1 -0
- package/dist/esm/companion/application-documentation/technical-documentation-publication-policy.js +93 -0
- package/dist/esm/companion/application-documentation/technical-documentation-publication-policy.js.map +1 -0
- package/dist/esm/companion/content/application-consumer-documentation-content-loader.d.ts +52 -0
- package/dist/esm/companion/content/application-consumer-documentation-content-loader.d.ts.map +1 -0
- package/dist/esm/companion/content/application-consumer-documentation-content-loader.js +191 -0
- package/dist/esm/companion/content/application-consumer-documentation-content-loader.js.map +1 -0
- package/dist/esm/companion/index.d.ts +39 -0
- package/dist/esm/companion/index.d.ts.map +1 -0
- package/dist/esm/companion/index.js +39 -0
- package/dist/esm/companion/index.js.map +1 -0
- package/dist/esm/companion/openapi-generator.d.ts +94 -0
- package/dist/esm/companion/openapi-generator.d.ts.map +1 -0
- package/dist/esm/companion/openapi-generator.js +1562 -0
- package/dist/esm/companion/openapi-generator.js.map +1 -0
- package/dist/esm/companion/operation-projection.schemas.d.ts +797 -0
- package/dist/esm/companion/operation-projection.schemas.d.ts.map +1 -0
- package/dist/esm/companion/operation-projection.schemas.js +610 -0
- package/dist/esm/companion/operation-projection.schemas.js.map +1 -0
- package/dist/esm/companion/publish-result.types.d.ts +124 -0
- package/dist/esm/companion/publish-result.types.d.ts.map +1 -0
- package/dist/esm/companion/publish-result.types.js +28 -0
- package/dist/esm/companion/publish-result.types.js.map +1 -0
- package/dist/esm/companion/rendering/technical-documentation-authorized-bundle-reader.d.ts +26 -0
- package/dist/esm/companion/rendering/technical-documentation-authorized-bundle-reader.d.ts.map +1 -0
- package/dist/esm/companion/rendering/technical-documentation-authorized-bundle-reader.js +63 -0
- package/dist/esm/companion/rendering/technical-documentation-authorized-bundle-reader.js.map +1 -0
- package/dist/esm/companion/rendering/technical-documentation-build-measurement.d.ts +23 -0
- package/dist/esm/companion/rendering/technical-documentation-build-measurement.d.ts.map +1 -0
- package/dist/esm/companion/rendering/technical-documentation-build-measurement.js +104 -0
- package/dist/esm/companion/rendering/technical-documentation-build-measurement.js.map +1 -0
- package/dist/esm/companion/rendering/technical-documentation-docusaurus-renderer.d.ts +9 -0
- package/dist/esm/companion/rendering/technical-documentation-docusaurus-renderer.d.ts.map +1 -0
- package/dist/esm/companion/rendering/technical-documentation-docusaurus-renderer.js +421 -0
- package/dist/esm/companion/rendering/technical-documentation-docusaurus-renderer.js.map +1 -0
- package/dist/esm/companion/rendering/technical-documentation-managed-tree-validator.d.ts +72 -0
- package/dist/esm/companion/rendering/technical-documentation-managed-tree-validator.d.ts.map +1 -0
- package/dist/esm/companion/rendering/technical-documentation-managed-tree-validator.js +204 -0
- package/dist/esm/companion/rendering/technical-documentation-managed-tree-validator.js.map +1 -0
- package/dist/esm/companion/rendering/technical-documentation-markdown-renderer.d.ts +9 -0
- package/dist/esm/companion/rendering/technical-documentation-markdown-renderer.d.ts.map +1 -0
- package/dist/esm/companion/rendering/technical-documentation-markdown-renderer.js +106 -0
- package/dist/esm/companion/rendering/technical-documentation-markdown-renderer.js.map +1 -0
- package/dist/esm/companion/rendering/technical-documentation-mdx-escaping.d.ts +43 -0
- package/dist/esm/companion/rendering/technical-documentation-mdx-escaping.d.ts.map +1 -0
- package/dist/esm/companion/rendering/technical-documentation-mdx-escaping.js +88 -0
- package/dist/esm/companion/rendering/technical-documentation-mdx-escaping.js.map +1 -0
- package/dist/esm/companion/rendering/technical-documentation-openapi-renderer.d.ts +7 -0
- package/dist/esm/companion/rendering/technical-documentation-openapi-renderer.d.ts.map +1 -0
- package/dist/esm/companion/rendering/technical-documentation-openapi-renderer.js +51 -0
- package/dist/esm/companion/rendering/technical-documentation-openapi-renderer.js.map +1 -0
- package/dist/esm/companion/rendering/technical-documentation-render-model.d.ts +45 -0
- package/dist/esm/companion/rendering/technical-documentation-render-model.d.ts.map +1 -0
- package/dist/esm/companion/rendering/technical-documentation-render-model.js +178 -0
- package/dist/esm/companion/rendering/technical-documentation-render-model.js.map +1 -0
- package/dist/esm/companion/rendering/technical-documentation-search-index-renderer.d.ts +3 -0
- package/dist/esm/companion/rendering/technical-documentation-search-index-renderer.d.ts.map +1 -0
- package/dist/esm/companion/rendering/technical-documentation-search-index-renderer.js +49 -0
- package/dist/esm/companion/rendering/technical-documentation-search-index-renderer.js.map +1 -0
- package/dist/esm/companion/spec-to-operation-doc.d.ts +176 -0
- package/dist/esm/companion/spec-to-operation-doc.d.ts.map +1 -0
- package/dist/esm/companion/spec-to-operation-doc.js +326 -0
- package/dist/esm/companion/spec-to-operation-doc.js.map +1 -0
- package/dist/esm/companion/technical-documentation-asset-path.d.ts +16 -0
- package/dist/esm/companion/technical-documentation-asset-path.d.ts.map +1 -0
- package/dist/esm/companion/technical-documentation-asset-path.js +19 -0
- package/dist/esm/companion/technical-documentation-asset-path.js.map +1 -0
- package/dist/esm/companion/technical-documentation-capture-execution-port.d.ts +14 -0
- package/dist/esm/companion/technical-documentation-capture-execution-port.d.ts.map +1 -0
- package/dist/esm/companion/technical-documentation-capture-execution-port.js +1 -0
- package/dist/esm/companion/technical-documentation-capture-execution-port.js.map +1 -0
- package/dist/esm/companion/technical-documentation-diagram-materializer.d.ts +25 -0
- package/dist/esm/companion/technical-documentation-diagram-materializer.d.ts.map +1 -0
- package/dist/esm/companion/technical-documentation-diagram-materializer.js +86 -0
- package/dist/esm/companion/technical-documentation-diagram-materializer.js.map +1 -0
- package/dist/esm/companion/technical-documentation-placeholder-materializer.d.ts +17 -0
- package/dist/esm/companion/technical-documentation-placeholder-materializer.d.ts.map +1 -0
- package/dist/esm/companion/technical-documentation-placeholder-materializer.js +63 -0
- package/dist/esm/companion/technical-documentation-placeholder-materializer.js.map +1 -0
- package/dist/esm/companion/zod-to-openapi.d.ts +67 -0
- package/dist/esm/companion/zod-to-openapi.d.ts.map +1 -0
- package/dist/esm/companion/zod-to-openapi.js +211 -0
- package/dist/esm/companion/zod-to-openapi.js.map +1 -0
- package/dist/esm/companion-exports.d.ts +32 -0
- package/dist/esm/companion-exports.d.ts.map +1 -0
- package/dist/esm/companion-exports.js +32 -0
- package/dist/esm/companion-exports.js.map +1 -0
- package/dist/esm/config/define-tech-doc-config.d.ts +38 -0
- package/dist/esm/config/define-tech-doc-config.d.ts.map +1 -0
- package/dist/esm/config/define-tech-doc-config.js +40 -0
- package/dist/esm/config/define-tech-doc-config.js.map +1 -0
- package/dist/esm/config/index.d.ts +22 -0
- package/dist/esm/config/index.d.ts.map +1 -0
- package/dist/esm/config/index.js +22 -0
- package/dist/esm/config/index.js.map +1 -0
- package/dist/esm/config/wildo-tech-doc-config.schemas.d.ts +204 -0
- package/dist/esm/config/wildo-tech-doc-config.schemas.d.ts.map +1 -0
- package/dist/esm/config/wildo-tech-doc-config.schemas.js +192 -0
- package/dist/esm/config/wildo-tech-doc-config.schemas.js.map +1 -0
- package/dist/esm/content/application-consumer-documentation-content-manifest.schemas.d.ts +82 -0
- package/dist/esm/content/application-consumer-documentation-content-manifest.schemas.d.ts.map +1 -0
- package/dist/esm/content/application-consumer-documentation-content-manifest.schemas.js +114 -0
- package/dist/esm/content/application-consumer-documentation-content-manifest.schemas.js.map +1 -0
- package/dist/esm/content/application-consumer-documentation-content.techdoc.d.ts +420 -0
- package/dist/esm/content/application-consumer-documentation-content.techdoc.d.ts.map +1 -0
- package/dist/esm/content/application-consumer-documentation-content.techdoc.js +9619 -0
- package/dist/esm/content/application-consumer-documentation-content.techdoc.js.map +1 -0
- package/dist/esm/content.exports.d.ts +9 -0
- package/dist/esm/content.exports.d.ts.map +1 -0
- package/dist/esm/content.exports.js +9 -0
- package/dist/esm/content.exports.js.map +1 -0
- package/dist/esm/index.d.ts +26 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +26 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/openapi/api-reference-link-index.d.ts +98 -0
- package/dist/esm/openapi/api-reference-link-index.d.ts.map +1 -0
- package/dist/esm/openapi/api-reference-link-index.js +301 -0
- package/dist/esm/openapi/api-reference-link-index.js.map +1 -0
- package/dist/esm/openapi/api-reference-targets.d.ts +71 -0
- package/dist/esm/openapi/api-reference-targets.d.ts.map +1 -0
- package/dist/esm/openapi/api-reference-targets.js +114 -0
- package/dist/esm/openapi/api-reference-targets.js.map +1 -0
- package/dist/esm/openapi/index.d.ts +20 -0
- package/dist/esm/openapi/index.d.ts.map +1 -0
- package/dist/esm/openapi/index.js +20 -0
- package/dist/esm/openapi/index.js.map +1 -0
- package/dist/esm/openapi/openapi-generation-output.schemas.d.ts +80 -0
- package/dist/esm/openapi/openapi-generation-output.schemas.d.ts.map +1 -0
- package/dist/esm/openapi/openapi-generation-output.schemas.js +76 -0
- package/dist/esm/openapi/openapi-generation-output.schemas.js.map +1 -0
- package/dist/esm/openapi-reference-model.exports.d.ts +10 -0
- package/dist/esm/openapi-reference-model.exports.d.ts.map +1 -0
- package/dist/esm/openapi-reference-model.exports.js +10 -0
- package/dist/esm/openapi-reference-model.exports.js.map +1 -0
- package/dist/esm/runtime/AuthExchangePage.d.ts +84 -0
- package/dist/esm/runtime/AuthExchangePage.d.ts.map +1 -0
- package/dist/esm/runtime/AuthExchangePage.js +188 -0
- package/dist/esm/runtime/AuthExchangePage.js.map +1 -0
- package/dist/esm/runtime/DocsAuthContext.d.ts +119 -0
- package/dist/esm/runtime/DocsAuthContext.d.ts.map +1 -0
- package/dist/esm/runtime/DocsAuthContext.js +171 -0
- package/dist/esm/runtime/DocsAuthContext.js.map +1 -0
- package/dist/esm/runtime/decode-jwt-claims.d.ts +39 -0
- package/dist/esm/runtime/decode-jwt-claims.d.ts.map +1 -0
- package/dist/esm/runtime/decode-jwt-claims.js +86 -0
- package/dist/esm/runtime/decode-jwt-claims.js.map +1 -0
- package/dist/esm/runtime/docs-auth-client.d.ts +193 -0
- package/dist/esm/runtime/docs-auth-client.d.ts.map +1 -0
- package/dist/esm/runtime/docs-auth-client.js +211 -0
- package/dist/esm/runtime/docs-auth-client.js.map +1 -0
- package/dist/esm/runtime/docs-auth-session.schemas.d.ts +77 -0
- package/dist/esm/runtime/docs-auth-session.schemas.d.ts.map +1 -0
- package/dist/esm/runtime/docs-auth-session.schemas.js +50 -0
- package/dist/esm/runtime/docs-auth-session.schemas.js.map +1 -0
- package/dist/esm/runtime/frontend-provider-registry.techdoc.d.ts +17 -0
- package/dist/esm/runtime/frontend-provider-registry.techdoc.d.ts.map +1 -0
- package/dist/esm/runtime/frontend-provider-registry.techdoc.js +23 -0
- package/dist/esm/runtime/frontend-provider-registry.techdoc.js.map +1 -0
- package/dist/esm/runtime/index.d.ts +57 -0
- package/dist/esm/runtime/index.d.ts.map +1 -0
- package/dist/esm/runtime/index.js +76 -0
- package/dist/esm/runtime/index.js.map +1 -0
- package/dist/esm/runtime/openapi-reference-conservation.d.ts +20 -0
- package/dist/esm/runtime/openapi-reference-conservation.d.ts.map +1 -0
- package/dist/esm/runtime/openapi-reference-conservation.js +102 -0
- package/dist/esm/runtime/openapi-reference-conservation.js.map +1 -0
- package/dist/esm/runtime/openapi-reference-model.d.ts +224 -0
- package/dist/esm/runtime/openapi-reference-model.d.ts.map +1 -0
- package/dist/esm/runtime/openapi-reference-model.js +579 -0
- package/dist/esm/runtime/openapi-reference-model.js.map +1 -0
- package/dist/esm/runtime/openapi-reference-view.d.ts +13 -0
- package/dist/esm/runtime/openapi-reference-view.d.ts.map +1 -0
- package/dist/esm/runtime/openapi-reference-view.js +284 -0
- package/dist/esm/runtime/openapi-reference-view.js.map +1 -0
- package/dist/esm/runtime/use-docs-auth-session.d.ts +26 -0
- package/dist/esm/runtime/use-docs-auth-session.d.ts.map +1 -0
- package/dist/esm/runtime/use-docs-auth-session.js +34 -0
- package/dist/esm/runtime/use-docs-auth-session.js.map +1 -0
- package/dist/esm/runtime/use-docs-frontend-provider-registry.d.ts +3 -0
- package/dist/esm/runtime/use-docs-frontend-provider-registry.d.ts.map +1 -0
- package/dist/esm/runtime/use-docs-frontend-provider-registry.js +5 -0
- package/dist/esm/runtime/use-docs-frontend-provider-registry.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/package.json +117 -0
|
@@ -0,0 +1,1562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @wildo-package @wildo-ai/saas-technical-doc/companion (generator)
|
|
3
|
+
*
|
|
4
|
+
* Pure-function OpenAPI 3.1 generator (saas-technical-doc.md Step 3).
|
|
5
|
+
*
|
|
6
|
+
* Pipeline:
|
|
7
|
+
*
|
|
8
|
+
* `OpenApiGenerationInput`
|
|
9
|
+
* ──filterApiCallOperations──► (assert URL-bearing variants)
|
|
10
|
+
* ──groupOperationsByConsumerApiSection──► (use source-resolved section)
|
|
11
|
+
* ──buildOpenApiDocument──► (per-section OpenAPI 3.1 doc as a JS object)
|
|
12
|
+
* ──assembleOpenApiGenerationOutput──►(top-level `OpenApiGenerationOutput`)
|
|
13
|
+
*
|
|
14
|
+
* Key design points:
|
|
15
|
+
*
|
|
16
|
+
* K-1: This module is `OpenApiGenerationInput` → `OpenApiGenerationOutput`
|
|
17
|
+
* and contains no I/O, no global state, and no DI. It is consumed by
|
|
18
|
+
* the companion controller (which owns the introspection hop +
|
|
19
|
+
* filesystem write — see Step 4) and by unit tests in isolation.
|
|
20
|
+
*
|
|
21
|
+
* K-2: Sections that contain ZERO operations after filtering are
|
|
22
|
+
* OMITTED from the output's `sections[]` array (per the
|
|
23
|
+
* `OpenApiGenerationOutputSchema` JSDoc). The companion UI
|
|
24
|
+
* surfaces this as an absent section — the empty section is
|
|
25
|
+
* intentional, not a generator bug.
|
|
26
|
+
*
|
|
27
|
+
* K-3: The generator uses Zod-parsed input (validated up-front via
|
|
28
|
+
* `OpenApiGenerationInputSchema`) so every downstream helper can
|
|
29
|
+
* assume well-formed projections. Callers MUST call
|
|
30
|
+
* `generateOpenApiDocuments(input)` (which parses) and NOT the
|
|
31
|
+
* sub-helpers directly with unparsed data.
|
|
32
|
+
*
|
|
33
|
+
* K-4: The semantic output carries one document object. YAML/JSON
|
|
34
|
+
* serialization is renderer-owned so divergent format payloads cannot
|
|
35
|
+
* cross this contract.
|
|
36
|
+
*
|
|
37
|
+
* @wildo-boundary
|
|
38
|
+
* Imports only the portable OpenAPI output contract, the local operation
|
|
39
|
+
* projection contract, the lightweight public runtime constants and
|
|
40
|
+
* specification-prose cleanup. No serializer, React or saas-models root
|
|
41
|
+
* barrel enters this semantic generator.
|
|
42
|
+
*/
|
|
43
|
+
import { WildoHeaderKeys } from '@wildo-ai/saas-models/public-runtime';
|
|
44
|
+
import { OpenApiSection, } from '../openapi/openapi-generation-output.schemas.js';
|
|
45
|
+
import { OpenApiGenerationInputSchema, OPENAPI_RESOURCE_IDENTITY_CONTRACT_VERSION, OperationProjectionAuthenticationMode, OperationProjectionVariantType, } from './operation-projection.schemas.js';
|
|
46
|
+
import { stripImplementationNoise } from './spec-to-operation-doc.js';
|
|
47
|
+
/**
|
|
48
|
+
* Retains every projected URL-bearing operation.
|
|
49
|
+
*
|
|
50
|
+
* - Variant outside `API_CALL` / `API_CALL_WITH_CALLBACK` (K-5):
|
|
51
|
+
* defense-in-depth re-check on top of the projector's filter.
|
|
52
|
+
* If a non-API variant ever leaks through, drop it silently here
|
|
53
|
+
* rather than throwing — the projector is the single source of
|
|
54
|
+
* truth for "should this op even be in the projection at all," but
|
|
55
|
+
* we don't want a future projector regression to corrupt the
|
|
56
|
+
* generated doc with non-HTTP operations.
|
|
57
|
+
*
|
|
58
|
+
* `isApiKeyAccessDisabled` controls the operation's accepted authentication
|
|
59
|
+
* schemes. It does not make an HTTP operation disappear from its API contract.
|
|
60
|
+
*
|
|
61
|
+
* Returns a NEW array — does not mutate the input.
|
|
62
|
+
*/
|
|
63
|
+
export function filterApiCallOperations(operations) {
|
|
64
|
+
return operations.filter((op) => {
|
|
65
|
+
if (op.variantType !== OperationProjectionVariantType.API_CALL &&
|
|
66
|
+
op.variantType !== OperationProjectionVariantType.API_CALL_WITH_CALLBACK) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Groups operations by the explicit consumer-facing section resolved by the
|
|
74
|
+
* source projector. This layer deliberately does not reinterpret role strings
|
|
75
|
+
* or internal resource scopes: doing so would create a second authority for
|
|
76
|
+
* which product reference owns an operation.
|
|
77
|
+
*
|
|
78
|
+
* Returns a frozen `Record` keyed by `OpenApiSection`. Sections with
|
|
79
|
+
* zero operations are returned as empty arrays (NOT omitted) so the
|
|
80
|
+
* caller's filtering logic in `assembleOpenApiGenerationOutput` is the
|
|
81
|
+
* single place that decides "drop empty sections from the output."
|
|
82
|
+
*/
|
|
83
|
+
export function groupOperationsByConsumerApiSection(operations) {
|
|
84
|
+
const apiReferenceOperations = [];
|
|
85
|
+
const applicationAdministrationOperations = [];
|
|
86
|
+
for (const op of operations) {
|
|
87
|
+
switch (op.consumerApiSection) {
|
|
88
|
+
case OpenApiSection.API_REFERENCE:
|
|
89
|
+
apiReferenceOperations.push(op);
|
|
90
|
+
break;
|
|
91
|
+
case OpenApiSection.APPLICATION_ADMINISTRATION_API_REFERENCE:
|
|
92
|
+
applicationAdministrationOperations.push(op);
|
|
93
|
+
break;
|
|
94
|
+
default: {
|
|
95
|
+
const exhaustiveSection = op.consumerApiSection;
|
|
96
|
+
throw new Error(`OpenAPI generator: unsupported consumer API section ${String(exhaustiveSection)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return Object.freeze({
|
|
101
|
+
[OpenApiSection.API_REFERENCE]: Object.freeze(apiReferenceOperations),
|
|
102
|
+
[OpenApiSection.APPLICATION_ADMINISTRATION_API_REFERENCE]: Object.freeze(applicationAdministrationOperations),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Stable, deterministic ordering for operations within an OpenAPI
|
|
107
|
+
* doc. Sorted by `(resourceIdentifier, path, httpVerb)` so the
|
|
108
|
+
* generated YAML diffs cleanly across runs even if the projector
|
|
109
|
+
* yields operations in a different order (e.g. iteration order
|
|
110
|
+
* depending on the resource registry walk).
|
|
111
|
+
*/
|
|
112
|
+
function sortOperationsForDeterministicOutput(operations) {
|
|
113
|
+
return [...operations].sort((a, b) => {
|
|
114
|
+
if (a.resourceIdentifier !== b.resourceIdentifier) {
|
|
115
|
+
return a.resourceIdentifier.localeCompare(b.resourceIdentifier);
|
|
116
|
+
}
|
|
117
|
+
if (a.path !== b.path) {
|
|
118
|
+
return a.path.localeCompare(b.path);
|
|
119
|
+
}
|
|
120
|
+
return a.httpVerb.localeCompare(b.httpVerb);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Maps an `OperationProjection` to its developer-facing OpenAPI `operationId`.
|
|
125
|
+
*
|
|
126
|
+
* VERB-FIRST camelCase — the convention the best public API references and the
|
|
127
|
+
* OpenAPI/SDK-generator ecosystem converge on (`createUser`, `listOrganizations`,
|
|
128
|
+
* `rotateApiKey`; see Speakeasy + Redocly operationId guidance). The
|
|
129
|
+
* `operationId` becomes the generated client method name; combined with the
|
|
130
|
+
* resource TAG, tag-grouping generators yield `sdk.organizations.create()` and
|
|
131
|
+
* flat generators yield `createOrganization()`.
|
|
132
|
+
*
|
|
133
|
+
* Composition: `<verb><ResourceNoun><Qualifiers>`, lower-camelCased.
|
|
134
|
+
*
|
|
135
|
+
* - **verb** — the standard CRUD identifiers (the LOWERCASE
|
|
136
|
+
* `CoreResourceOperation` values the projector actually emits) map to
|
|
137
|
+
* idiomatic verbs (`create→create`, `read→get`, `list→list`,
|
|
138
|
+
* `update→update`, `delete→delete`, `search→search` — see {@link CRUD_VERBS});
|
|
139
|
+
* any other identifier becomes its own verb phrase via `pascal`, whether a
|
|
140
|
+
* custom op (`exportAuditLogs`→`exportAuditLogs`, `EXPORT_AUDIT_LOGS`→
|
|
141
|
+
* `exportAuditLogs`) or a framework collection op the map omits
|
|
142
|
+
* (`count`→`count`, `create_many`→`createMany`).
|
|
143
|
+
* - **ResourceNoun** — `resourceIdentifier` PascalCased with camelCase word
|
|
144
|
+
* boundaries PRESERVED (`organizationApiKeys`→`OrganizationApiKeys`); the
|
|
145
|
+
* prior implementation lowercased segment interiors and produced
|
|
146
|
+
* `Organizationapikeys`.
|
|
147
|
+
* - **Redundancy** — a custom verb phrase that already contains the resource
|
|
148
|
+
* noun drops the repeat (`exportAuditLogs`, not `exportAuditLogsAuditLogs`).
|
|
149
|
+
* - **Qualifiers** — the `operationKey` segments AFTER the base identifier:
|
|
150
|
+
* the semantic `Via<Relationship>` disambiguator for relationship-nested
|
|
151
|
+
* paths, the `summary`/`context` data-mode, and `BULK`. Each is PascalCased
|
|
152
|
+
* and appended in order.
|
|
153
|
+
*
|
|
154
|
+
* Unique within the document and stable across runs (the multi-path
|
|
155
|
+
* disambiguator is the relationship name, not a positional index).
|
|
156
|
+
*/
|
|
157
|
+
/**
|
|
158
|
+
* Split a string on separators AND camelCase boundaries (lowercase/digit →
|
|
159
|
+
* uppercase) so multi-word camelCase identifiers keep their word boundaries.
|
|
160
|
+
*/
|
|
161
|
+
function splitWords(input) {
|
|
162
|
+
return input.split(/[^a-zA-Z0-9]+|(?<=[a-z0-9])(?=[A-Z])/u).filter((segment) => segment.length > 0);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Industry-standard initialisms whose casing carries meaning in public API
|
|
166
|
+
* vocabulary. This is presentation-only: stable resource and operation
|
|
167
|
+
* identities remain unchanged in tags, operation ids and `x-wildo` metadata.
|
|
168
|
+
*/
|
|
169
|
+
const API_REFERENCE_DISPLAY_INITIALISM_BY_TOKEN = Object.freeze({
|
|
170
|
+
a2a: 'A2A',
|
|
171
|
+
api: 'API',
|
|
172
|
+
dlq: 'DLQ',
|
|
173
|
+
idp: 'IdP',
|
|
174
|
+
ip: 'IP',
|
|
175
|
+
jwt: 'JWT',
|
|
176
|
+
m2m: 'M2M',
|
|
177
|
+
mcp: 'MCP',
|
|
178
|
+
oauth: 'OAuth',
|
|
179
|
+
oidc: 'OIDC',
|
|
180
|
+
pdf: 'PDF',
|
|
181
|
+
saml: 'SAML',
|
|
182
|
+
scim: 'SCIM',
|
|
183
|
+
siem: 'SIEM',
|
|
184
|
+
sso: 'SSO',
|
|
185
|
+
url: 'URL',
|
|
186
|
+
});
|
|
187
|
+
function apiReferenceDisplayWord(word, capitalizeOrdinaryWord) {
|
|
188
|
+
const initialism = API_REFERENCE_DISPLAY_INITIALISM_BY_TOKEN[word.toLowerCase()];
|
|
189
|
+
if (initialism !== undefined)
|
|
190
|
+
return initialism;
|
|
191
|
+
const lowercaseWord = word.toLowerCase();
|
|
192
|
+
return capitalizeOrdinaryWord
|
|
193
|
+
? `${lowercaseWord.charAt(0).toUpperCase()}${lowercaseWord.slice(1)}`
|
|
194
|
+
: lowercaseWord;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* PascalCase a string. Lowercases the interior ONLY for all-uppercase words
|
|
198
|
+
* (SCREAMING_SNAKE op identifiers like `EXPORT`, `EXCHANGE` → `Export`,
|
|
199
|
+
* `Exchange`); camelCase words (`Api`, `organization`) keep their interior so
|
|
200
|
+
* word boundaries survive (`organizationApiKeys` → `OrganizationApiKeys`).
|
|
201
|
+
* Shared by `buildOperationId` and the `components/schemas` namer
|
|
202
|
+
* (`componentSchemaName`) so operation ids and model names stay consistent.
|
|
203
|
+
*/
|
|
204
|
+
function pascal(input) {
|
|
205
|
+
return splitWords(input)
|
|
206
|
+
.map((word) => {
|
|
207
|
+
const rest = /^[A-Z0-9]+$/u.test(word) ? word.slice(1).toLowerCase() : word.slice(1);
|
|
208
|
+
return word.charAt(0).toUpperCase() + rest;
|
|
209
|
+
})
|
|
210
|
+
.join('');
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Idiomatic verb-first `operationId` verbs for the standard CRUD identifiers.
|
|
214
|
+
*
|
|
215
|
+
* Keyed on the LOWERCASE `@wildo-ai/saas-models` `CoreResourceOperation` VALUES
|
|
216
|
+
* (`'read'`, `'create'`, `'list'`, …) — NOT the SCREAMING_SNAKE enum MEMBER names.
|
|
217
|
+
* The projector sets `baseOperationIdentifier` to
|
|
218
|
+
* `String(operation.operationIdentifier)`, which for a core operation IS the
|
|
219
|
+
* lowercase enum value, so the lookup key MUST be lowercase.
|
|
220
|
+
*
|
|
221
|
+
* (History: this map was originally keyed on `'READ'` / `'CREATE'` / … — the
|
|
222
|
+
* enum member names — so no lookup ever matched at runtime and every base fell
|
|
223
|
+
* through to the custom-verb `pascal` path. It went unnoticed because
|
|
224
|
+
* `pascal(value)` lower-cased equals the intended verb for `create` / `list` /
|
|
225
|
+
* `update` / `delete` / `search` — the whole map was a no-op EXCEPT `read`,
|
|
226
|
+
* which produced `readX` instead of the intended `getX`.)
|
|
227
|
+
*
|
|
228
|
+
* Mirrored as string literals for the same boundary reason as the other
|
|
229
|
+
* framework constants in this file — the generator must NOT import
|
|
230
|
+
* `@wildo-ai/saas-models` (see the module `@wildo-boundary` note). Keep in sync
|
|
231
|
+
* with `CoreResourceOperation`.
|
|
232
|
+
*
|
|
233
|
+
* `read → get` is the sole non-identity mapping; the other five are listed for
|
|
234
|
+
* self-documentation and drift-resistance (they equal `pascal(value)`). Every
|
|
235
|
+
* OTHER identifier — custom ops AND the framework's own `count` / `create_many` /
|
|
236
|
+
* `update_many` / `delete_many` — becomes its own verb phrase via `pascal`
|
|
237
|
+
* (`create_many → createMany…`, `export_audit_logs → exportAuditLogs…`).
|
|
238
|
+
*/
|
|
239
|
+
const CRUD_VERBS = {
|
|
240
|
+
create: 'create',
|
|
241
|
+
read: 'get',
|
|
242
|
+
list: 'list',
|
|
243
|
+
update: 'update',
|
|
244
|
+
delete: 'delete',
|
|
245
|
+
search: 'search',
|
|
246
|
+
};
|
|
247
|
+
/**
|
|
248
|
+
* Human, sentence-case verb labels for the default operation TITLE (`summary`).
|
|
249
|
+
*
|
|
250
|
+
* Keyed on the same lowercase `CoreResourceOperation` values as {@link CRUD_VERBS}
|
|
251
|
+
* (see that constant for the boundary + casing rationale). NB the TITLE verb for
|
|
252
|
+
* a read is the plain `'Read'` (e.g. "Read application auth policy"), distinct
|
|
253
|
+
* from the `operationId` verb `get` — the two surfaces intentionally diverge.
|
|
254
|
+
* Identifiers absent here fall through to a humanised phrase of the identifier
|
|
255
|
+
* itself in `buildDefaultSummary`.
|
|
256
|
+
*/
|
|
257
|
+
const VERB_LABEL = {
|
|
258
|
+
create: 'Create',
|
|
259
|
+
read: 'Read',
|
|
260
|
+
list: 'List',
|
|
261
|
+
update: 'Update',
|
|
262
|
+
delete: 'Delete',
|
|
263
|
+
search: 'Search',
|
|
264
|
+
};
|
|
265
|
+
function buildOperationId(op) {
|
|
266
|
+
const base = op.baseOperationIdentifier;
|
|
267
|
+
const isCustom = CRUD_VERBS[base] === undefined;
|
|
268
|
+
const verbPascal = isCustom ? pascal(base) : pascal(CRUD_VERBS[base]);
|
|
269
|
+
const resourcePascal = pascal(op.resourceIdentifier);
|
|
270
|
+
// A custom verb phrase that already names the resource (e.g. EXPORT_AUDIT_LOGS
|
|
271
|
+
// on `auditLogs`) drops the repeated noun.
|
|
272
|
+
const corePascal = isCustom && verbPascal.includes(resourcePascal)
|
|
273
|
+
? verbPascal
|
|
274
|
+
: `${verbPascal}${resourcePascal}`;
|
|
275
|
+
// Qualifiers = `operationKey` with the leading base identifier removed (the
|
|
276
|
+
// base can itself contain `_`, so we strip by length, not by splitting).
|
|
277
|
+
const qualifierTail = op.operationKey.startsWith(base)
|
|
278
|
+
? op.operationKey.slice(base.length)
|
|
279
|
+
: '';
|
|
280
|
+
const qualifiersPascal = qualifierTail.split('_').filter(Boolean).map(pascal).join('');
|
|
281
|
+
const combined = `${corePascal}${qualifiersPascal}`;
|
|
282
|
+
return combined.charAt(0).toLowerCase() + combined.slice(1);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Default summary fallback. Used when the projector did not provide
|
|
286
|
+
* an explicit `summary` so the docs site never renders a blank title.
|
|
287
|
+
*/
|
|
288
|
+
/**
|
|
289
|
+
* The operation TITLE (OpenAPI `summary` — page title, nav label, breadcrumb).
|
|
290
|
+
*
|
|
291
|
+
* A SHORT, human, derived action label — NOT the authored `purpose` prose
|
|
292
|
+
* (that is impl-aware intent text and now lives in the `description`; see
|
|
293
|
+
* `buildOperationDocFromSpec`). Mirrors the verb-first `operationId` but rendered
|
|
294
|
+
* as a readable sentence-case phrase:
|
|
295
|
+
*
|
|
296
|
+
* read + applicationAuthPolicy → "Read application auth policy"
|
|
297
|
+
* list + organizationApiKeys (summary variant) → "List organization api keys (summary)"
|
|
298
|
+
* create + appAnnouncements via posted-by-users → "Create app announcements via posted by users"
|
|
299
|
+
* export_audit_logs + auditLogs → "Export audit logs"
|
|
300
|
+
*
|
|
301
|
+
* The qualifier suffixes (`Via<…>`, `summary`/`context` data-mode, `BULK`) are
|
|
302
|
+
* humanised so the multiple endpoints of one resource read distinctly in the
|
|
303
|
+
* sidebar instead of colliding on the same title.
|
|
304
|
+
*/
|
|
305
|
+
function buildDefaultSummary(op) {
|
|
306
|
+
const words = (input) => input.split(/[^a-zA-Z0-9]+|(?<=[a-z0-9])(?=[A-Z])/u).filter((segment) => segment.length > 0);
|
|
307
|
+
const sentence = (input) => words(input).map((word) => apiReferenceDisplayWord(word, false)).join(' ');
|
|
308
|
+
const base = op.baseOperationIdentifier;
|
|
309
|
+
const verbPhrase = VERB_LABEL[base] ?? sentence(base);
|
|
310
|
+
const resourcePhrase = sentence(op.resourceIdentifier);
|
|
311
|
+
const actionWords = words(verbPhrase).map((word) => word.toLowerCase());
|
|
312
|
+
const resourceWords = words(resourcePhrase).map((word) => word.toLowerCase());
|
|
313
|
+
const actionAlreadyNamesResource = resourceWords.length > 0
|
|
314
|
+
&& actionWords.length >= resourceWords.length
|
|
315
|
+
&& resourceWords.every((word, index) => actionWords.at(index - resourceWords.length) === word);
|
|
316
|
+
let title = actionAlreadyNamesResource
|
|
317
|
+
? `${verbPhrase.charAt(0).toUpperCase()}${verbPhrase.slice(1)}`
|
|
318
|
+
: `${verbPhrase.charAt(0).toUpperCase()}${verbPhrase.slice(1)} ${resourcePhrase}`.trim();
|
|
319
|
+
const qualifierTail = op.operationKey.startsWith(base) ? op.operationKey.slice(base.length) : '';
|
|
320
|
+
for (const qualifier of qualifierTail.split('_').filter(Boolean)) {
|
|
321
|
+
if (qualifier.startsWith('Via')) {
|
|
322
|
+
title += ` via ${sentence(qualifier.slice(3))}`;
|
|
323
|
+
}
|
|
324
|
+
else if (qualifier === 'BULK') {
|
|
325
|
+
title += ' (bulk)';
|
|
326
|
+
}
|
|
327
|
+
else if (qualifier.toLowerCase() === 'summary' || qualifier.toLowerCase() === 'context') {
|
|
328
|
+
title += ` (${qualifier.toLowerCase()})`;
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
title += ` ${sentence(qualifier)}`;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return title;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Extracts `{paramName}` placeholders from an OpenAPI-style template
|
|
338
|
+
* path. Used to derive `parameters[]: in: path` entries automatically
|
|
339
|
+
* — the projector does not need to redeclare them.
|
|
340
|
+
*
|
|
341
|
+
* Returns an empty array for paths with no placeholders.
|
|
342
|
+
*/
|
|
343
|
+
function extractPathParameters(path) {
|
|
344
|
+
const matches = path.matchAll(/\{([^}]+)\}/gu);
|
|
345
|
+
return Array.from(matches, (match) => match[1]);
|
|
346
|
+
}
|
|
347
|
+
function isPlainObject(value) {
|
|
348
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
349
|
+
}
|
|
350
|
+
function isQueryParameterVerb(httpVerb) {
|
|
351
|
+
return (httpVerb === 'get'
|
|
352
|
+
|| httpVerb === 'delete');
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Converts a top-level object JSON Schema fragment into OpenAPI query
|
|
356
|
+
* parameters for query-style verbs (GET / DELETE). Request examples are carried
|
|
357
|
+
* onto the individual OpenAPI Parameter Objects: these methods have no JSON
|
|
358
|
+
* body, so dropping an authored example here would make a valid source example
|
|
359
|
+
* invisible to both an OpenAPI viewer and the Wildo renderer.
|
|
360
|
+
*
|
|
361
|
+
* Why fail-loud on non-object schemas:
|
|
362
|
+
*
|
|
363
|
+
* The projection explicitly allows `requestBodySchema` to represent
|
|
364
|
+
* query input for GET / DELETE operations. If a projector bug emits a
|
|
365
|
+
* scalar / array / malformed schema here, silently reusing it as an
|
|
366
|
+
* OpenAPI `requestBody` would recreate the exact mis-documentation bug
|
|
367
|
+
* this helper exists to close. Throwing keeps the spec honest and pins
|
|
368
|
+
* the regression to the generator boundary.
|
|
369
|
+
*/
|
|
370
|
+
function buildQueryParametersFromRequestSchema(op) {
|
|
371
|
+
const schema = op.requestBodySchema;
|
|
372
|
+
if (schema === null) {
|
|
373
|
+
return [];
|
|
374
|
+
}
|
|
375
|
+
if (!isPlainObject(schema) || schema.type !== 'object') {
|
|
376
|
+
throw new Error(`OpenAPI generator: ${op.httpVerb.toUpperCase()} ${op.path} for `
|
|
377
|
+
+ `${op.resourceIdentifier}.${op.operationKey} carries a non-object request schema. `
|
|
378
|
+
+ `GET/DELETE request schemas must be object-shaped so the generator can emit query parameters.`);
|
|
379
|
+
}
|
|
380
|
+
const properties = schema.properties;
|
|
381
|
+
if (properties === undefined) {
|
|
382
|
+
return [];
|
|
383
|
+
}
|
|
384
|
+
if (!isPlainObject(properties)) {
|
|
385
|
+
throw new Error(`OpenAPI generator: ${op.httpVerb.toUpperCase()} ${op.path} for `
|
|
386
|
+
+ `${op.resourceIdentifier}.${op.operationKey} has an object request schema with malformed `
|
|
387
|
+
+ '`properties`. Expected a plain object so query parameters can be emitted deterministically.');
|
|
388
|
+
}
|
|
389
|
+
const requiredFields = Array.isArray(schema.required)
|
|
390
|
+
? new Set(schema.required.filter((value) => typeof value === 'string'))
|
|
391
|
+
: new Set();
|
|
392
|
+
// Collection-axis dedupe: on a paginated collection operation (`op.pagination`
|
|
393
|
+
// present) the framework-generated SEARCH request DTO carries NESTED
|
|
394
|
+
// `pagination` / `sorting` / `filters` object properties. The canonical wire
|
|
395
|
+
// shape is FLAT (what the frontend HTTP client sends, what the backend
|
|
396
|
+
// controller reads first — the nested forms are fallbacks): the `page` /
|
|
397
|
+
// `limit` / `sort` trio, a free-text `q`, and one parameter per filter field
|
|
398
|
+
// (scalars flat, date ranges as `deepObject`). Exploding the `filters` object
|
|
399
|
+
// verbatim would emit a nested object with no `style: deepObject` metadata, so
|
|
400
|
+
// SDK generators would serialise it wrong. Drop the nested axes here — the flat
|
|
401
|
+
// trio is emitted by `buildPaginationQueryParameters`, and the flat `q` +
|
|
402
|
+
// per-field filters by `buildCollectionFilterQueryParameters`, both on the same
|
|
403
|
+
// operation. The flat trio names are excluded too so a DTO can never collide with
|
|
404
|
+
// them. Ops WITHOUT the pagination contract keep every DTO property untouched.
|
|
405
|
+
const paginationAxisProperties = op.pagination !== undefined
|
|
406
|
+
? new Set(['pagination', 'sorting', 'filters', 'page', 'limit', 'sort'])
|
|
407
|
+
: new Set();
|
|
408
|
+
return Object.entries(properties)
|
|
409
|
+
.filter(([name]) => !paginationAxisProperties.has(name))
|
|
410
|
+
.map(([name, propertySchema]) => {
|
|
411
|
+
const parameter = {
|
|
412
|
+
name,
|
|
413
|
+
in: 'query',
|
|
414
|
+
required: requiredFields.has(name),
|
|
415
|
+
schema: isPlainObject(propertySchema) ? propertySchema : {},
|
|
416
|
+
};
|
|
417
|
+
if (isPlainObject(propertySchema) && typeof propertySchema.description === 'string') {
|
|
418
|
+
parameter['description'] = propertySchema.description;
|
|
419
|
+
}
|
|
420
|
+
const examples = buildParameterExamples(op, name);
|
|
421
|
+
if (examples !== undefined) {
|
|
422
|
+
parameter['examples'] = examples;
|
|
423
|
+
}
|
|
424
|
+
return parameter;
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Projects source-authored request values onto one OpenAPI parameter. Path and
|
|
429
|
+
* query parameters share the same Parameter Object example contract; retaining
|
|
430
|
+
* the source title lets a consumer correlate a concrete id/filter with the
|
|
431
|
+
* business case it was authored for. Response-only examples never participate.
|
|
432
|
+
*/
|
|
433
|
+
function buildParameterExamples(op, parameterName) {
|
|
434
|
+
const examples = {};
|
|
435
|
+
for (const example of op.examples ?? []) {
|
|
436
|
+
if (!isPlainObject(example.request) || !Object.prototype.hasOwnProperty.call(example.request, parameterName)) {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
examples[example.title] = { value: example.request[parameterName] };
|
|
440
|
+
}
|
|
441
|
+
return Object.keys(examples).length > 0 ? examples : undefined;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Adds matching source-authored values to a generated Parameter Object. This
|
|
445
|
+
* is shared by path parameters, direct DTO-property query parameters and the
|
|
446
|
+
* framework-generated flat collection axes (`q`, filters, pagination).
|
|
447
|
+
*/
|
|
448
|
+
function attachParameterExamples(op, parameter) {
|
|
449
|
+
const name = parameter['name'];
|
|
450
|
+
if (typeof name !== 'string') {
|
|
451
|
+
return parameter;
|
|
452
|
+
}
|
|
453
|
+
const examples = buildParameterExamples(op, name);
|
|
454
|
+
return examples === undefined ? parameter : { ...parameter, examples };
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Flattens a paginated collection operation's nested `filters` request-DTO object
|
|
458
|
+
* into the FLAT, wire-truthful query parameters the framework actually accepts —
|
|
459
|
+
* the filtering counterpart of the `page` / `limit` / `sort` pagination-trio
|
|
460
|
+
* reshape (`buildPaginationQueryParameters`).
|
|
461
|
+
*
|
|
462
|
+
* The framework's SEARCH request DTO (`createResourceSearchQueryRequestSchema`)
|
|
463
|
+
* always carries a top-level `filters` OBJECT: the free-text `searchRequest`, the
|
|
464
|
+
* `createdAt` / `updatedAt` date-range filters, plus one property per authored
|
|
465
|
+
* filter field. That nested object is NOT the wire shape — the frontend HTTP
|
|
466
|
+
* client sends each filter FLAT (`?status=…`) and free-text as `?q=…`, and the
|
|
467
|
+
* backend controller (`extractCollectionFilters` + `normalizeCollectionQueryRequestData`)
|
|
468
|
+
* reads the flat forms first. This helper re-surfaces the FLAT-serialisable
|
|
469
|
+
* `filters` properties as their own query parameters:
|
|
470
|
+
*
|
|
471
|
+
* - `searchRequest` → the canonical flat `q` string (what the frontend sends and
|
|
472
|
+
* the backend reads before `searchQuery` before the nested `filters.searchRequest`).
|
|
473
|
+
* - any SCALAR property (string / number / boolean / enum) → a plain flat query
|
|
474
|
+
* parameter (`?status=active`), carrying the property's schema (and description).
|
|
475
|
+
* - an OBJECT-valued property (a date range `{ startDate?, endDate? }`) →
|
|
476
|
+
* `style: deepObject, explode: true`, so SDK generators serialise it as
|
|
477
|
+
* `field[startDate]=…&field[endDate]=…` (bracket notation). The backend runs
|
|
478
|
+
* Express 5's default `simple` query parser (which leaves bracketed keys flat),
|
|
479
|
+
* so the controller reconstructs the nested object from those keys —
|
|
480
|
+
* `reconstructBracketedRangeFilters` in the collection-query controller,
|
|
481
|
+
* declaration-gated to actual range fields. Emitting the `deepObject` metadata
|
|
482
|
+
* is the second half of the fix the pagination reshape called out: a nested
|
|
483
|
+
* object in a GET query string needs `style: deepObject`, which the naive DTO
|
|
484
|
+
* explosion never emitted. (A global `extended` query parser was deliberately
|
|
485
|
+
* NOT used — it changes `req.query` shape for every controller and breaks the
|
|
486
|
+
* flat-scalar `req.query.X as string` assumption across auth / SCIM / chart.)
|
|
487
|
+
* - a filter field whose name is ALSO a path parameter of this endpoint → SKIPPED
|
|
488
|
+
* (URL-supplied; see the per-operation note below).
|
|
489
|
+
*
|
|
490
|
+
* Gated on `op.pagination !== undefined` (collection ops only — the same signal
|
|
491
|
+
* the collection-axis dedupe uses), a query verb, and a `filters` object property,
|
|
492
|
+
* so a non-collection GET carrying an unrelated `filters` property keeps its naive
|
|
493
|
+
* explosion untouched. All filter parameters are OPTIONAL. Returns `[]` when the
|
|
494
|
+
* op is not a paginated collection op or declares no `filters` object.
|
|
495
|
+
*/
|
|
496
|
+
function buildCollectionFilterQueryParameters(op) {
|
|
497
|
+
if (op.pagination === undefined || !isQueryParameterVerb(op.httpVerb)) {
|
|
498
|
+
return [];
|
|
499
|
+
}
|
|
500
|
+
const schema = op.requestBodySchema;
|
|
501
|
+
if (!isPlainObject(schema) || !isPlainObject(schema.properties)) {
|
|
502
|
+
return [];
|
|
503
|
+
}
|
|
504
|
+
const filtersSchema = schema.properties['filters'];
|
|
505
|
+
if (!isPlainObject(filtersSchema) || !isPlainObject(filtersSchema.properties)) {
|
|
506
|
+
return [];
|
|
507
|
+
}
|
|
508
|
+
const filterProperties = filtersSchema.properties;
|
|
509
|
+
// A filter field that is ALSO a path parameter of THIS endpoint (a parent-scope
|
|
510
|
+
// FK reached via a nested path, e.g. `todoId` on `/…/todos/{todoId}/tasks`) is
|
|
511
|
+
// already supplied by the URL. Emitting it AGAIN as a query parameter is
|
|
512
|
+
// redundant, produces two same-named parameters SDK generators mishandle, and —
|
|
513
|
+
// because the backend merges `{ ...req.params, ...req.query }` — would let the
|
|
514
|
+
// query value silently override the path scope (a tenant-scope footgun). Skip
|
|
515
|
+
// it: the path parameter is authoritative. The skip is PER-OPERATION (keyed on
|
|
516
|
+
// this path's params), so the same filter field is still emitted on a sibling
|
|
517
|
+
// path that does NOT bind the FK, where filtering by it is meaningful.
|
|
518
|
+
const pathParameterNames = new Set(extractPathParameters(op.path));
|
|
519
|
+
const parameters = [];
|
|
520
|
+
// Free-text search → the canonical flat `q` (the frontend sends `?q=`, and the
|
|
521
|
+
// backend reads `q` first). The `filters.searchRequest` DTO property is the
|
|
522
|
+
// nested fallback name, so it is surfaced under the wire name, not verbatim.
|
|
523
|
+
if ('searchRequest' in filterProperties) {
|
|
524
|
+
parameters.push(attachParameterExamples(op, {
|
|
525
|
+
name: 'q',
|
|
526
|
+
in: 'query',
|
|
527
|
+
required: false,
|
|
528
|
+
schema: { type: 'string' },
|
|
529
|
+
description: 'Free-text search query, matched against the operation\'s configured searchable '
|
|
530
|
+
+ 'fields. Sent flat as `?q=…` (the wire form the backend reads first).',
|
|
531
|
+
}));
|
|
532
|
+
}
|
|
533
|
+
for (const [name, propertySchema] of Object.entries(filterProperties)) {
|
|
534
|
+
if (name === 'searchRequest' || pathParameterNames.has(name)) {
|
|
535
|
+
continue; // `searchRequest` surfaced as `q` above; path-param FKs are URL-supplied
|
|
536
|
+
}
|
|
537
|
+
const isObjectValued = isPlainObject(propertySchema)
|
|
538
|
+
&& (propertySchema.type === 'object' || 'properties' in propertySchema);
|
|
539
|
+
const parameter = { name, in: 'query', required: false };
|
|
540
|
+
// Object-valued filters (date ranges) are irreducibly nested; emit deepObject
|
|
541
|
+
// so SDKs serialise `name[<field>]=…`. The backend's collection-query controller
|
|
542
|
+
// reconstructs the nested object from those bracketed keys (the global query
|
|
543
|
+
// parser stays `simple`); see `reconstructBracketedRangeFilters`.
|
|
544
|
+
if (isObjectValued) {
|
|
545
|
+
parameter['style'] = 'deepObject';
|
|
546
|
+
parameter['explode'] = true;
|
|
547
|
+
}
|
|
548
|
+
parameter['schema'] = isPlainObject(propertySchema) ? propertySchema : {};
|
|
549
|
+
if (isPlainObject(propertySchema) && typeof propertySchema.description === 'string') {
|
|
550
|
+
parameter['description'] = propertySchema.description;
|
|
551
|
+
}
|
|
552
|
+
else if (isObjectValued) {
|
|
553
|
+
// No authored description on the range object — synthesise a truthful hint
|
|
554
|
+
// that names the deepObject bracket form for each sub-field it carries.
|
|
555
|
+
const subKeys = isPlainObject(propertySchema) && isPlainObject(propertySchema.properties)
|
|
556
|
+
? Object.keys(propertySchema.properties)
|
|
557
|
+
: [];
|
|
558
|
+
const bracketHint = subKeys.length > 0
|
|
559
|
+
? subKeys.map((subKey) => `\`${name}[${subKey}]=…\``).join(' & ')
|
|
560
|
+
: `\`${name}[<field>]=…\``;
|
|
561
|
+
parameter['description'] =
|
|
562
|
+
`Object-valued filter — serialise each sub-field with deepObject bracket notation (${bracketHint}).`;
|
|
563
|
+
}
|
|
564
|
+
parameters.push(attachParameterExamples(op, parameter));
|
|
565
|
+
}
|
|
566
|
+
return parameters;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Framework-universal pagination defaults, mirrored from the backend
|
|
570
|
+
* repository adapters (`_doList` in both the MongoDB and PostgreSQL resource
|
|
571
|
+
* repositories, `@wildo-ai/saas-backend-lib`): an omitted `page` reads as
|
|
572
|
+
* page 1, an omitted `limit` reads as 20 items per page. Duplicated as
|
|
573
|
+
* literals for the same boundary reason as the enums above (the generator
|
|
574
|
+
* must not import `@wildo-ai/saas-backend-lib`); keep in sync with the
|
|
575
|
+
* repository defaults.
|
|
576
|
+
*/
|
|
577
|
+
const PAGINATION_DEFAULT_PAGE = 1;
|
|
578
|
+
const PAGINATION_DEFAULT_LIMIT = 20;
|
|
579
|
+
/**
|
|
580
|
+
* Emits the flat `page` / `limit` / `sort` query parameters for a paginated
|
|
581
|
+
* collection operation (`op.pagination` present — LIST / SEARCH-like; see the
|
|
582
|
+
* projection schema JSDoc). These parameters are FRAMEWORK knowledge: the
|
|
583
|
+
* backend accepts them on every HTTP LIST/SEARCH dispatch but no request DTO
|
|
584
|
+
* declares them, so without this emission the spec documents paginated
|
|
585
|
+
* endpoints with no way to page them (and the `info.description` conventions
|
|
586
|
+
* block would promise parameters the operations never declare).
|
|
587
|
+
*
|
|
588
|
+
* Shapes follow the runtime contract exactly:
|
|
589
|
+
*
|
|
590
|
+
* - `page` — 1-based positive integer, server default 1.
|
|
591
|
+
* - `limit` — positive integer, server default 20, capped at the
|
|
592
|
+
* per-operation `maxLimit` the projector resolved.
|
|
593
|
+
* - `sort` — comma-separated `field:asc` / `field:desc` pairs, highest
|
|
594
|
+
* priority first. With a non-empty `sortableFields` allow-list the backend
|
|
595
|
+
* silently ignores unrecognised fields and defaults to the first allowed
|
|
596
|
+
* field descending; with no allow-list any field applies and the default
|
|
597
|
+
* is `createdAt:desc`. The description states whichever branch is true
|
|
598
|
+
* for this operation, so the docs never promise sorting the backend
|
|
599
|
+
* would drop.
|
|
600
|
+
*/
|
|
601
|
+
function buildPaginationQueryParameters(op) {
|
|
602
|
+
const pagination = op.pagination;
|
|
603
|
+
if (pagination === undefined) {
|
|
604
|
+
return [];
|
|
605
|
+
}
|
|
606
|
+
const sortableFields = pagination.sortableFields;
|
|
607
|
+
const sortDescription = sortableFields.length > 0
|
|
608
|
+
? 'Sort order: comma-separated `field:asc` / `field:desc` pairs, highest priority first '
|
|
609
|
+
+ `(e.g. \`${sortableFields[0]}:desc\`). Sortable fields: `
|
|
610
|
+
+ `${sortableFields.map((field) => `\`${field}\``).join(', ')}. `
|
|
611
|
+
+ `Unrecognised fields are ignored. Defaults to \`${sortableFields[0]}:desc\`.`
|
|
612
|
+
: 'Sort order: comma-separated `field:asc` / `field:desc` pairs, highest priority first '
|
|
613
|
+
+ '(e.g. `createdAt:desc`). Defaults to `createdAt:desc` (newest first).';
|
|
614
|
+
return [
|
|
615
|
+
{
|
|
616
|
+
name: 'page',
|
|
617
|
+
in: 'query',
|
|
618
|
+
required: false,
|
|
619
|
+
schema: { type: 'integer', minimum: 1, default: PAGINATION_DEFAULT_PAGE },
|
|
620
|
+
description: '1-based page number of the collection slice.',
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
name: 'limit',
|
|
624
|
+
in: 'query',
|
|
625
|
+
required: false,
|
|
626
|
+
schema: { type: 'integer', minimum: 1, maximum: pagination.maxLimit, default: PAGINATION_DEFAULT_LIMIT },
|
|
627
|
+
description: `Maximum number of items per page (up to ${pagination.maxLimit}).`,
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
name: 'sort',
|
|
631
|
+
in: 'query',
|
|
632
|
+
required: false,
|
|
633
|
+
schema: { type: 'string' },
|
|
634
|
+
description: sortDescription,
|
|
635
|
+
},
|
|
636
|
+
].map((parameter) => attachParameterExamples(op, parameter));
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* HTTP header carrying a complete API key. Read from the lightweight public
|
|
640
|
+
* runtime entrypoint so the generator shares the backend's canonical header
|
|
641
|
+
* vocabulary without importing the saas-models root barrel.
|
|
642
|
+
*/
|
|
643
|
+
const API_KEY_AUTHORIZATION_HEADER_NAME = WildoHeaderKeys.AUTHORIZATION;
|
|
644
|
+
/** HTTP header carrying an anonymous-session token. */
|
|
645
|
+
const ANONYMOUS_SESSION_HEADER_NAME = WildoHeaderKeys.ANONYMOUS_SESSION_TOKEN;
|
|
646
|
+
/**
|
|
647
|
+
* Framework-universal OpenAPI `components.securitySchemes`. Every Wildo API
|
|
648
|
+
* authenticates the same ways, so the generator emits these UNCONDITIONALLY —
|
|
649
|
+
* framework knowledge, not app config. Per-operation `security` references them
|
|
650
|
+
* by name (see `buildOperationSecurity`).
|
|
651
|
+
*
|
|
652
|
+
* - `bearerAuth` — JWT access token in `Authorization: Bearer <jwt>`.
|
|
653
|
+
* - `apiKeyAuth` — org-/app-scoped API key (`sk_org_…` / `sk_app_…`) in `Authorization` without a bearer scheme.
|
|
654
|
+
* - `anonymousSessionAuth` — anonymous-session token in `x-anonymous-session-token`
|
|
655
|
+
* (the pre-authentication surface; `APP_ANONYMOUS` operations).
|
|
656
|
+
*/
|
|
657
|
+
const SECURITY_SCHEMES = {
|
|
658
|
+
bearerAuth: {
|
|
659
|
+
type: 'http',
|
|
660
|
+
scheme: 'bearer',
|
|
661
|
+
bearerFormat: 'JWT',
|
|
662
|
+
description: 'JWT access token issued by the authentication service. Send as `Authorization: Bearer <token>`.',
|
|
663
|
+
},
|
|
664
|
+
apiKeyAuth: {
|
|
665
|
+
type: 'apiKey',
|
|
666
|
+
in: 'header',
|
|
667
|
+
name: API_KEY_AUTHORIZATION_HEADER_NAME,
|
|
668
|
+
description: 'Organization- or application-scoped API key (`sk_org_…` / `sk_app_…`), sent as the complete value of the `Authorization` header without a `Bearer` scheme.',
|
|
669
|
+
},
|
|
670
|
+
anonymousSessionAuth: {
|
|
671
|
+
type: 'apiKey',
|
|
672
|
+
in: 'header',
|
|
673
|
+
name: ANONYMOUS_SESSION_HEADER_NAME,
|
|
674
|
+
description: 'Anonymous-session token for pre-authentication (anonymous user) access, sent in the `x-anonymous-session-token` header.',
|
|
675
|
+
},
|
|
676
|
+
};
|
|
677
|
+
/**
|
|
678
|
+
* Per-operation OpenAPI `security`, derived ENTIRELY from the source-resolved
|
|
679
|
+
* access document (NOT `primaryScope`, which is the scope/audience axis).
|
|
680
|
+
*
|
|
681
|
+
* - `roles` contains `APP_PUBLIC` → `[]` — explicitly PUBLIC (OpenAPI reads an
|
|
682
|
+
* empty `security` array as "no authentication required"). Public access
|
|
683
|
+
* wins over any other listed role, so the docs/"Try it" don't demand a token
|
|
684
|
+
* for sign-up, public reads, etc.
|
|
685
|
+
* - otherwise the requirements are OR-ed (any one satisfies the request):
|
|
686
|
+
* • `anonymousSessionAuth` when `roles` contains `APP_ANONYMOUS`.
|
|
687
|
+
* • `bearerAuth` (+ `apiKeyAuth`, unless `isApiKeyAccessDisabled`) when the
|
|
688
|
+
* op has ANY non-`APP_ANONYMOUS` role, OR no role at all (an open endpoint
|
|
689
|
+
* for any authenticated user — "app users can list users").
|
|
690
|
+
*
|
|
691
|
+
* So `[APP_ANONYMOUS]` → anonymous-session only; `[APP_ANONYMOUS, APP_USER]` →
|
|
692
|
+
* anonymous-session OR bearer/api-key; `[APP_USER]` / `[]` / `[ORG_OWNER]` →
|
|
693
|
+
* bearer/api-key. `isApiKeyAccessDisabled` removes only the API-key alternative;
|
|
694
|
+
* the HTTP operation remains documented.
|
|
695
|
+
*/
|
|
696
|
+
function buildOperationSecurity(op) {
|
|
697
|
+
if (op.access.authenticationMode === OperationProjectionAuthenticationMode.PUBLIC)
|
|
698
|
+
return [];
|
|
699
|
+
const requirements = [];
|
|
700
|
+
if (op.access.authenticationMode === OperationProjectionAuthenticationMode.ANONYMOUS_SESSION
|
|
701
|
+
|| op.access.authenticationMode === OperationProjectionAuthenticationMode.ANONYMOUS_OR_AUTHENTICATED) {
|
|
702
|
+
requirements.push({ anonymousSessionAuth: [] });
|
|
703
|
+
}
|
|
704
|
+
if (op.access.authenticationMode === OperationProjectionAuthenticationMode.AUTHENTICATED
|
|
705
|
+
|| op.access.authenticationMode === OperationProjectionAuthenticationMode.ANONYMOUS_OR_AUTHENTICATED) {
|
|
706
|
+
requirements.push({ bearerAuth: [] });
|
|
707
|
+
if (!op.isApiKeyAccessDisabled) {
|
|
708
|
+
requirements.push({ apiKeyAuth: [] });
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return requirements;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Builds the OpenAPI 3.1 `paths.<path>.<method>` entry for a single
|
|
715
|
+
* operation. Returns a plain JS object suitable for direct YAML
|
|
716
|
+
* serialisation — no YAML primitives leak past the YAML emitter call.
|
|
717
|
+
*/
|
|
718
|
+
function buildOperationObject(op, operationId, errorResponseSchema) {
|
|
719
|
+
const operationObject = {
|
|
720
|
+
operationId,
|
|
721
|
+
summary: op.summary ?? buildDefaultSummary(op),
|
|
722
|
+
tags: [op.resourceIdentifier],
|
|
723
|
+
// Strict framework metadata for consumers that need to join a rendered
|
|
724
|
+
// operation back to its resolved source identity. Standard OpenAPI fields
|
|
725
|
+
// stay standard; this carries only Wildo-specific semantics.
|
|
726
|
+
'x-wildo': {
|
|
727
|
+
...op.identity,
|
|
728
|
+
section: op.consumerApiSection,
|
|
729
|
+
roles: [...op.roles],
|
|
730
|
+
access: op.access,
|
|
731
|
+
},
|
|
732
|
+
};
|
|
733
|
+
if (op.description !== undefined) {
|
|
734
|
+
operationObject['description'] = op.description;
|
|
735
|
+
}
|
|
736
|
+
// Auth (Tier-1 framework knowledge). The source projector resolves access
|
|
737
|
+
// semantics once; this generator only transports that fact into standard
|
|
738
|
+
// OpenAPI `security` and the companion `x-wildo` extension.
|
|
739
|
+
const security = buildOperationSecurity(op);
|
|
740
|
+
operationObject['security'] = security;
|
|
741
|
+
if (op.access.authenticationMode !== OperationProjectionAuthenticationMode.PUBLIC && op.access.roleRequirements.length > 0) {
|
|
742
|
+
operationObject['x-required-roles'] = op.access.roleRequirements.map((requirement) => requirement.role);
|
|
743
|
+
}
|
|
744
|
+
const parameters = [];
|
|
745
|
+
const pathParams = extractPathParameters(op.path);
|
|
746
|
+
if (pathParams.length > 0) {
|
|
747
|
+
parameters.push(...pathParams.map((paramName) => attachParameterExamples(op, {
|
|
748
|
+
name: paramName,
|
|
749
|
+
in: 'path',
|
|
750
|
+
required: true,
|
|
751
|
+
schema: { type: 'string' },
|
|
752
|
+
})));
|
|
753
|
+
}
|
|
754
|
+
if (op.stepUpAuthentication !== undefined) {
|
|
755
|
+
parameters.push({
|
|
756
|
+
name: op.stepUpAuthentication.headerName,
|
|
757
|
+
in: 'header',
|
|
758
|
+
required: true,
|
|
759
|
+
schema: { type: 'string' },
|
|
760
|
+
description: `Single-use re-authentication token obtained from POST ${op.stepUpAuthentication.tokenEndpointPath}. `
|
|
761
|
+
+ 'Required for this sensitive operation and consumed when the request is authorized.',
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
if (op.acceptsIfMatch) {
|
|
765
|
+
parameters.push({
|
|
766
|
+
name: WildoHeaderKeys.IF_MATCH,
|
|
767
|
+
in: 'header',
|
|
768
|
+
required: false,
|
|
769
|
+
schema: {
|
|
770
|
+
type: 'string',
|
|
771
|
+
pattern: '^(?:[0-9]+|"[0-9]+"|W/"[0-9]+")$',
|
|
772
|
+
},
|
|
773
|
+
examples: {
|
|
774
|
+
'Current resource version': { value: '7' },
|
|
775
|
+
},
|
|
776
|
+
description: 'Optional optimistic-locking version from `_version` in the latest resource response. '
|
|
777
|
+
+ 'Send it as a bare integer (`7`), quoted entity tag (`"7"`) or weak entity tag (`W/"7"`). '
|
|
778
|
+
+ 'When supplied, the mutation is applied only if that version is still current.',
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
if (isQueryParameterVerb(op.httpVerb)) {
|
|
782
|
+
parameters.push(...buildQueryParametersFromRequestSchema(op));
|
|
783
|
+
// Paginated collection ops carry a nested `filters` bag in their request DTO;
|
|
784
|
+
// it was dropped from the naive explosion above and is re-surfaced here as the
|
|
785
|
+
// flat `q` + one parameter per filter field (scalars flat, date ranges as
|
|
786
|
+
// deepObject — the backend controller reconstructs the nested range object).
|
|
787
|
+
parameters.push(...buildCollectionFilterQueryParameters(op));
|
|
788
|
+
}
|
|
789
|
+
else if (op.requestBodySchema !== null) {
|
|
790
|
+
const requestJsonMedia = { schema: op.requestBodySchema };
|
|
791
|
+
const requestExamples = buildRequestExamples(op);
|
|
792
|
+
if (requestExamples !== undefined) {
|
|
793
|
+
requestJsonMedia['examples'] = requestExamples;
|
|
794
|
+
}
|
|
795
|
+
operationObject['requestBody'] = {
|
|
796
|
+
required: true,
|
|
797
|
+
content: {
|
|
798
|
+
'application/json': requestJsonMedia,
|
|
799
|
+
},
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
// Paginated collection ops (LIST / SEARCH-like) accept the framework's flat
|
|
803
|
+
// `page` / `limit` / `sort` query parameters regardless of what their request
|
|
804
|
+
// DTO declares. Appended LAST so operation-specific parameters (`q`, filter
|
|
805
|
+
// fields) read first and the boilerplate trio sits together at the end.
|
|
806
|
+
parameters.push(...buildPaginationQueryParameters(op));
|
|
807
|
+
if (parameters.length > 0) {
|
|
808
|
+
operationObject['parameters'] = parameters;
|
|
809
|
+
}
|
|
810
|
+
// Inject engine metadata (`_version` / `_field_meta`) that the response
|
|
811
|
+
// serializer reattaches onto every materialized resource document but which no
|
|
812
|
+
// resource schema declares. Computed once and shared by the `responses` object
|
|
813
|
+
// and the async callback payload (both describe the same operation result) so
|
|
814
|
+
// structurally identical schemas still content-dedup into one component when
|
|
815
|
+
// hoisted.
|
|
816
|
+
const augmentedResponseBodySchema = augmentResponseSchemaWithEngineMetadata(op.responseBodySchema);
|
|
817
|
+
operationObject['responses'] = buildResponsesObject(op, augmentedResponseBodySchema, errorResponseSchema);
|
|
818
|
+
if (op.variantType === OperationProjectionVariantType.API_CALL_WITH_CALLBACK) {
|
|
819
|
+
// The Zod superRefine on OperationProjectionSchema guarantees
|
|
820
|
+
// callbackPath is defined here — but the runtime check below is
|
|
821
|
+
// defense-in-depth in case a future caller bypasses Zod parsing
|
|
822
|
+
// and feeds the helper a raw projection (which the public API
|
|
823
|
+
// forbids but the helper itself remains pure-function).
|
|
824
|
+
if (op.callbackPath !== undefined) {
|
|
825
|
+
operationObject['callbacks'] = {
|
|
826
|
+
completion: {
|
|
827
|
+
[`{$request.body#/callbackUrl}${op.callbackPath}`]: {
|
|
828
|
+
post: {
|
|
829
|
+
requestBody: {
|
|
830
|
+
required: true,
|
|
831
|
+
content: {
|
|
832
|
+
'application/json': {
|
|
833
|
+
schema: augmentedResponseBodySchema ?? {},
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
},
|
|
837
|
+
responses: {
|
|
838
|
+
'204': { description: 'Callback acknowledged' },
|
|
839
|
+
},
|
|
840
|
+
},
|
|
841
|
+
},
|
|
842
|
+
},
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (op.idempotent !== undefined) {
|
|
847
|
+
operationObject['x-idempotent'] = op.idempotent;
|
|
848
|
+
}
|
|
849
|
+
if (op.rateLimitNote !== undefined) {
|
|
850
|
+
const rateLimitLine = `**Rate limiting:** ${op.rateLimitNote}`;
|
|
851
|
+
operationObject['description'] = typeof operationObject['description'] === 'string'
|
|
852
|
+
? `${operationObject['description']}\n\n${rateLimitLine}`
|
|
853
|
+
: rateLimitLine;
|
|
854
|
+
}
|
|
855
|
+
return operationObject;
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Client-facing engine-metadata properties that the response serializer
|
|
859
|
+
* REATTACHES onto every materialized resource document AFTER the response DTO
|
|
860
|
+
* parse strips them (`operation-response-serializer.backend.utils` +
|
|
861
|
+
* `services-registry-handler.transformOutput`, both in `@wildo-ai/saas-backend-lib`).
|
|
862
|
+
* They are engine metadata, NOT declared fields of any resource schema — so
|
|
863
|
+
* `operation.responseDto` (and thus the projected `responseBodySchema`) omits
|
|
864
|
+
* them and the generated OpenAPI would under-document the real wire response.
|
|
865
|
+
* We emit them as OPTIONAL, non-required properties so the spec matches what
|
|
866
|
+
* clients actually receive; well-behaved clients already ignore unknown
|
|
867
|
+
* `_`-prefixed fields, this just makes the contract explicit.
|
|
868
|
+
*
|
|
869
|
+
* - `_version` — optimistic-locking counter (a non-negative integer; the
|
|
870
|
+
* stored `_version_db_doc` surfaced to clients, starts at 1 and increments on
|
|
871
|
+
* every write, but can read as `0`). The frontend echoes it back as the
|
|
872
|
+
* `If-Match` request header on inline updates to detect concurrent edits.
|
|
873
|
+
* - `_field_meta` — per-field CRDT causality map used for conflict-free merges
|
|
874
|
+
* of concurrent edits. Opaque to API clients; safe to ignore.
|
|
875
|
+
*
|
|
876
|
+
* Cloned per-injection (spread) so no two schemas share a mutable node.
|
|
877
|
+
*/
|
|
878
|
+
const ENGINE_METADATA_PROPERTY_SCHEMAS = {
|
|
879
|
+
_version: {
|
|
880
|
+
type: 'integer',
|
|
881
|
+
description: 'Engine metadata (not a resource field): optimistic-locking version counter. '
|
|
882
|
+
+ 'Echo it back as the `If-Match` request header on updates to guard against concurrent edits.',
|
|
883
|
+
},
|
|
884
|
+
_field_meta: {
|
|
885
|
+
type: 'object',
|
|
886
|
+
additionalProperties: true,
|
|
887
|
+
description: 'Engine metadata (not a resource field): per-field CRDT causality map used for '
|
|
888
|
+
+ 'conflict-free merges of concurrent edits. Opaque to clients — safe to ignore.',
|
|
889
|
+
},
|
|
890
|
+
};
|
|
891
|
+
/**
|
|
892
|
+
* The structural signature of a MATERIALIZED resource document in a response
|
|
893
|
+
* schema is the `_id` property. The serializer attaches `_version` /
|
|
894
|
+
* `_field_meta` to exactly those objects (repository documents carry the stored
|
|
895
|
+
* `_version_db_doc`, surfaced as `_version`); envelopes (`{ count }`, delete /
|
|
896
|
+
* purge acks), computed-state projections (feature entitlements, lifecycle
|
|
897
|
+
* state), and `context`-mode wrappers have no `_id` and carry no engine
|
|
898
|
+
* metadata. Gating on `_id` keeps the spec truthful in BOTH directions — no
|
|
899
|
+
* under-documentation of real documents, no over-documentation of envelopes.
|
|
900
|
+
* Mutates `objectSchema.properties` in place (the caller owns a fresh clone).
|
|
901
|
+
*/
|
|
902
|
+
function injectEngineMetadataIntoResourceDocumentSchema(objectSchema) {
|
|
903
|
+
const properties = objectSchema['properties'];
|
|
904
|
+
if (!isPlainObject(properties) || !('_id' in properties)) {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
for (const [name, schema] of Object.entries(ENGINE_METADATA_PROPERTY_SCHEMAS)) {
|
|
908
|
+
// Never clobber an authored field of the same name (defensive — no resource
|
|
909
|
+
// declares `_version` / `_field_meta`, but the guard keeps injection
|
|
910
|
+
// idempotent). NOT added to `required`: engine metadata is optional.
|
|
911
|
+
if (!(name in properties)) {
|
|
912
|
+
properties[name] = { ...schema };
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Walk the composite wrappers (`oneOf` / `anyOf` / `allOf`) a polymorphic /
|
|
918
|
+
* discriminated-union response produces (`z.toJSONSchema` emits `oneOf` for a
|
|
919
|
+
* `ZodDiscriminatedUnion`) down to the concrete object branches, injecting
|
|
920
|
+
* engine metadata into each branch that is a resource document. A non-composite
|
|
921
|
+
* object is injected directly.
|
|
922
|
+
*/
|
|
923
|
+
function injectEngineMetadataIntoEntitySchemas(schema) {
|
|
924
|
+
if (!isPlainObject(schema)) {
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
let isComposite = false;
|
|
928
|
+
for (const compositeKey of ['oneOf', 'anyOf', 'allOf']) {
|
|
929
|
+
const branches = schema[compositeKey];
|
|
930
|
+
if (Array.isArray(branches)) {
|
|
931
|
+
isComposite = true;
|
|
932
|
+
for (const branch of branches) {
|
|
933
|
+
injectEngineMetadataIntoEntitySchemas(branch);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (!isComposite) {
|
|
938
|
+
injectEngineMetadataIntoResourceDocumentSchema(schema);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Return a CLONE of an operation's response body schema with the engine-metadata
|
|
943
|
+
* properties injected at the SAME structural positions the response serializer
|
|
944
|
+
* attaches them (`operation-response-serializer.backend.utils`):
|
|
945
|
+
*
|
|
946
|
+
* 1. paginated `{ data: [ … ], pagination }` → each `data[]` item
|
|
947
|
+
* 2. flat array `[ … ]` → each item
|
|
948
|
+
* 3. single object → the object itself
|
|
949
|
+
*
|
|
950
|
+
* (each unwrapped through `oneOf`/`anyOf`/`allOf` for polymorphic resources).
|
|
951
|
+
* The original schema is NEVER mutated — the callback path and any structurally
|
|
952
|
+
* identical schema on another operation must stay pristine so content-dedup
|
|
953
|
+
* (`stableStringify`) still collapses them onto one component. Non-object
|
|
954
|
+
* schemas (`null` / scalar) pass through unchanged.
|
|
955
|
+
*/
|
|
956
|
+
function augmentResponseSchemaWithEngineMetadata(responseBodySchema) {
|
|
957
|
+
if (!isPlainObject(responseBodySchema)) {
|
|
958
|
+
return responseBodySchema;
|
|
959
|
+
}
|
|
960
|
+
const clone = structuredClone(responseBodySchema);
|
|
961
|
+
const properties = clone['properties'];
|
|
962
|
+
const dataSchema = isPlainObject(properties) ? properties['data'] : undefined;
|
|
963
|
+
if (isPlainObject(dataSchema) && (dataSchema['type'] === 'array' || 'items' in dataSchema)) {
|
|
964
|
+
// Paginated `{ data: [ … ] }` wrapper — mirror the serializer's `body.data[i]`
|
|
965
|
+
// attach position. (A `context`-mode wrapper's `data[]` item is itself a
|
|
966
|
+
// `{ data: [ … ] }` sub-collection with no `_id`, so it is correctly skipped.)
|
|
967
|
+
injectEngineMetadataIntoEntitySchemas(dataSchema['items']);
|
|
968
|
+
}
|
|
969
|
+
else if (clone['type'] === 'array' || 'items' in clone) {
|
|
970
|
+
injectEngineMetadataIntoEntitySchemas(clone['items']);
|
|
971
|
+
}
|
|
972
|
+
else {
|
|
973
|
+
injectEngineMetadataIntoEntitySchemas(clone);
|
|
974
|
+
}
|
|
975
|
+
return clone;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Builds the OpenAPI `responses` object for an operation: authored success
|
|
979
|
+
* statuses + error scenarios (Tier 3), plus the framework-wide authentication
|
|
980
|
+
* failure derived from the source-resolved access mode. It falls back to the
|
|
981
|
+
* default single `200`/`204` when no success status is authored. Response
|
|
982
|
+
* `examples` are attached to the status they declare via `forStatus`. Multiple
|
|
983
|
+
* error scenarios on the same code merge their `when` prose into one entry's
|
|
984
|
+
* description.
|
|
985
|
+
*
|
|
986
|
+
* `responseBodySchema` is the operation's response body schema ALREADY augmented
|
|
987
|
+
* with engine metadata (see `augmentResponseSchemaWithEngineMetadata`); the
|
|
988
|
+
* caller computes it once so the `responses` object and the async callback
|
|
989
|
+
* payload share the same node.
|
|
990
|
+
*/
|
|
991
|
+
function buildResponsesObject(op, responseBodySchema, errorResponseSchema) {
|
|
992
|
+
const responseExamplesByStatus = new Map();
|
|
993
|
+
for (const example of op.examples ?? []) {
|
|
994
|
+
if (example.response === undefined || example.forStatus === undefined) {
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
const bucket = responseExamplesByStatus.get(example.forStatus) ?? {};
|
|
998
|
+
bucket[example.title] = { value: example.response };
|
|
999
|
+
responseExamplesByStatus.set(example.forStatus, bucket);
|
|
1000
|
+
}
|
|
1001
|
+
const buildSuccessContent = (code) => {
|
|
1002
|
+
if (responseBodySchema === null || code === '204') {
|
|
1003
|
+
return undefined;
|
|
1004
|
+
}
|
|
1005
|
+
const jsonMedia = { schema: responseBodySchema };
|
|
1006
|
+
const examples = responseExamplesByStatus.get(code);
|
|
1007
|
+
if (examples !== undefined) {
|
|
1008
|
+
jsonMedia['examples'] = examples;
|
|
1009
|
+
}
|
|
1010
|
+
return { 'application/json': jsonMedia };
|
|
1011
|
+
};
|
|
1012
|
+
const responses = {};
|
|
1013
|
+
const successStatuses = op.responseStatuses ?? [];
|
|
1014
|
+
if (successStatuses.length > 0) {
|
|
1015
|
+
for (const status of successStatuses) {
|
|
1016
|
+
const entry = { description: status.meaning };
|
|
1017
|
+
const content = buildSuccessContent(status.code);
|
|
1018
|
+
if (content !== undefined) {
|
|
1019
|
+
entry['content'] = content;
|
|
1020
|
+
}
|
|
1021
|
+
responses[status.code] = entry;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
else if (responseBodySchema !== null) {
|
|
1025
|
+
const entry = { description: 'Success' };
|
|
1026
|
+
const content = buildSuccessContent('200');
|
|
1027
|
+
if (content !== undefined) {
|
|
1028
|
+
entry['content'] = content;
|
|
1029
|
+
}
|
|
1030
|
+
responses['200'] = entry;
|
|
1031
|
+
}
|
|
1032
|
+
else {
|
|
1033
|
+
responses['204'] = { description: 'No Content' };
|
|
1034
|
+
}
|
|
1035
|
+
for (const error of op.errorScenarios ?? []) {
|
|
1036
|
+
const description = error.errorCode !== undefined
|
|
1037
|
+
? `${error.when} (error code: \`${error.errorCode}\`)`
|
|
1038
|
+
: error.when;
|
|
1039
|
+
const errorJsonMedia = { schema: errorResponseSchema };
|
|
1040
|
+
const examples = responseExamplesByStatus.get(error.code);
|
|
1041
|
+
if (examples !== undefined) {
|
|
1042
|
+
errorJsonMedia['examples'] = examples;
|
|
1043
|
+
}
|
|
1044
|
+
const errorContent = { 'application/json': errorJsonMedia };
|
|
1045
|
+
const existing = responses[error.code];
|
|
1046
|
+
if (isPlainObject(existing) && typeof existing['description'] === 'string') {
|
|
1047
|
+
existing['description'] = `${existing['description']}\n\n${description}`;
|
|
1048
|
+
existing['content'] = errorContent;
|
|
1049
|
+
}
|
|
1050
|
+
else {
|
|
1051
|
+
responses[error.code] = {
|
|
1052
|
+
description,
|
|
1053
|
+
content: errorContent,
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
if (op.acceptsIfMatch) {
|
|
1058
|
+
const mergeFrameworkError = (code, description) => {
|
|
1059
|
+
const existing = responses[code];
|
|
1060
|
+
if (isPlainObject(existing) && typeof existing['description'] === 'string') {
|
|
1061
|
+
existing['description'] = `${existing['description']}\n\n${description}`;
|
|
1062
|
+
existing['content'] = { 'application/json': { schema: errorResponseSchema } };
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
responses[code] = {
|
|
1066
|
+
description,
|
|
1067
|
+
content: { 'application/json': { schema: errorResponseSchema } },
|
|
1068
|
+
};
|
|
1069
|
+
};
|
|
1070
|
+
mergeFrameworkError('400', 'The optional `If-Match` header is present but does not contain a non-negative integer resource version.');
|
|
1071
|
+
mergeFrameworkError('409', 'The `If-Match` version no longer matches the current resource version, so the mutation was not applied.');
|
|
1072
|
+
}
|
|
1073
|
+
// Every framework RATE_LIMIT error with a retry delay is serialized by
|
|
1074
|
+
// `ErrorHandlerBackendService` with an HTTP `Retry-After` header. A source-
|
|
1075
|
+
// authored 429 is therefore sufficient to project this protocol contract;
|
|
1076
|
+
// individual resource specifications must not duplicate framework header
|
|
1077
|
+
// vocabulary. The current runtime emits integer delay-seconds, not an HTTP
|
|
1078
|
+
// date, so the Header Object deliberately exposes that narrower shape.
|
|
1079
|
+
const rateLimitResponse = responses['429'];
|
|
1080
|
+
if (isPlainObject(rateLimitResponse)) {
|
|
1081
|
+
rateLimitResponse['headers'] = {
|
|
1082
|
+
'Retry-After': {
|
|
1083
|
+
description: 'Delay in seconds before the client should retry the request.',
|
|
1084
|
+
schema: { type: 'integer', minimum: 0 },
|
|
1085
|
+
},
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
// Authentication happens before the resource operation. The projection's
|
|
1089
|
+
// access mode is the sole truth for which credentials may satisfy it, so the
|
|
1090
|
+
// renderer and each resource specification do not need to re-state the same
|
|
1091
|
+
// 401 contract. A resource-owned 401 is more specific (for example a
|
|
1092
|
+
// step-up flow) and deliberately takes precedence.
|
|
1093
|
+
if (op.access.authenticationMode !== OperationProjectionAuthenticationMode.PUBLIC && responses['401'] === undefined) {
|
|
1094
|
+
const description = (() => {
|
|
1095
|
+
switch (op.access.authenticationMode) {
|
|
1096
|
+
case OperationProjectionAuthenticationMode.ANONYMOUS_SESSION:
|
|
1097
|
+
return 'The anonymous-session token is missing, malformed, expired, or invalid.';
|
|
1098
|
+
case OperationProjectionAuthenticationMode.ANONYMOUS_OR_AUTHENTICATED:
|
|
1099
|
+
return 'No valid anonymous-session token, bearer token, or API key was supplied.';
|
|
1100
|
+
case OperationProjectionAuthenticationMode.AUTHENTICATED:
|
|
1101
|
+
return 'No valid bearer token or API key was supplied.';
|
|
1102
|
+
default: {
|
|
1103
|
+
const exhaustiveAuthenticationMode = op.access.authenticationMode;
|
|
1104
|
+
throw new Error(`OpenAPI generator: unsupported authentication mode ${String(exhaustiveAuthenticationMode)}`);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
})();
|
|
1108
|
+
responses['401'] = {
|
|
1109
|
+
description,
|
|
1110
|
+
content: { 'application/json': { schema: errorResponseSchema } },
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
return responses;
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Builds the OpenAPI request-body `examples` map from authored examples that
|
|
1117
|
+
* carry a `request` payload. Returns undefined when there are none.
|
|
1118
|
+
*/
|
|
1119
|
+
function buildRequestExamples(op) {
|
|
1120
|
+
const examples = {};
|
|
1121
|
+
const pathParameterNames = new Set(extractPathParameters(op.path));
|
|
1122
|
+
for (const example of op.examples ?? []) {
|
|
1123
|
+
if (example.request === undefined) {
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
// Source examples use one object so the same authored scenario can feed path
|
|
1127
|
+
// parameters and the JSON body. OpenAPI represents those as separate locations:
|
|
1128
|
+
// keep path values on Parameter Objects (via `buildParameterExamples`) and remove
|
|
1129
|
+
// them from the body example. Otherwise a primary-scope id such as
|
|
1130
|
+
// `organizationId` appears twice even when the request DTO correctly owns only
|
|
1131
|
+
// `justification` and `requestedDurationMinutes`.
|
|
1132
|
+
const value = isPlainObject(example.request)
|
|
1133
|
+
? Object.fromEntries(Object.entries(example.request).filter(([name]) => !pathParameterNames.has(name)))
|
|
1134
|
+
: example.request;
|
|
1135
|
+
examples[example.title] = { value };
|
|
1136
|
+
}
|
|
1137
|
+
return Object.keys(examples).length > 0 ? examples : undefined;
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Is this JSON-Schema fragment worth hoisting into `components/schemas`? Only
|
|
1141
|
+
* structured bodies (objects / arrays / composed schemas) earn a named
|
|
1142
|
+
* component + `$ref`; bare scalars (`{ type: 'string' }`) and empty `{}` stay
|
|
1143
|
+
* inline (a `$ref` to a one-line scalar would be pure overhead). Already-`$ref`
|
|
1144
|
+
* fragments are left alone.
|
|
1145
|
+
*/
|
|
1146
|
+
function isHoistableSchema(value) {
|
|
1147
|
+
if (!isPlainObject(value) || '$ref' in value) {
|
|
1148
|
+
return false;
|
|
1149
|
+
}
|
|
1150
|
+
return (value['type'] === 'object'
|
|
1151
|
+
|| value['type'] === 'array'
|
|
1152
|
+
|| 'properties' in value
|
|
1153
|
+
|| 'items' in value
|
|
1154
|
+
|| 'allOf' in value
|
|
1155
|
+
|| 'anyOf' in value
|
|
1156
|
+
|| 'oneOf' in value);
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Deterministic, key-sorted stringification used to detect structurally
|
|
1160
|
+
* IDENTICAL schemas regardless of key order, so the same shape authored on
|
|
1161
|
+
* different operations (or the same operation reached at multiple paths — the
|
|
1162
|
+
* `Via<…>` variants) collapses onto ONE component.
|
|
1163
|
+
*/
|
|
1164
|
+
function stableStringify(value) {
|
|
1165
|
+
if (Array.isArray(value)) {
|
|
1166
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
1167
|
+
}
|
|
1168
|
+
if (isPlainObject(value)) {
|
|
1169
|
+
return `{${Object.keys(value)
|
|
1170
|
+
.sort()
|
|
1171
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
|
1172
|
+
.join(',')}}`;
|
|
1173
|
+
}
|
|
1174
|
+
return JSON.stringify(value) ?? 'null';
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Component name for an operation's request/response body. Mirrors the
|
|
1178
|
+
* verb-first operationId namer (shares `pascal`): `<Resource><BaseVerb><Quals><Role>`,
|
|
1179
|
+
* e.g. `TodosListResponse`, `TodosCreateRequest`, `TodosListSummaryResponse`,
|
|
1180
|
+
* `TodosChangeStatusBulkRequest`. Schema-affecting qualifiers (`summary` /
|
|
1181
|
+
* `context` data-mode, `BULK`) ARE included; the multi-path `Via<…>`
|
|
1182
|
+
* disambiguator is STRIPPED because it never changes the body shape — so the
|
|
1183
|
+
* many URL variants of one operation share a single model.
|
|
1184
|
+
*/
|
|
1185
|
+
function componentSchemaName(op, role) {
|
|
1186
|
+
const qualifierTail = op.operationKey.startsWith(op.baseOperationIdentifier)
|
|
1187
|
+
? op.operationKey.slice(op.baseOperationIdentifier.length)
|
|
1188
|
+
: '';
|
|
1189
|
+
const schemaQualifiers = qualifierTail
|
|
1190
|
+
.split('_')
|
|
1191
|
+
.filter((q) => q.length > 0 && !q.startsWith('Via'))
|
|
1192
|
+
.map(pascal)
|
|
1193
|
+
.join('');
|
|
1194
|
+
return `${pascal(op.resourceIdentifier)}${pascal(op.baseOperationIdentifier)}${schemaQualifiers}${role}`;
|
|
1195
|
+
}
|
|
1196
|
+
function createSchemaRegistry() {
|
|
1197
|
+
const nameBySemanticIdentityAndContent = new Map();
|
|
1198
|
+
const contentByName = new Map();
|
|
1199
|
+
const schemas = {};
|
|
1200
|
+
return {
|
|
1201
|
+
ref(schema, desiredName) {
|
|
1202
|
+
if (!isHoistableSchema(schema)) {
|
|
1203
|
+
return schema;
|
|
1204
|
+
}
|
|
1205
|
+
const canonical = stableStringify(schema);
|
|
1206
|
+
const semanticIdentityAndContent = `${desiredName}\u0000${canonical}`;
|
|
1207
|
+
const existingName = nameBySemanticIdentityAndContent.get(semanticIdentityAndContent);
|
|
1208
|
+
if (existingName !== undefined) {
|
|
1209
|
+
return { $ref: `#/components/schemas/${existingName}` };
|
|
1210
|
+
}
|
|
1211
|
+
let name = desiredName;
|
|
1212
|
+
let suffix = 2;
|
|
1213
|
+
while (contentByName.has(name) && contentByName.get(name) !== canonical) {
|
|
1214
|
+
name = `${desiredName}${suffix}`;
|
|
1215
|
+
suffix += 1;
|
|
1216
|
+
}
|
|
1217
|
+
nameBySemanticIdentityAndContent.set(semanticIdentityAndContent, name);
|
|
1218
|
+
contentByName.set(name, canonical);
|
|
1219
|
+
schemas[name] = schema;
|
|
1220
|
+
return { $ref: `#/components/schemas/${name}` };
|
|
1221
|
+
},
|
|
1222
|
+
schemas() {
|
|
1223
|
+
return schemas;
|
|
1224
|
+
},
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Replace an operation object's inline request/response BODY schemas with
|
|
1229
|
+
* `$ref`s into `components/schemas` (via `registry`). Walks only the
|
|
1230
|
+
* `application/json` body slots — `requestBody` and each `responses[*]` with
|
|
1231
|
+
* content; query `parameters` (GET/DELETE) and the rare `callbacks` block stay
|
|
1232
|
+
* inline. All success statuses share one response schema object, so they
|
|
1233
|
+
* resolve to the same `$ref`. Mutates `operationObject` in place.
|
|
1234
|
+
*/
|
|
1235
|
+
function hoistOperationSchemas(operationObject, op, registry) {
|
|
1236
|
+
const requestBody = operationObject['requestBody'];
|
|
1237
|
+
if (isPlainObject(requestBody) && isPlainObject(requestBody['content'])) {
|
|
1238
|
+
const media = requestBody['content']['application/json'];
|
|
1239
|
+
if (isPlainObject(media) && isHoistableSchema(media['schema'])) {
|
|
1240
|
+
media['schema'] = registry.ref(media['schema'], componentSchemaName(op, 'Request'));
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
const responses = operationObject['responses'];
|
|
1244
|
+
if (isPlainObject(responses)) {
|
|
1245
|
+
for (const entry of Object.values(responses)) {
|
|
1246
|
+
if (!isPlainObject(entry) || !isPlainObject(entry['content'])) {
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
const media = entry['content']['application/json'];
|
|
1250
|
+
if (isPlainObject(media) && isHoistableSchema(media['schema'])) {
|
|
1251
|
+
media['schema'] = registry.ref(media['schema'], componentSchemaName(op, 'Response'));
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Framework-universal API conventions, emitted as the trailing section of every
|
|
1258
|
+
* `info.description`. These describe behaviour that is TRUE OF EVERY Wildo API
|
|
1259
|
+
* (engine-generic application behavior, not Wildo author guidance or app
|
|
1260
|
+
* configuration), so the reusable generator authors them once here. Grounded
|
|
1261
|
+
* in the actual generated spec: the three security schemes (see
|
|
1262
|
+
* `SECURITY_SCHEMES`), the `{ data, pagination }` list envelope, the
|
|
1263
|
+
* `x-idempotent` extension, and standard HTTP error codes. Rate-limit response
|
|
1264
|
+
* headers are deliberately NOT claimed — they are not emitted in the spec.
|
|
1265
|
+
*/
|
|
1266
|
+
const API_CONVENTIONS_MARKDOWN = [
|
|
1267
|
+
'## API conventions',
|
|
1268
|
+
'',
|
|
1269
|
+
'### Authentication',
|
|
1270
|
+
'',
|
|
1271
|
+
'Each operation lists the credentials it accepts under **Security**:',
|
|
1272
|
+
'',
|
|
1273
|
+
'- **Bearer token** — a JWT access token sent as `Authorization: Bearer <token>`.',
|
|
1274
|
+
'- **API key** — an organization- or application-scoped key (`sk_org_…` / `sk_app_…`) sent as the complete value of the `Authorization` header without a `Bearer` scheme.',
|
|
1275
|
+
'- **Anonymous session** — an anonymous-session token sent in `x-anonymous-session-token`, for pre-authentication operations.',
|
|
1276
|
+
'',
|
|
1277
|
+
'Operations with no security requirement are public.',
|
|
1278
|
+
'',
|
|
1279
|
+
'### Base URL',
|
|
1280
|
+
'',
|
|
1281
|
+
'Pick a base URL from the **Servers** dropdown and prepend it to each operation path (paths already include the `/api/v1` mount).',
|
|
1282
|
+
'',
|
|
1283
|
+
'### Pagination',
|
|
1284
|
+
'',
|
|
1285
|
+
'List and search operations return `{ "data": [ … ], "pagination": { "page", "limit", "total", "totalPages" } }`. Use the `page` and `limit` query parameters to page through results, and `sort` (comma-separated `field:asc` / `field:desc` pairs, highest priority first) to order them. Each operation documents its per-page maximum and sortable fields on the parameters themselves.',
|
|
1286
|
+
'',
|
|
1287
|
+
'### Filtering',
|
|
1288
|
+
'',
|
|
1289
|
+
'Search operations accept a free-text `q` query parameter and one query parameter per configured filter field (for example `?status=active`). Range filters (dates) are object-valued: pass them with `deepObject` bracket notation — `createdAt[startDate]=<ISO>&createdAt[endDate]=<ISO>`. Each operation documents its available filter fields on the parameters themselves.',
|
|
1290
|
+
'',
|
|
1291
|
+
'### Idempotency',
|
|
1292
|
+
'',
|
|
1293
|
+
'Operations flagged **idempotent** (the `x-idempotent` extension) are safe to retry — repeating the request has the same effect as making it once.',
|
|
1294
|
+
'',
|
|
1295
|
+
'### Errors',
|
|
1296
|
+
'',
|
|
1297
|
+
'Errors use standard HTTP status codes. Each operation documents the specific failures it can return (for example `400`, `403`, `404`, `409`) and the condition that triggers each.',
|
|
1298
|
+
].join('\n');
|
|
1299
|
+
/**
|
|
1300
|
+
* Compose the OpenAPI `info.description` (the API reference landing page): the
|
|
1301
|
+
* app's authored `apiOverview` (when present) followed by the framework-universal
|
|
1302
|
+
* `API_CONVENTIONS_MARKDOWN`. The conventions are ALWAYS emitted, so the landing
|
|
1303
|
+
* page is never blank — the overview is pure enrichment on top.
|
|
1304
|
+
*/
|
|
1305
|
+
function buildInfoDescription(input) {
|
|
1306
|
+
const overview = input.apiOverview?.trim();
|
|
1307
|
+
return overview && overview.length > 0
|
|
1308
|
+
? `${overview}\n\n${API_CONVENTIONS_MARKDOWN}`
|
|
1309
|
+
: API_CONVENTIONS_MARKDOWN;
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* Produces the display label for an OpenAPI resource tag without changing the
|
|
1313
|
+
* stable tag identity used by generated clients and operation metadata.
|
|
1314
|
+
*
|
|
1315
|
+
* The identity remains the resource identifier (`organizationApiKeys`), while
|
|
1316
|
+
* the native Docusaurus reference can present a human-scannable resource
|
|
1317
|
+
* heading (`Organization Api Keys`). Applications can later supply richer
|
|
1318
|
+
* product terminology without making that editorial concern part of the wire
|
|
1319
|
+
* contract.
|
|
1320
|
+
*/
|
|
1321
|
+
function buildResourceTagDisplayName(resourceIdentifier) {
|
|
1322
|
+
return splitWords(resourceIdentifier)
|
|
1323
|
+
.map((word) => apiReferenceDisplayWord(word, true))
|
|
1324
|
+
.join(' ');
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Builds the full OpenAPI 3.1 document object for a single section.
|
|
1328
|
+
* Returns a plain JS object — the YAML emitter is a separate concern.
|
|
1329
|
+
*
|
|
1330
|
+
* `paths` are nested by template path; multiple HTTP verbs on the
|
|
1331
|
+
* same path are merged into a single path-item object as the OpenAPI
|
|
1332
|
+
* spec requires. Body schemas are hoisted into `components/schemas`
|
|
1333
|
+
* (content-deduped, `$ref`-linked) by a per-section `SchemaRegistry`.
|
|
1334
|
+
*/
|
|
1335
|
+
function buildOpenApiDocument(section, operations, input) {
|
|
1336
|
+
const sortedOps = sortOperationsForDeterministicOutput(operations);
|
|
1337
|
+
const titleBase = input.publicMarketingTitle ?? input.appDisplayName;
|
|
1338
|
+
// `organization` / `application` are internal scope partitions. The API
|
|
1339
|
+
// reference must name the consumer job instead: organization-scoped tenant
|
|
1340
|
+
// operations are the normal API, whereas application-scoped operations are
|
|
1341
|
+
// the privileged administration surface.
|
|
1342
|
+
const sectionLabel = section === OpenApiSection.API_REFERENCE
|
|
1343
|
+
? 'API reference'
|
|
1344
|
+
: 'Application administration API reference';
|
|
1345
|
+
const paths = {};
|
|
1346
|
+
const ownershipByPathAndVerb = new Map();
|
|
1347
|
+
const tagSet = new Set();
|
|
1348
|
+
// operationId must be unique within the document. The verb-first builder is
|
|
1349
|
+
// unique by construction for almost every operation, but a few resources with
|
|
1350
|
+
// deeply self-nested URL families (e.g. `applications` via app-metadata /
|
|
1351
|
+
// app-versions) reduce to the same semantic `Via<…>` and would otherwise
|
|
1352
|
+
// collide. Disambiguate genuine collisions with a stable numeric suffix: the
|
|
1353
|
+
// first occurrence (in deterministic sort order) keeps the clean name, later
|
|
1354
|
+
// ones get `2`, `3`, … — applied here rather than in the projector because
|
|
1355
|
+
// uniqueness is a per-document (per-section) property.
|
|
1356
|
+
const operationIdUseCount = new Map();
|
|
1357
|
+
const schemaRegistry = createSchemaRegistry();
|
|
1358
|
+
// The schema comes from the same model the backend error handler serializes.
|
|
1359
|
+
// Register before per-operation hoisting so every non-success response shares
|
|
1360
|
+
// the stable, named component instead of producing resource-local copies.
|
|
1361
|
+
const errorResponseSchema = schemaRegistry.ref(input.errorResponseSchema, 'ErrorResponse');
|
|
1362
|
+
for (const op of sortedOps) {
|
|
1363
|
+
if (paths[op.path] === undefined) {
|
|
1364
|
+
paths[op.path] = {};
|
|
1365
|
+
}
|
|
1366
|
+
const collisionKey = `${op.path}::${op.httpVerb}`;
|
|
1367
|
+
const existingOwner = ownershipByPathAndVerb.get(collisionKey);
|
|
1368
|
+
if (existingOwner !== undefined) {
|
|
1369
|
+
throw new Error(`OpenAPI generator: duplicate ${op.httpVerb.toUpperCase()} ${op.path} in `
|
|
1370
|
+
+ `${section}. Existing operation ${existingOwner} collides with `
|
|
1371
|
+
+ `${op.resourceIdentifier}.${op.operationKey}. Each OpenAPI path+verb pair must be unique.`);
|
|
1372
|
+
}
|
|
1373
|
+
const baseOperationId = buildOperationId(op);
|
|
1374
|
+
const priorUses = operationIdUseCount.get(baseOperationId) ?? 0;
|
|
1375
|
+
operationIdUseCount.set(baseOperationId, priorUses + 1);
|
|
1376
|
+
const operationId = priorUses === 0 ? baseOperationId : `${baseOperationId}${priorUses + 1}`;
|
|
1377
|
+
const operationObject = buildOperationObject(op, operationId, errorResponseSchema);
|
|
1378
|
+
hoistOperationSchemas(operationObject, op, schemaRegistry);
|
|
1379
|
+
paths[op.path][op.httpVerb] = operationObject;
|
|
1380
|
+
ownershipByPathAndVerb.set(collisionKey, `${op.resourceIdentifier}.${op.operationKey}`);
|
|
1381
|
+
tagSet.add(op.resourceIdentifier);
|
|
1382
|
+
}
|
|
1383
|
+
// Tier-2: per-resource tag descriptions (resource business purpose). The
|
|
1384
|
+
// tag set is derived from the section's operations above; descriptions are
|
|
1385
|
+
// looked up by tag name from the input's resourceTags (order-independent).
|
|
1386
|
+
// Cleaned of implementation-only noise here (this is the surface the projector
|
|
1387
|
+
// does NOT route through `buildOperationDocFromSpec`), so the resource group
|
|
1388
|
+
// blurbs read identically to the per-operation prose. Tags whose description
|
|
1389
|
+
// cleans to empty are dropped (treated as having no description).
|
|
1390
|
+
const resourceTagsByName = new Map();
|
|
1391
|
+
for (const tag of input.resourceTags ?? []) {
|
|
1392
|
+
if (resourceTagsByName.has(tag.name)) {
|
|
1393
|
+
throw new Error(`OpenAPI generator: duplicate resource tag descriptor for '${tag.name}'.`);
|
|
1394
|
+
}
|
|
1395
|
+
resourceTagsByName.set(tag.name, tag);
|
|
1396
|
+
}
|
|
1397
|
+
return {
|
|
1398
|
+
openapi: '3.1.0',
|
|
1399
|
+
info: {
|
|
1400
|
+
title: `${titleBase} ${sectionLabel}`,
|
|
1401
|
+
description: buildInfoDescription(input),
|
|
1402
|
+
version: input.supportedApiVersion,
|
|
1403
|
+
},
|
|
1404
|
+
// `servers` only when the app enriched `apiServers` (deployment URLs are
|
|
1405
|
+
// app/environment knowledge — see WildoTechnicalDocConfig.apiServers).
|
|
1406
|
+
// Omitted → back-compatible with the pre-servers output.
|
|
1407
|
+
...(input.servers && input.servers.length > 0 ? { servers: input.servers } : {}),
|
|
1408
|
+
tags: Array.from(tagSet)
|
|
1409
|
+
.sort((a, b) => a.localeCompare(b))
|
|
1410
|
+
.map((tagName) => {
|
|
1411
|
+
const descriptor = resourceTagsByName.get(tagName);
|
|
1412
|
+
const description = descriptor?.description === undefined
|
|
1413
|
+
? undefined
|
|
1414
|
+
: stripImplementationNoise(descriptor.description);
|
|
1415
|
+
const resourceMetadata = {
|
|
1416
|
+
contractVersion: OPENAPI_RESOURCE_IDENTITY_CONTRACT_VERSION,
|
|
1417
|
+
resourceRef: `technical-documentation:resource/${tagName}`,
|
|
1418
|
+
...(descriptor?.lifecycleRole === undefined ? {} : { lifecycleRole: stripImplementationNoise(descriptor.lifecycleRole) }),
|
|
1419
|
+
...(descriptor?.relationships === undefined ? {} : { relationships: descriptor.relationships }),
|
|
1420
|
+
...(descriptor?.category === undefined ? {} : { category: descriptor.category }),
|
|
1421
|
+
};
|
|
1422
|
+
const tag = {
|
|
1423
|
+
name: tagName,
|
|
1424
|
+
'x-displayName': buildResourceTagDisplayName(tagName),
|
|
1425
|
+
'x-wildo': resourceMetadata,
|
|
1426
|
+
};
|
|
1427
|
+
return description !== undefined && description.length > 0 ? { ...tag, description } : tag;
|
|
1428
|
+
}),
|
|
1429
|
+
paths,
|
|
1430
|
+
// `components.schemas` = hoisted, content-deduped request/response body
|
|
1431
|
+
// models (named `<Resource><Verb><Quals>Request|Response`); `securitySchemes`
|
|
1432
|
+
// = framework-universal auth, referenced by each op's `security`.
|
|
1433
|
+
components: {
|
|
1434
|
+
...(Object.keys(schemaRegistry.schemas()).length > 0
|
|
1435
|
+
? { schemas: schemaRegistry.schemas() }
|
|
1436
|
+
: {}),
|
|
1437
|
+
securitySchemes: SECURITY_SCHEMES,
|
|
1438
|
+
},
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Counts the unique `resourceIdentifier` values among a set of
|
|
1443
|
+
* operations. Used to populate `OpenApiSectionOutput.resourceCount`.
|
|
1444
|
+
*/
|
|
1445
|
+
function countDistinctResources(operations) {
|
|
1446
|
+
const resources = new Set();
|
|
1447
|
+
for (const op of operations) {
|
|
1448
|
+
resources.add(op.resourceIdentifier);
|
|
1449
|
+
}
|
|
1450
|
+
return resources.size;
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Builds a single section's output entry. Returns `null` when the
|
|
1454
|
+
* section is empty so the caller can omit it from `sections[]` per
|
|
1455
|
+
* K-2.
|
|
1456
|
+
*/
|
|
1457
|
+
function buildSectionOutput(section, operations, input) {
|
|
1458
|
+
if (operations.length === 0) {
|
|
1459
|
+
return null;
|
|
1460
|
+
}
|
|
1461
|
+
const document = buildOpenApiDocument(section, operations, input);
|
|
1462
|
+
return {
|
|
1463
|
+
section,
|
|
1464
|
+
operationCount: operations.length,
|
|
1465
|
+
resourceCount: countDistinctResources(operations),
|
|
1466
|
+
document,
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Refuses a generator change that drops, duplicates or rewrites a resolved HTTP
|
|
1471
|
+
* operation between the source projection and the final OpenAPI documents.
|
|
1472
|
+
*
|
|
1473
|
+
* This is intentionally performed against the emitted `x-wildo` identity,
|
|
1474
|
+
* rather than operationId or path iteration order. Those are presentation and
|
|
1475
|
+
* OpenAPI container details respectively; the source tuple is the contract.
|
|
1476
|
+
*/
|
|
1477
|
+
function assertGeneratedOperationConservation(operations, sections) {
|
|
1478
|
+
const identityKey = (identity) => JSON.stringify([
|
|
1479
|
+
identity['contractVersion'],
|
|
1480
|
+
identity['resourceRef'],
|
|
1481
|
+
identity['operationFamilyRef'],
|
|
1482
|
+
identity['operationVariantRef'],
|
|
1483
|
+
identity['variantType'],
|
|
1484
|
+
identity['httpVerb'],
|
|
1485
|
+
identity['path'],
|
|
1486
|
+
identity['section'],
|
|
1487
|
+
]);
|
|
1488
|
+
const expected = new Set();
|
|
1489
|
+
for (const operation of operations) {
|
|
1490
|
+
const key = identityKey({ ...operation.identity, section: operation.consumerApiSection });
|
|
1491
|
+
if (expected.has(key))
|
|
1492
|
+
throw new Error(`OpenAPI generator: duplicate source operation identity ${key}`);
|
|
1493
|
+
expected.add(key);
|
|
1494
|
+
}
|
|
1495
|
+
const actual = new Set();
|
|
1496
|
+
for (const section of sections) {
|
|
1497
|
+
const paths = section.document['paths'];
|
|
1498
|
+
if (!isPlainObject(paths))
|
|
1499
|
+
throw new Error(`OpenAPI generator: ${section.section} document has no paths object`);
|
|
1500
|
+
for (const pathItem of Object.values(paths)) {
|
|
1501
|
+
if (!isPlainObject(pathItem))
|
|
1502
|
+
continue;
|
|
1503
|
+
for (const operationObject of Object.values(pathItem)) {
|
|
1504
|
+
if (!isPlainObject(operationObject))
|
|
1505
|
+
continue;
|
|
1506
|
+
const metadata = operationObject['x-wildo'];
|
|
1507
|
+
if (metadata === undefined)
|
|
1508
|
+
continue;
|
|
1509
|
+
if (!isPlainObject(metadata))
|
|
1510
|
+
throw new Error('OpenAPI generator: x-wildo operation metadata must be an object');
|
|
1511
|
+
const key = identityKey(metadata);
|
|
1512
|
+
if (actual.has(key))
|
|
1513
|
+
throw new Error(`OpenAPI generator: duplicate emitted operation identity ${key}`);
|
|
1514
|
+
actual.add(key);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
if (actual.size !== expected.size || [...expected].some((key) => !actual.has(key))) {
|
|
1519
|
+
throw new Error('OpenAPI generator: resolved HTTP operation conservation failed between source projection and OpenAPI output');
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Public entry point — single function the companion controller
|
|
1524
|
+
* calls.
|
|
1525
|
+
*
|
|
1526
|
+
* Steps:
|
|
1527
|
+
* 1. Parse the input (defense-in-depth — the controller validates
|
|
1528
|
+
* against `OpenApiGenerationInputSchema` already, but we re-parse
|
|
1529
|
+
* here so direct callers can't bypass validation).
|
|
1530
|
+
* 2. Assert URL-bearing variants without treating authentication-mode flags
|
|
1531
|
+
* as eligibility filters.
|
|
1532
|
+
* 3. Group by the explicit source-resolved consumer section.
|
|
1533
|
+
* 4. Build one semantic OpenAPI document per non-empty section.
|
|
1534
|
+
* 5. Drop empty sections (K-2).
|
|
1535
|
+
*
|
|
1536
|
+
* @param rawInput The unparsed input — typically the deserialised
|
|
1537
|
+
* wire payload from the introspection subprocess hop.
|
|
1538
|
+
* @returns The `OpenApiGenerationOutput` ready to be returned by the
|
|
1539
|
+
* companion controller.
|
|
1540
|
+
*/
|
|
1541
|
+
export function generateOpenApiDocuments(rawInput) {
|
|
1542
|
+
const input = OpenApiGenerationInputSchema.parse(rawInput);
|
|
1543
|
+
const apiOps = filterApiCallOperations(input.operations);
|
|
1544
|
+
const split = groupOperationsByConsumerApiSection(apiOps);
|
|
1545
|
+
const sectionOrder = [
|
|
1546
|
+
OpenApiSection.API_REFERENCE,
|
|
1547
|
+
OpenApiSection.APPLICATION_ADMINISTRATION_API_REFERENCE,
|
|
1548
|
+
];
|
|
1549
|
+
const sections = [];
|
|
1550
|
+
for (const section of sectionOrder) {
|
|
1551
|
+
const sectionOutput = buildSectionOutput(section, split[section], input);
|
|
1552
|
+
if (sectionOutput !== null) {
|
|
1553
|
+
sections.push(sectionOutput);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
assertGeneratedOperationConservation(apiOps, sections);
|
|
1557
|
+
return {
|
|
1558
|
+
generatedAt: new Date().toISOString(),
|
|
1559
|
+
sections,
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
//# sourceMappingURL=openapi-generator.js.map
|