@nifrajs/cli 3.5.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -3
- package/dist/app-files.d.ts +11 -0
- package/dist/app-files.d.ts.map +1 -0
- package/dist/app-files.js +34 -0
- package/dist/app-files.js.map +1 -0
- package/dist/assure-hydration.d.ts +7 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +128 -43
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +16 -2
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +5 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +32 -10
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/cdn-check.d.ts +41 -0
- package/dist/cdn-check.d.ts.map +1 -0
- package/dist/cdn-check.js +207 -0
- package/dist/cdn-check.js.map +1 -0
- package/dist/check-diagnostics.d.ts +4 -0
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +13 -0
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +49 -7
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +291 -62
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +99 -63
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +42 -16
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +262 -82
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +57 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +270 -26
- package/dist/command-catalog.js.map +1 -1
- package/dist/config-target.d.ts +8 -0
- package/dist/config-target.d.ts.map +1 -0
- package/dist/config-target.js +45 -0
- package/dist/config-target.js.map +1 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +52 -13
- package/dist/contracts.js.map +1 -1
- package/dist/db-audit.d.ts +37 -0
- package/dist/db-audit.d.ts.map +1 -0
- package/dist/db-audit.js +99 -0
- package/dist/db-audit.js.map +1 -0
- package/dist/db-child.d.ts +64 -0
- package/dist/db-child.d.ts.map +1 -0
- package/dist/db-child.js +213 -0
- package/dist/db-child.js.map +1 -0
- package/dist/db-config.d.ts +60 -0
- package/dist/db-config.d.ts.map +1 -0
- package/dist/db-config.js +176 -0
- package/dist/db-config.js.map +1 -0
- package/dist/db-tool.d.ts +84 -0
- package/dist/db-tool.d.ts.map +1 -0
- package/dist/db-tool.js +537 -0
- package/dist/db-tool.js.map +1 -0
- package/dist/dev-bun-config.d.ts +20 -13
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +98 -41
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/dev-feed-tool.d.ts +106 -0
- package/dist/dev-feed-tool.d.ts.map +1 -0
- package/dist/dev-feed-tool.js +713 -0
- package/dist/dev-feed-tool.js.map +1 -0
- package/dist/dev-server-client.d.ts +67 -0
- package/dist/dev-server-client.d.ts.map +1 -0
- package/dist/dev-server-client.js +195 -0
- package/dist/dev-server-client.js.map +1 -0
- package/dist/diff-tool.d.ts +3 -3
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +6 -5
- package/dist/diff-tool.js.map +1 -1
- package/dist/docs-search.d.ts.map +1 -1
- package/dist/docs-search.js +12 -4
- package/dist/docs-search.js.map +1 -1
- package/dist/doctor.d.ts +17 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +76 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +15 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +73 -1
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +41 -21
- package/dist/fix-recipes.js.map +1 -1
- package/dist/frontend-guidance.js +5 -5
- package/dist/frontend-guidance.js.map +1 -1
- package/dist/i18n-check.d.ts +16 -0
- package/dist/i18n-check.d.ts.map +1 -0
- package/dist/i18n-check.js +154 -0
- package/dist/i18n-check.js.map +1 -0
- package/dist/init-agents.d.ts +49 -13
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +239 -34
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/code-unit-order.d.ts +4 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +6 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/script-kind.d.ts +7 -0
- package/dist/internal/script-kind.d.ts.map +1 -0
- package/dist/internal/script-kind.js +12 -0
- package/dist/internal/script-kind.js.map +1 -0
- package/dist/internal/source-facts.d.ts +5 -0
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +61 -5
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +49 -24
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +507 -67
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +5 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +92 -27
- package/dist/introspect.js.map +1 -1
- package/dist/learn.js +3 -3
- package/dist/learn.js.map +1 -1
- package/dist/levels-tool.d.ts +10 -0
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +33 -3
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +67 -17
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +78 -18
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +3 -2
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-app-tool.d.ts.map +1 -1
- package/dist/mcp-app-tool.js +2 -0
- package/dist/mcp-app-tool.js.map +1 -1
- package/dist/mcp-context.js +1 -1
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-delegate.d.ts +57 -0
- package/dist/mcp-delegate.d.ts.map +1 -0
- package/dist/mcp-delegate.js +130 -0
- package/dist/mcp-delegate.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +23 -5
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-exec.d.ts +2 -0
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +132 -108
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.d.ts.map +1 -1
- package/dist/mcp-http.js +17 -5
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp-io.d.ts +14 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +45 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts +48 -0
- package/dist/mcp-isolate.d.ts.map +1 -0
- package/dist/mcp-isolate.js +222 -0
- package/dist/mcp-isolate.js.map +1 -0
- package/dist/mcp-project-child.d.ts +74 -0
- package/dist/mcp-project-child.d.ts.map +1 -0
- package/dist/mcp-project-child.js +130 -0
- package/dist/mcp-project-child.js.map +1 -0
- package/dist/mcp-render.js +2 -2
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp-root.d.ts +37 -9
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +110 -23
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-run.d.ts +18 -1
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +82 -9
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp-ws.js +4 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts +16 -3
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +81 -53
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +44 -0
- package/dist/migrate-layout.d.ts.map +1 -0
- package/dist/migrate-layout.js +982 -0
- package/dist/migrate-layout.js.map +1 -0
- package/dist/openapi-tool.d.ts +2 -2
- package/dist/openapi-tool.d.ts.map +1 -1
- package/dist/openapi-tool.js.map +1 -1
- package/dist/openapi-types.d.ts +2 -1
- package/dist/openapi-types.d.ts.map +1 -1
- package/dist/openapi-types.js +240 -12
- package/dist/openapi-types.js.map +1 -1
- package/dist/pipeline-report.d.ts +3 -2
- package/dist/pipeline-report.d.ts.map +1 -1
- package/dist/pipeline-report.js +19 -16
- package/dist/pipeline-report.js.map +1 -1
- package/dist/port.d.ts +5 -4
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +46 -29
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +5 -0
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js.map +1 -1
- package/dist/recipes/4.0.0.d.ts +9 -0
- package/dist/recipes/4.0.0.d.ts.map +1 -0
- package/dist/recipes/4.0.0.js +34 -0
- package/dist/recipes/4.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts +11 -1
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +47 -4
- package/dist/release-verification.js.map +1 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +12 -8
- package/dist/review.js.map +1 -1
- package/dist/route-types.d.ts +28 -0
- package/dist/route-types.d.ts.map +1 -0
- package/dist/route-types.js +106 -0
- package/dist/route-types.js.map +1 -0
- package/dist/rules/codes.d.ts +9 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +9 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/data-guard.d.ts +12 -0
- package/dist/rules/data-guard.d.ts.map +1 -0
- package/dist/rules/data-guard.js +213 -0
- package/dist/rules/data-guard.js.map +1 -0
- package/dist/rules/index.d.ts +3 -0
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js +17 -5
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/islands.d.ts.map +1 -1
- package/dist/rules/islands.js +7 -4
- package/dist/rules/islands.js.map +1 -1
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +71 -38
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/nano.d.ts.map +1 -1
- package/dist/rules/nano.js +15 -6
- package/dist/rules/nano.js.map +1 -1
- package/dist/rules/pages.d.ts +15 -0
- package/dist/rules/pages.d.ts.map +1 -0
- package/dist/rules/pages.js +88 -0
- package/dist/rules/pages.js.map +1 -0
- package/dist/rules/routes.d.ts +11 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +180 -2
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.d.ts +10 -0
- package/dist/rules/secrets.d.ts.map +1 -0
- package/dist/rules/secrets.js +169 -0
- package/dist/rules/secrets.js.map +1 -0
- package/dist/rules/security.d.ts +7 -0
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +171 -65
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/typescript.d.ts +16 -0
- package/dist/rules/typescript.d.ts.map +1 -0
- package/dist/rules/typescript.js +59 -0
- package/dist/rules/typescript.js.map +1 -0
- package/dist/rules/zones.d.ts +17 -0
- package/dist/rules/zones.d.ts.map +1 -0
- package/dist/rules/zones.js +174 -0
- package/dist/rules/zones.js.map +1 -0
- package/dist/scaffold.d.ts +25 -12
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +132 -87
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts +17 -3
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +941 -153
- package/dist/sdk.js.map +1 -1
- package/dist/search-terms.d.ts +2 -0
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +23 -8
- package/dist/search-terms.js.map +1 -1
- package/dist/smoke.d.ts +79 -0
- package/dist/smoke.d.ts.map +1 -0
- package/dist/smoke.js +494 -0
- package/dist/smoke.js.map +1 -0
- package/dist/sync-manifest.d.ts.map +1 -1
- package/dist/sync-manifest.js +2 -1
- package/dist/sync-manifest.js.map +1 -1
- package/dist/sync-routes.d.ts +4 -3
- package/dist/sync-routes.d.ts.map +1 -1
- package/dist/sync-routes.js +4 -3
- package/dist/sync-routes.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +30 -11
- package/dist/test-tool.js.map +1 -1
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +11 -5
- package/dist/types-search.js.map +1 -1
- package/dist/upgrade.d.ts +36 -6
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +167 -29
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +4 -0
- package/dist/verification-plan.js.map +1 -1
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +45 -12
- package/dist/verification.js.map +1 -1
- package/dist/web-app-options.d.ts +19 -0
- package/dist/web-app-options.d.ts.map +1 -0
- package/dist/web-app-options.js +49 -0
- package/dist/web-app-options.js.map +1 -0
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +29 -13
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +185 -29
- package/docs/llms-full.txt +3807 -580
- package/docs/types.json +14229 -10939
- package/package.json +19 -12
- package/src/app-files.ts +40 -0
- package/src/assure-hydration.ts +139 -46
- package/src/assure.ts +17 -2
- package/src/capabilities-tool.ts +35 -9
- package/src/cdn-check.ts +268 -0
- package/src/check-diagnostics.ts +17 -0
- package/src/check-scan.ts +358 -63
- package/src/check.ts +97 -62
- package/src/cli.ts +296 -87
- package/src/command-catalog.ts +374 -38
- package/src/config-target.ts +47 -0
- package/src/contracts.ts +54 -12
- package/src/db-audit.ts +128 -0
- package/src/db-child.ts +285 -0
- package/src/db-config.ts +224 -0
- package/src/db-tool.ts +690 -0
- package/src/dev-bun-config.ts +116 -40
- package/src/dev-feed-tool.ts +912 -0
- package/src/dev-server-client.ts +267 -0
- package/src/diff-tool.ts +6 -5
- package/src/docs-search.ts +10 -3
- package/src/doctor.ts +98 -15
- package/src/env-file.ts +78 -1
- package/src/fix-recipes.ts +44 -31
- package/src/frontend-guidance.ts +5 -5
- package/src/i18n-check.ts +179 -0
- package/src/init-agents.ts +301 -38
- package/src/internal/code-unit-order.ts +5 -0
- package/src/internal/script-kind.ts +11 -0
- package/src/internal/source-facts.ts +96 -22
- package/src/internal/typescript-import.ts +663 -68
- package/src/introspect.ts +90 -27
- package/src/learn.ts +3 -3
- package/src/levels-tool.ts +47 -2
- package/src/load.ts +140 -33
- package/src/manifest-tool.ts +3 -2
- package/src/mcp-app-tool.ts +2 -0
- package/src/mcp-context.ts +1 -1
- package/src/mcp-delegate.ts +162 -0
- package/src/mcp-docs-tools.ts +23 -5
- package/src/mcp-exec.ts +160 -132
- package/src/mcp-http.ts +19 -5
- package/src/mcp-io.ts +57 -0
- package/src/mcp-isolate.ts +287 -0
- package/src/mcp-project-child.ts +182 -0
- package/src/mcp-render.ts +2 -2
- package/src/mcp-root.ts +117 -22
- package/src/mcp-run.ts +104 -10
- package/src/mcp-ws.ts +4 -1
- package/src/mcp.ts +106 -64
- package/src/migrate-layout.ts +1121 -0
- package/src/openapi-tool.ts +7 -3
- package/src/openapi-types.ts +315 -13
- package/src/pipeline-report.ts +22 -18
- package/src/port.ts +53 -30
- package/src/project-facts.ts +5 -0
- package/src/recipes/4.0.0.ts +35 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +74 -5
- package/src/review.ts +13 -8
- package/src/route-types.ts +133 -0
- package/src/rules/codes.ts +9 -0
- package/src/rules/data-guard.ts +230 -0
- package/src/rules/index.ts +18 -5
- package/src/rules/islands.ts +12 -4
- package/src/rules/legacy.ts +71 -39
- package/src/rules/nano.ts +18 -6
- package/src/rules/pages.ts +99 -0
- package/src/rules/routes.ts +181 -2
- package/src/rules/secrets.ts +174 -0
- package/src/rules/security.ts +198 -70
- package/src/rules/typescript.ts +77 -0
- package/src/rules/zones.ts +195 -0
- package/src/scaffold.ts +147 -95
- package/src/sdk.ts +1215 -158
- package/src/search-terms.ts +24 -7
- package/src/smoke.ts +607 -0
- package/src/sync-manifest.ts +2 -1
- package/src/sync-routes.ts +4 -3
- package/src/test-tool.ts +32 -11
- package/src/types-search.ts +11 -4
- package/src/upgrade.ts +205 -25
- package/src/verification-plan.ts +13 -0
- package/src/verification.ts +50 -12
- package/src/web-app-options.ts +57 -0
- package/src/work-graph.ts +27 -13
package/src/sdk.ts
CHANGED
|
@@ -1,28 +1,92 @@
|
|
|
1
|
-
/** Deterministic
|
|
1
|
+
/** Deterministic typed SDK generation from the backend's OpenAPI contract. */
|
|
2
2
|
import { existsSync } from "node:fs"
|
|
3
3
|
import { basename, resolve } from "node:path"
|
|
4
|
+
import type { JsonSchema } from "@nifrajs/core/reflection"
|
|
4
5
|
import { type OpenAPIDocument, toOpenAPI } from "@nifrajs/schema/openapi"
|
|
6
|
+
import { BACKEND_APP_FILE } from "./app-files.ts"
|
|
7
|
+
import { codeUnitOrder } from "./internal/code-unit-order.ts"
|
|
5
8
|
|
|
6
9
|
export type SdkLanguage = "python" | "go"
|
|
7
10
|
|
|
11
|
+
export interface SdkRenderOptions {
|
|
12
|
+
/** Refuse operations whose request/response schema is opaque or unsupported. */
|
|
13
|
+
readonly strict?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SdkOpaqueIssue {
|
|
17
|
+
readonly operation: string
|
|
18
|
+
readonly location: string
|
|
19
|
+
readonly reason: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class SdkGenerationError extends Error {
|
|
23
|
+
readonly issues: readonly SdkOpaqueIssue[]
|
|
24
|
+
|
|
25
|
+
constructor(issues: readonly SdkOpaqueIssue[]) {
|
|
26
|
+
super(
|
|
27
|
+
"[nifra] strict SDK generation found " +
|
|
28
|
+
issues.length +
|
|
29
|
+
" opaque contract " +
|
|
30
|
+
(issues.length === 1 ? "portion" : "portions") +
|
|
31
|
+
":\n" +
|
|
32
|
+
issues.map((issue) => `- ${issue.operation} ${issue.location}: ${issue.reason}`).join("\n"),
|
|
33
|
+
)
|
|
34
|
+
this.name = "SdkGenerationError"
|
|
35
|
+
this.issues = Object.freeze([...issues])
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface SchemaRecord {
|
|
40
|
+
readonly [key: string]: unknown
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ParameterLike {
|
|
44
|
+
readonly name: string
|
|
45
|
+
readonly in: "path" | "query" | "header" | "cookie"
|
|
46
|
+
readonly required: boolean
|
|
47
|
+
readonly schema?: JsonSchema
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface MediaLike {
|
|
51
|
+
readonly schema?: JsonSchema
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface RequestBodyLike {
|
|
55
|
+
readonly required?: boolean
|
|
56
|
+
readonly content?: Readonly<Record<string, MediaLike>>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface ResponseLike {
|
|
60
|
+
readonly description?: string
|
|
61
|
+
readonly content?: Readonly<Record<string, MediaLike>>
|
|
62
|
+
}
|
|
63
|
+
|
|
8
64
|
interface OperationLike {
|
|
9
65
|
readonly operationId?: string
|
|
10
|
-
readonly parameters?: readonly
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
66
|
+
readonly parameters?: readonly ParameterLike[]
|
|
67
|
+
readonly requestBody?: RequestBodyLike
|
|
68
|
+
readonly responses?: Readonly<Record<string, ResponseLike>>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface OperationEntry {
|
|
72
|
+
readonly method: string
|
|
73
|
+
readonly path: string
|
|
74
|
+
readonly operation: OperationLike
|
|
16
75
|
}
|
|
17
76
|
|
|
18
77
|
const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] as const
|
|
78
|
+
const SUCCESS_STATUS = /^[23][0-9]{2}$/
|
|
79
|
+
const ERROR_STATUS = /^[45][0-9]{2}$/
|
|
19
80
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
81
|
+
const recordOf = (value: unknown): SchemaRecord | undefined =>
|
|
82
|
+
value !== null && typeof value === "object" && !Array.isArray(value)
|
|
83
|
+
? (value as SchemaRecord)
|
|
84
|
+
: undefined
|
|
85
|
+
|
|
86
|
+
function operations(document: OpenAPIDocument): readonly OperationEntry[] {
|
|
87
|
+
const result: OperationEntry[] = []
|
|
24
88
|
for (const [path, item] of Object.entries(document.paths).sort(([a], [b]) =>
|
|
25
|
-
a
|
|
89
|
+
codeUnitOrder(a, b),
|
|
26
90
|
)) {
|
|
27
91
|
for (const method of HTTP_METHODS) {
|
|
28
92
|
const operation = (item as Record<string, unknown>)[method]
|
|
@@ -35,15 +99,64 @@ function operations(
|
|
|
35
99
|
}
|
|
36
100
|
|
|
37
101
|
function identifier(value: string, fallback: string): string {
|
|
38
|
-
const startsWithDigit = /^\d/.test(value)
|
|
39
102
|
const normalized = value
|
|
40
103
|
.replace(/[^A-Za-z0-9_]+/g, "_")
|
|
41
104
|
.replace(/_+/g, "_")
|
|
42
105
|
.replace(/^_+|_+$/g, "")
|
|
43
|
-
const safe =
|
|
106
|
+
const safe = /^\d/.test(normalized) ? `_${normalized}` : normalized
|
|
44
107
|
return safe === "" || safe === "_" ? fallback : safe
|
|
45
108
|
}
|
|
46
109
|
|
|
110
|
+
const PYTHON_KEYWORDS = new Set([
|
|
111
|
+
"and",
|
|
112
|
+
"as",
|
|
113
|
+
"assert",
|
|
114
|
+
"async",
|
|
115
|
+
"await",
|
|
116
|
+
"break",
|
|
117
|
+
"case",
|
|
118
|
+
"class",
|
|
119
|
+
"continue",
|
|
120
|
+
"def",
|
|
121
|
+
"del",
|
|
122
|
+
"elif",
|
|
123
|
+
"else",
|
|
124
|
+
"except",
|
|
125
|
+
"False",
|
|
126
|
+
"finally",
|
|
127
|
+
"for",
|
|
128
|
+
"from",
|
|
129
|
+
"global",
|
|
130
|
+
"if",
|
|
131
|
+
"import",
|
|
132
|
+
"in",
|
|
133
|
+
"is",
|
|
134
|
+
"lambda",
|
|
135
|
+
"None",
|
|
136
|
+
"nonlocal",
|
|
137
|
+
"not",
|
|
138
|
+
"or",
|
|
139
|
+
"pass",
|
|
140
|
+
"raise",
|
|
141
|
+
"return",
|
|
142
|
+
"True",
|
|
143
|
+
"try",
|
|
144
|
+
"while",
|
|
145
|
+
"with",
|
|
146
|
+
"yield",
|
|
147
|
+
])
|
|
148
|
+
|
|
149
|
+
function pythonIdentifier(value: string, fallback: string): string {
|
|
150
|
+
const result = identifier(value, fallback)
|
|
151
|
+
return PYTHON_KEYWORDS.has(result) ? `${result}_` : result
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function pascal(value: string, fallback: string): string {
|
|
155
|
+
const words = identifier(value, fallback).split("_").filter(Boolean)
|
|
156
|
+
const result = words.map((word) => word[0]!.toUpperCase() + word.slice(1)).join("")
|
|
157
|
+
return result === "" ? fallback : result
|
|
158
|
+
}
|
|
159
|
+
|
|
47
160
|
function operationName(operation: OperationLike, method: string, path: string): string {
|
|
48
161
|
return identifier(operation.operationId ?? `${method}_${path}`, `${method}_request`)
|
|
49
162
|
}
|
|
@@ -61,200 +174,1144 @@ function pathParameters(path: string): readonly string[] {
|
|
|
61
174
|
return [...path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]!).filter(Boolean)
|
|
62
175
|
}
|
|
63
176
|
|
|
64
|
-
function
|
|
177
|
+
function firstSchema(
|
|
178
|
+
container: { readonly content?: Readonly<Record<string, MediaLike>> } | undefined,
|
|
179
|
+
): JsonSchema | undefined {
|
|
180
|
+
return Object.values(container?.content ?? {})[0]?.schema
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function responseSchema(response: ResponseLike | undefined): JsonSchema | undefined {
|
|
184
|
+
return firstSchema(response)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function schemaRef(schema: JsonSchema): string | undefined {
|
|
188
|
+
const record = recordOf(schema)
|
|
189
|
+
return typeof record?.$ref === "string" ? record.$ref : undefined
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function schemaType(schema: JsonSchema): string | undefined {
|
|
193
|
+
const record = recordOf(schema)
|
|
194
|
+
return typeof record?.type === "string" ? record.type : undefined
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function schemaProperties(schema: JsonSchema): Readonly<Record<string, JsonSchema>> | undefined {
|
|
198
|
+
const properties = recordOf(recordOf(schema)?.properties)
|
|
199
|
+
return properties as Readonly<Record<string, JsonSchema>> | undefined
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function schemaRequired(schema: JsonSchema): ReadonlySet<string> {
|
|
203
|
+
const required = recordOf(schema)?.required
|
|
204
|
+
return new Set(
|
|
205
|
+
Array.isArray(required)
|
|
206
|
+
? required.filter((value): value is string => typeof value === "string")
|
|
207
|
+
: [],
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function schemaUnion(schema: JsonSchema): readonly JsonSchema[] | undefined {
|
|
212
|
+
const record = recordOf(schema)
|
|
213
|
+
for (const key of ["anyOf", "oneOf"] as const) {
|
|
214
|
+
const value = record?.[key]
|
|
215
|
+
if (
|
|
216
|
+
Array.isArray(value) &&
|
|
217
|
+
value.every((item) => typeof item === "boolean" || recordOf(item) !== undefined)
|
|
218
|
+
) {
|
|
219
|
+
return value as JsonSchema[]
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return undefined
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function schemaIsNullable(schema: JsonSchema): boolean {
|
|
226
|
+
return schemaUnion(schema)?.some((item) => schemaType(item) === "null") ?? false
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isObjectModel(schema: JsonSchema): boolean {
|
|
230
|
+
const record = recordOf(schema)
|
|
231
|
+
const type = schemaType(schema)
|
|
232
|
+
if (type !== "object" && schemaProperties(schema) === undefined) return false
|
|
233
|
+
const additional = record?.additionalProperties
|
|
234
|
+
return additional === undefined || additional === false
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function operationLabel(entry: OperationEntry): string {
|
|
238
|
+
return `${entry.method.toUpperCase()} ${entry.path}`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
class SchemaCatalog {
|
|
242
|
+
readonly models = new Map<string, JsonSchema>()
|
|
243
|
+
readonly componentNames = new Map<string, string>()
|
|
244
|
+
private readonly namesBySchema = new Map<JsonSchema, string>()
|
|
245
|
+
private readonly usedNames = new Set<string>()
|
|
246
|
+
|
|
247
|
+
constructor(components: Readonly<Record<string, JsonSchema>> = {}) {
|
|
248
|
+
for (const name of Object.keys(components).sort()) {
|
|
249
|
+
this.componentNames.set(name, this.uniqueName(pascal(name, "Model")))
|
|
250
|
+
}
|
|
251
|
+
for (const [name, schema] of Object.entries(components)) {
|
|
252
|
+
const className = this.componentNames.get(name)
|
|
253
|
+
if (className !== undefined) this.models.set(className, schema)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private uniqueName(preferred: string): string {
|
|
258
|
+
let name = preferred || "Model"
|
|
259
|
+
let suffix = 2
|
|
260
|
+
while (this.usedNames.has(name)) name = `${preferred}_${suffix++}`
|
|
261
|
+
this.usedNames.add(name)
|
|
262
|
+
return name
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
model(schema: JsonSchema, hint: string): string {
|
|
266
|
+
const known = this.namesBySchema.get(schema)
|
|
267
|
+
if (known !== undefined) return known
|
|
268
|
+
const name = this.uniqueName(pascal(hint, "Model"))
|
|
269
|
+
this.namesBySchema.set(schema, name)
|
|
270
|
+
this.models.set(name, schema)
|
|
271
|
+
return name
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
refName(ref: string): string | undefined {
|
|
275
|
+
const prefix = "#/components/schemas/"
|
|
276
|
+
return ref.startsWith(prefix) ? this.componentNames.get(ref.slice(prefix.length)) : undefined
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
class OpaqueTracker {
|
|
281
|
+
readonly issues: SdkOpaqueIssue[] = []
|
|
282
|
+
|
|
283
|
+
add(operation: string, location: string, reason: string): void {
|
|
284
|
+
this.issues.push({ operation, location, reason })
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function literal(value: unknown): string {
|
|
289
|
+
if (typeof value === "string") return JSON.stringify(value)
|
|
290
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
|
291
|
+
return "None"
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function pythonType(
|
|
295
|
+
schema: JsonSchema | undefined,
|
|
296
|
+
hint: string,
|
|
297
|
+
catalog: SchemaCatalog,
|
|
298
|
+
tracker: OpaqueTracker,
|
|
299
|
+
operation: string,
|
|
300
|
+
location: string,
|
|
301
|
+
): string {
|
|
302
|
+
if (schema === undefined) {
|
|
303
|
+
tracker.add(operation, location, "schema is not declared")
|
|
304
|
+
return "Any"
|
|
305
|
+
}
|
|
306
|
+
if (typeof schema === "boolean") {
|
|
307
|
+
if (schema) tracker.add(operation, location, "boolean true schema has no usable static type")
|
|
308
|
+
return schema ? "Any" : "Never"
|
|
309
|
+
}
|
|
310
|
+
const ref = schemaRef(schema)
|
|
311
|
+
if (ref !== undefined) {
|
|
312
|
+
const name = catalog.refName(ref)
|
|
313
|
+
if (name !== undefined) return name
|
|
314
|
+
tracker.add(operation, location, `unresolved schema reference ${ref}`)
|
|
315
|
+
return "Any"
|
|
316
|
+
}
|
|
317
|
+
const union = schemaUnion(schema)
|
|
318
|
+
if (union !== undefined) {
|
|
319
|
+
const members = union
|
|
320
|
+
.filter((item) => schemaType(item) !== "null")
|
|
321
|
+
.map((item, index) =>
|
|
322
|
+
pythonType(item, hint + (index + 1), catalog, tracker, operation, location),
|
|
323
|
+
)
|
|
324
|
+
if (members.length === 0) return "None"
|
|
325
|
+
const rendered = [...new Set(members)].join(" | ")
|
|
326
|
+
return schemaIsNullable(schema) && !rendered.includes("None") ? `${rendered} | None` : rendered
|
|
327
|
+
}
|
|
328
|
+
const properties = schemaProperties(schema)
|
|
329
|
+
const type = schemaType(schema)
|
|
330
|
+
if (type === "object" || properties !== undefined) {
|
|
331
|
+
if (
|
|
332
|
+
properties === undefined &&
|
|
333
|
+
recordOf(schema)?.additionalProperties !== undefined &&
|
|
334
|
+
recordOf(schema)?.additionalProperties !== false
|
|
335
|
+
) {
|
|
336
|
+
const extra = recordOf(schema)?.additionalProperties
|
|
337
|
+
const extraType =
|
|
338
|
+
typeof extra === "boolean"
|
|
339
|
+
? "Any"
|
|
340
|
+
: pythonType(extra as JsonSchema, `${hint}Value`, catalog, tracker, operation, location)
|
|
341
|
+
return `dict[str, ${extraType}]`
|
|
342
|
+
}
|
|
343
|
+
return catalog.model(schema, hint)
|
|
344
|
+
}
|
|
345
|
+
if (type === "array") {
|
|
346
|
+
const item = recordOf(schema)?.items as JsonSchema | undefined
|
|
347
|
+
return (
|
|
348
|
+
"list[" +
|
|
349
|
+
pythonType(item, `${hint}Item`, catalog, tracker, operation, `${location}.items`) +
|
|
350
|
+
"]"
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
if (type === "string") {
|
|
354
|
+
const enumValues = recordOf(schema)?.enum
|
|
355
|
+
if (Array.isArray(enumValues) && enumValues.length > 0) {
|
|
356
|
+
return `Literal[${enumValues.map(literal).join(", ")}]`
|
|
357
|
+
}
|
|
358
|
+
return "str"
|
|
359
|
+
}
|
|
360
|
+
if (type === "integer") return "int"
|
|
361
|
+
if (type === "number") return "float"
|
|
362
|
+
if (type === "boolean") return "bool"
|
|
363
|
+
if (type === "null") return "None"
|
|
364
|
+
const constant = recordOf(schema)?.const
|
|
365
|
+
if (constant !== undefined) return `Literal[${literal(constant)}]`
|
|
366
|
+
const allOf = recordOf(schema)?.allOf
|
|
367
|
+
if (Array.isArray(allOf) && allOf.length > 0) {
|
|
368
|
+
return pythonType(allOf[0] as JsonSchema, hint, catalog, tracker, operation, location)
|
|
369
|
+
}
|
|
370
|
+
tracker.add(operation, location, "schema shape is opaque or unsupported")
|
|
371
|
+
return "Any"
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function pythonModelFields(
|
|
375
|
+
schema: JsonSchema,
|
|
376
|
+
name: string,
|
|
377
|
+
catalog: SchemaCatalog,
|
|
378
|
+
tracker: OpaqueTracker,
|
|
379
|
+
): string {
|
|
380
|
+
const properties = schemaProperties(schema)
|
|
381
|
+
if (properties === undefined) return " pass"
|
|
382
|
+
const required = schemaRequired(schema)
|
|
383
|
+
const used = new Set<string>()
|
|
384
|
+
const fields: string[] = []
|
|
385
|
+
for (const [jsonName, property] of Object.entries(properties)) {
|
|
386
|
+
let fieldName = pythonIdentifier(jsonName, "field")
|
|
387
|
+
while (used.has(fieldName)) fieldName += "_"
|
|
388
|
+
used.add(fieldName)
|
|
389
|
+
let type = pythonType(
|
|
390
|
+
property,
|
|
391
|
+
name + pascal(jsonName, "Field"),
|
|
392
|
+
catalog,
|
|
393
|
+
tracker,
|
|
394
|
+
"schema",
|
|
395
|
+
`model ${name}.${jsonName}`,
|
|
396
|
+
)
|
|
397
|
+
const optional = !required.has(jsonName)
|
|
398
|
+
if (optional && !type.includes("None")) type += " | None"
|
|
399
|
+
if (fieldName === jsonName) {
|
|
400
|
+
fields.push(` ${fieldName}: ${type}${optional ? " = None" : ""}`)
|
|
401
|
+
} else {
|
|
402
|
+
const metadata = `{"json_name": ${JSON.stringify(jsonName)}}`
|
|
403
|
+
fields.push(
|
|
404
|
+
" " +
|
|
405
|
+
fieldName +
|
|
406
|
+
": " +
|
|
407
|
+
type +
|
|
408
|
+
" = field(" +
|
|
409
|
+
(optional ? "default=None, " : "") +
|
|
410
|
+
"metadata=" +
|
|
411
|
+
metadata +
|
|
412
|
+
")",
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return fields.length === 0 ? " pass" : fields.join("\n")
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function renderPythonModels(catalog: SchemaCatalog, tracker: OpaqueTracker): string {
|
|
420
|
+
const rendered: string[] = []
|
|
421
|
+
let index = 0
|
|
422
|
+
while (index < catalog.models.size) {
|
|
423
|
+
const entries = [...catalog.models.entries()]
|
|
424
|
+
const [name, schema] = entries[index++]!
|
|
425
|
+
if (isObjectModel(schema)) {
|
|
426
|
+
rendered.push(
|
|
427
|
+
"@dataclass(frozen=True)\nclass " +
|
|
428
|
+
name +
|
|
429
|
+
":\n" +
|
|
430
|
+
pythonModelFields(schema, name, catalog, tracker),
|
|
431
|
+
)
|
|
432
|
+
} else {
|
|
433
|
+
rendered.push(
|
|
434
|
+
name +
|
|
435
|
+
": TypeAlias = " +
|
|
436
|
+
pythonType(schema, name, catalog, tracker, "schema", `model ${name}`),
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return rendered.join("\n\n")
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function pythonErrorAliases(
|
|
444
|
+
entries: readonly OperationEntry[],
|
|
445
|
+
names: readonly string[],
|
|
446
|
+
catalog: SchemaCatalog,
|
|
447
|
+
tracker: OpaqueTracker,
|
|
448
|
+
): string {
|
|
449
|
+
const aliases: string[] = []
|
|
450
|
+
entries.forEach((entry, index) => {
|
|
451
|
+
const types: string[] = []
|
|
452
|
+
for (const [status, response] of Object.entries(entry.operation.responses ?? {}).sort(
|
|
453
|
+
([a], [b]) => codeUnitOrder(a, b),
|
|
454
|
+
)) {
|
|
455
|
+
if (!ERROR_STATUS.test(status)) continue
|
|
456
|
+
const schema = responseSchema(response)
|
|
457
|
+
types.push(
|
|
458
|
+
schema === undefined
|
|
459
|
+
? "None"
|
|
460
|
+
: pythonType(
|
|
461
|
+
schema,
|
|
462
|
+
`${pascal(names[index]!, "Request")}Error${status}`,
|
|
463
|
+
catalog,
|
|
464
|
+
tracker,
|
|
465
|
+
operationLabel(entry),
|
|
466
|
+
`response ${status}`,
|
|
467
|
+
),
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
if (types.length > 0) {
|
|
471
|
+
aliases.push(
|
|
472
|
+
`type ${pascal(names[index]!, "Request")}Error = ${[...new Set(types)].join(" | ")}`,
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
return aliases.join("\n")
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function pythonSdk(document: OpenAPIDocument, options: SdkRenderOptions): string {
|
|
65
480
|
const entries = operations(document)
|
|
66
481
|
const names = uniqueNames(
|
|
67
482
|
entries.map(({ method, path, operation }) => operationName(operation, method, path)),
|
|
68
483
|
)
|
|
484
|
+
const catalog = new SchemaCatalog(document.components?.schemas)
|
|
485
|
+
const tracker = new OpaqueTracker()
|
|
69
486
|
const methods: string[] = []
|
|
70
|
-
|
|
487
|
+
|
|
488
|
+
entries.forEach((entry, index) => {
|
|
489
|
+
const { method, path, operation } = entry
|
|
71
490
|
const name = names[index]!
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
491
|
+
const label = operationLabel(entry)
|
|
492
|
+
const pathNames = pathParameters(path)
|
|
493
|
+
const pathDefinitions = pathNames.map((value) =>
|
|
494
|
+
(operation.parameters ?? []).find(
|
|
495
|
+
(parameter) => parameter.in === "path" && parameter.name === value,
|
|
496
|
+
),
|
|
497
|
+
)
|
|
498
|
+
const queryParameters = (operation.parameters ?? []).filter(
|
|
499
|
+
(parameter) => parameter.in === "query",
|
|
500
|
+
)
|
|
501
|
+
const used = new Set(["self", "body", "headers", ...pathNames])
|
|
502
|
+
const queryNames = queryParameters.map((parameter) => {
|
|
503
|
+
let value = pythonIdentifier(parameter.name, "query")
|
|
77
504
|
while (used.has(value)) value += "_"
|
|
78
505
|
used.add(value)
|
|
79
506
|
return value
|
|
80
507
|
})
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
508
|
+
const required: string[] = []
|
|
509
|
+
const optional: string[] = []
|
|
510
|
+
queryParameters.forEach((parameter, queryIndex) => {
|
|
511
|
+
const type = pythonType(
|
|
512
|
+
parameter.schema,
|
|
513
|
+
`${pascal(name, "Request")}Query${pascal(parameter.name, "Value")}`,
|
|
514
|
+
catalog,
|
|
515
|
+
tracker,
|
|
516
|
+
label,
|
|
517
|
+
`query ${parameter.name}`,
|
|
518
|
+
)
|
|
519
|
+
const value = `${queryNames[queryIndex]!}: ${parameter.required ? type : `${type} | None`}`
|
|
520
|
+
if (parameter.required) required.push(value)
|
|
521
|
+
else optional.push(`${value} = None`)
|
|
85
522
|
})
|
|
86
|
-
|
|
87
|
-
|
|
523
|
+
const bodySchema = firstSchema(operation.requestBody)
|
|
524
|
+
if (operation.requestBody !== undefined) {
|
|
525
|
+
const bodyType = pythonType(
|
|
526
|
+
bodySchema,
|
|
527
|
+
`${pascal(name, "Request")}Body`,
|
|
528
|
+
catalog,
|
|
529
|
+
tracker,
|
|
530
|
+
label,
|
|
531
|
+
"request body",
|
|
532
|
+
)
|
|
533
|
+
if (operation.requestBody.required === true) required.push(`body: ${bodyType}`)
|
|
534
|
+
else optional.push(`body: ${bodyType} = None`)
|
|
535
|
+
}
|
|
536
|
+
optional.push("headers: Mapping[str, str] | None = None")
|
|
537
|
+
const signature = [
|
|
538
|
+
...pathDefinitions.map((parameter, parameterIndex) => {
|
|
539
|
+
const type = pythonType(
|
|
540
|
+
parameter?.schema,
|
|
541
|
+
`${pascal(name, "Request")}Path${parameterIndex + 1}`,
|
|
542
|
+
catalog,
|
|
543
|
+
tracker,
|
|
544
|
+
label,
|
|
545
|
+
`path ${pathNames[parameterIndex]}`,
|
|
546
|
+
)
|
|
547
|
+
return `${pythonIdentifier(pathNames[parameterIndex]!, "param")}: ${type}`
|
|
548
|
+
}),
|
|
549
|
+
"*",
|
|
550
|
+
...required,
|
|
551
|
+
...optional,
|
|
552
|
+
].join(", ")
|
|
88
553
|
const query =
|
|
89
554
|
queryNames.length === 0
|
|
90
555
|
? "None"
|
|
91
|
-
:
|
|
556
|
+
: "{" +
|
|
557
|
+
queryNames
|
|
558
|
+
.map(
|
|
559
|
+
(value, queryIndex) =>
|
|
560
|
+
`${JSON.stringify(queryParameters[queryIndex]!.name)}: ${value}`,
|
|
561
|
+
)
|
|
562
|
+
.join(", ") +
|
|
563
|
+
"}"
|
|
92
564
|
const paths =
|
|
93
|
-
|
|
565
|
+
pathNames.length === 0
|
|
566
|
+
? "None"
|
|
567
|
+
: "{" +
|
|
568
|
+
pathNames
|
|
569
|
+
.map((value) => `${JSON.stringify(value)}: ${pythonIdentifier(value, "param")}`)
|
|
570
|
+
.join(", ") +
|
|
571
|
+
"}"
|
|
572
|
+
const success = Object.entries(operation.responses ?? {}).find(([status]) =>
|
|
573
|
+
SUCCESS_STATUS.test(status),
|
|
574
|
+
)
|
|
575
|
+
const responseType =
|
|
576
|
+
success === undefined
|
|
94
577
|
? "None"
|
|
95
|
-
:
|
|
96
|
-
|
|
578
|
+
: pythonType(
|
|
579
|
+
responseSchema(success[1]),
|
|
580
|
+
`${pascal(name, "Request")}Response`,
|
|
581
|
+
catalog,
|
|
582
|
+
tracker,
|
|
583
|
+
label,
|
|
584
|
+
`response ${success[0]}`,
|
|
585
|
+
)
|
|
586
|
+
const errorType = Object.keys(operation.responses ?? {}).some((status) =>
|
|
587
|
+
ERROR_STATUS.test(status),
|
|
588
|
+
)
|
|
589
|
+
? `${pascal(name, "Request")}Error`
|
|
590
|
+
: "Any"
|
|
97
591
|
methods.push(
|
|
98
|
-
|
|
99
|
-
|
|
592
|
+
" def " +
|
|
593
|
+
name +
|
|
594
|
+
"(self" +
|
|
595
|
+
(signature ? `, ${signature}` : "") +
|
|
596
|
+
") -> " +
|
|
597
|
+
responseType +
|
|
598
|
+
":\n" +
|
|
599
|
+
" return self._request(" +
|
|
600
|
+
JSON.stringify(method.toUpperCase()) +
|
|
601
|
+
", " +
|
|
602
|
+
JSON.stringify(path) +
|
|
603
|
+
", path_params=" +
|
|
604
|
+
paths +
|
|
605
|
+
", query=" +
|
|
606
|
+
query +
|
|
607
|
+
", body=" +
|
|
608
|
+
(operation.requestBody === undefined ? "None" : "body") +
|
|
609
|
+
", headers=headers, response_type=" +
|
|
610
|
+
responseType +
|
|
611
|
+
", error_type=" +
|
|
612
|
+
errorType +
|
|
613
|
+
")",
|
|
100
614
|
)
|
|
101
615
|
})
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
from
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
616
|
+
|
|
617
|
+
const aliases = pythonErrorAliases(entries, names, catalog, tracker)
|
|
618
|
+
const models = renderPythonModels(catalog, tracker)
|
|
619
|
+
if (options.strict === true && tracker.issues.length > 0)
|
|
620
|
+
throw new SdkGenerationError(tracker.issues)
|
|
621
|
+
return [
|
|
622
|
+
"# Generated by nifra. Regenerate with: nifra sdk --lang python",
|
|
623
|
+
"from __future__ import annotations",
|
|
624
|
+
"",
|
|
625
|
+
"import json",
|
|
626
|
+
"import types",
|
|
627
|
+
"import urllib.error",
|
|
628
|
+
"import urllib.parse",
|
|
629
|
+
"import urllib.request",
|
|
630
|
+
"from dataclasses import dataclass, field, fields, is_dataclass",
|
|
631
|
+
"from typing import Any, Generic, Literal, Mapping, Never, TypeAlias, TypeVar, get_args, get_origin, get_type_hints",
|
|
632
|
+
"",
|
|
633
|
+
'T = TypeVar("T")',
|
|
634
|
+
"",
|
|
635
|
+
"class NifraApiError(RuntimeError, Generic[T]):",
|
|
636
|
+
" def __init__(self, status: int, body: T):",
|
|
637
|
+
' super().__init__(f"Nifra API request failed with HTTP {status}")',
|
|
638
|
+
" self.status = status",
|
|
639
|
+
" self.body = body",
|
|
640
|
+
"",
|
|
641
|
+
"def _decode(value: Any, target: Any) -> Any:",
|
|
642
|
+
" if target is None or target is Any or target is object:",
|
|
643
|
+
" return value",
|
|
644
|
+
" origin = get_origin(target)",
|
|
645
|
+
" args = get_args(target)",
|
|
646
|
+
" if origin in (list, tuple, set):",
|
|
647
|
+
" item = args[0] if args else Any",
|
|
648
|
+
" values = [_decode(item_value, item) for item_value in (value or [])]",
|
|
649
|
+
" return values if origin is list else tuple(values) if origin is tuple else set(values)",
|
|
650
|
+
" if origin is dict:",
|
|
651
|
+
" key_type, value_type = args if len(args) == 2 else (str, Any)",
|
|
652
|
+
" return {_decode(key, key_type): _decode(item, value_type) for key, item in (value or {}).items()}",
|
|
653
|
+
' if origin in (types.UnionType, getattr(__import__("typing"), "Union", object)):',
|
|
654
|
+
" for option in args:",
|
|
655
|
+
" if option is type(None) and value is None:",
|
|
656
|
+
" return None",
|
|
657
|
+
" if option is type(None):",
|
|
658
|
+
" continue",
|
|
659
|
+
" try:",
|
|
660
|
+
" return _decode(value, option)",
|
|
661
|
+
" except (TypeError, ValueError, KeyError):",
|
|
662
|
+
" pass",
|
|
663
|
+
" return value",
|
|
664
|
+
" if isinstance(target, type) and is_dataclass(target) and isinstance(value, dict):",
|
|
665
|
+
" hints = get_type_hints(target)",
|
|
666
|
+
" result = {}",
|
|
667
|
+
" for item in fields(target):",
|
|
668
|
+
' json_name = item.metadata.get("json_name", item.name)',
|
|
669
|
+
" if json_name in value:",
|
|
670
|
+
" result[item.name] = _decode(value[json_name], hints.get(item.name, Any))",
|
|
671
|
+
" return target(**result)",
|
|
672
|
+
" if target in (str, int, float, bool) and value is not None:",
|
|
673
|
+
" return target(value)",
|
|
674
|
+
" return value",
|
|
675
|
+
"",
|
|
676
|
+
models || "# No object models were found.",
|
|
677
|
+
"",
|
|
678
|
+
aliases || "# No typed error bodies were declared.",
|
|
679
|
+
"",
|
|
680
|
+
"class Client:",
|
|
681
|
+
" def __init__(self, base_url: str, headers: Mapping[str, str] | None = None, timeout: float = 30.0):",
|
|
682
|
+
' self.base_url = base_url.rstrip("/")',
|
|
683
|
+
" self.headers = dict(headers or {})",
|
|
684
|
+
" self.timeout = timeout",
|
|
685
|
+
"",
|
|
686
|
+
" def _request(self, method: str, path: str, *, path_params: Mapping[str, Any] | None, query: Mapping[str, Any] | None, body: Any, headers: Mapping[str, str] | None, response_type: Any, error_type: Any) -> Any:",
|
|
687
|
+
" route = path",
|
|
688
|
+
" for key, value in (path_params or {}).items():",
|
|
689
|
+
' route = route.replace("{" + key + "}", urllib.parse.quote(str(value), safe=""))',
|
|
690
|
+
" # A `.` or `..` segment is a step to another path, never a value: anything between this",
|
|
691
|
+
" # client and the server may resolve it, so the call is refused instead of sent elsewhere.",
|
|
692
|
+
' if any(segment in (".", "..") for segment in route.split("/")):',
|
|
693
|
+
" raise ValueError(\"a path parameter cannot make a '.' or '..' path segment\")",
|
|
694
|
+
" params = [(key, value) for key, value in (query or {}).items() if value is not None]",
|
|
695
|
+
" url = self.base_url + route",
|
|
696
|
+
" if params:",
|
|
697
|
+
' url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params, doseq=True)',
|
|
698
|
+
' payload = None if body is None else json.dumps(body).encode("utf-8")',
|
|
699
|
+
" request = urllib.request.Request(url, data=payload, method=method, headers={**self.headers, **dict(headers or {})})",
|
|
700
|
+
" if payload is not None:",
|
|
701
|
+
' request.add_header("Content-Type", "application/json")',
|
|
702
|
+
" try:",
|
|
703
|
+
" with urllib.request.urlopen(request, timeout=self.timeout) as response:",
|
|
704
|
+
" raw = response.read()",
|
|
705
|
+
" return _decode(json.loads(raw) if raw else None, response_type)",
|
|
706
|
+
" except urllib.error.HTTPError as error:",
|
|
707
|
+
" raw = error.read()",
|
|
708
|
+
" try:",
|
|
709
|
+
" value = json.loads(raw) if raw else None",
|
|
710
|
+
" except json.JSONDecodeError:",
|
|
711
|
+
' value = raw.decode("utf-8", errors="replace")',
|
|
712
|
+
" raise NifraApiError(error.code, _decode(value, error_type)) from error",
|
|
713
|
+
"",
|
|
714
|
+
methods.length === 0 ? " pass" : methods.join("\n\n"),
|
|
715
|
+
"",
|
|
716
|
+
].join("\n")
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function goType(
|
|
720
|
+
schema: JsonSchema | undefined,
|
|
721
|
+
hint: string,
|
|
722
|
+
catalog: SchemaCatalog,
|
|
723
|
+
tracker: OpaqueTracker,
|
|
724
|
+
operation: string,
|
|
725
|
+
location: string,
|
|
726
|
+
): string {
|
|
727
|
+
if (schema === undefined) {
|
|
728
|
+
tracker.add(operation, location, "schema is not declared")
|
|
729
|
+
return "any"
|
|
730
|
+
}
|
|
731
|
+
if (typeof schema === "boolean") {
|
|
732
|
+
if (schema) tracker.add(operation, location, "boolean true schema has no usable static type")
|
|
733
|
+
return schema ? "any" : "struct{}"
|
|
734
|
+
}
|
|
735
|
+
const ref = schemaRef(schema)
|
|
736
|
+
if (ref !== undefined) {
|
|
737
|
+
const name = catalog.refName(ref)
|
|
738
|
+
if (name !== undefined) return name
|
|
739
|
+
tracker.add(operation, location, `unresolved schema reference ${ref}`)
|
|
740
|
+
return "any"
|
|
741
|
+
}
|
|
742
|
+
const union = schemaUnion(schema)
|
|
743
|
+
if (union !== undefined) {
|
|
744
|
+
const members = union.filter((item) => schemaType(item) !== "null")
|
|
745
|
+
if (members.length === 1 && schemaIsNullable(schema)) {
|
|
746
|
+
const type = goType(members[0], hint, catalog, tracker, operation, location)
|
|
747
|
+
return type.startsWith("*") ? type : `*${type}`
|
|
748
|
+
}
|
|
749
|
+
tracker.add(operation, location, "non-nullable unions have no direct Go representation")
|
|
750
|
+
return "any"
|
|
751
|
+
}
|
|
752
|
+
const properties = schemaProperties(schema)
|
|
753
|
+
const type = schemaType(schema)
|
|
754
|
+
if (type === "object" || properties !== undefined) {
|
|
755
|
+
if (
|
|
756
|
+
properties === undefined &&
|
|
757
|
+
recordOf(schema)?.additionalProperties !== undefined &&
|
|
758
|
+
recordOf(schema)?.additionalProperties !== false
|
|
759
|
+
) {
|
|
760
|
+
const extra = recordOf(schema)?.additionalProperties
|
|
761
|
+
const extraType =
|
|
762
|
+
typeof extra === "boolean"
|
|
763
|
+
? "any"
|
|
764
|
+
: goType(extra as JsonSchema, `${hint}Value`, catalog, tracker, operation, location)
|
|
765
|
+
return `map[string]${extraType}`
|
|
766
|
+
}
|
|
767
|
+
return catalog.model(schema, hint)
|
|
768
|
+
}
|
|
769
|
+
if (type === "array") {
|
|
770
|
+
return (
|
|
771
|
+
"[]" +
|
|
772
|
+
goType(
|
|
773
|
+
recordOf(schema)?.items as JsonSchema | undefined,
|
|
774
|
+
`${hint}Item`,
|
|
775
|
+
catalog,
|
|
776
|
+
tracker,
|
|
777
|
+
operation,
|
|
778
|
+
`${location}.items`,
|
|
779
|
+
)
|
|
780
|
+
)
|
|
781
|
+
}
|
|
782
|
+
if (type === "string") return "string"
|
|
783
|
+
if (type === "integer") return "int64"
|
|
784
|
+
if (type === "number") return "float64"
|
|
785
|
+
if (type === "boolean") return "bool"
|
|
786
|
+
if (type === "null") return "struct{}"
|
|
787
|
+
const allOf = recordOf(schema)?.allOf
|
|
788
|
+
if (Array.isArray(allOf) && allOf.length > 0)
|
|
789
|
+
return goType(allOf[0] as JsonSchema, hint, catalog, tracker, operation, location)
|
|
790
|
+
tracker.add(operation, location, "schema shape is opaque or unsupported")
|
|
791
|
+
return "any"
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const GO_KEYWORDS = new Set([
|
|
795
|
+
"break",
|
|
796
|
+
"default",
|
|
797
|
+
"func",
|
|
798
|
+
"interface",
|
|
799
|
+
"select",
|
|
800
|
+
"case",
|
|
801
|
+
"defer",
|
|
802
|
+
"go",
|
|
803
|
+
"map",
|
|
804
|
+
"struct",
|
|
805
|
+
"chan",
|
|
806
|
+
"else",
|
|
807
|
+
"goto",
|
|
808
|
+
"package",
|
|
809
|
+
"switch",
|
|
810
|
+
"const",
|
|
811
|
+
"fallthrough",
|
|
812
|
+
"if",
|
|
813
|
+
"range",
|
|
814
|
+
"type",
|
|
815
|
+
"continue",
|
|
816
|
+
"for",
|
|
817
|
+
"import",
|
|
818
|
+
"return",
|
|
819
|
+
"var",
|
|
820
|
+
])
|
|
821
|
+
|
|
822
|
+
// encoding/json reads a tag name made only of letters, decimal digits and this punctuation. A key
|
|
823
|
+
// with a quote, backslash, backtick, comma or control character has no struct-tag spelling, and
|
|
824
|
+
// writing it raw would end the tag (or the struct) early.
|
|
825
|
+
const GO_JSON_TAG_NAME = /^[\p{L}\p{Nd}!#$%&()*+\-./:;<=>?@[\]^_{|}~ ]+$/u
|
|
826
|
+
|
|
827
|
+
function goFieldName(value: string): string {
|
|
828
|
+
const result = pascal(value, "Field")
|
|
829
|
+
return GO_KEYWORDS.has(result.toLowerCase()) ? `${result}Value` : result
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function goModelFields(
|
|
833
|
+
schema: JsonSchema,
|
|
834
|
+
name: string,
|
|
835
|
+
catalog: SchemaCatalog,
|
|
836
|
+
tracker: OpaqueTracker,
|
|
837
|
+
): string {
|
|
838
|
+
const properties = schemaProperties(schema)
|
|
839
|
+
if (properties === undefined) return ""
|
|
840
|
+
const required = schemaRequired(schema)
|
|
841
|
+
const used = new Set<string>()
|
|
842
|
+
const tag = String.fromCharCode(96)
|
|
843
|
+
const fields: string[] = []
|
|
844
|
+
for (const [jsonName, property] of Object.entries(properties)) {
|
|
845
|
+
if (!GO_JSON_TAG_NAME.test(jsonName)) {
|
|
846
|
+
tracker.add(
|
|
847
|
+
"schema",
|
|
848
|
+
`model ${name}.${JSON.stringify(jsonName)}`,
|
|
849
|
+
"property name cannot be spelled as a Go struct tag",
|
|
850
|
+
)
|
|
851
|
+
continue
|
|
852
|
+
}
|
|
853
|
+
let fieldName = goFieldName(jsonName)
|
|
854
|
+
while (used.has(fieldName)) fieldName += "Value"
|
|
855
|
+
used.add(fieldName)
|
|
856
|
+
let type = goType(
|
|
857
|
+
property,
|
|
858
|
+
name + pascal(jsonName, "Field"),
|
|
859
|
+
catalog,
|
|
860
|
+
tracker,
|
|
861
|
+
"schema",
|
|
862
|
+
`model ${name}.${jsonName}`,
|
|
863
|
+
)
|
|
864
|
+
if (!required.has(jsonName) && type !== "any" && !type.startsWith("*") && type !== "struct{}")
|
|
865
|
+
type = `*${type}`
|
|
866
|
+
fields.push(
|
|
867
|
+
"\t" +
|
|
868
|
+
fieldName +
|
|
869
|
+
" " +
|
|
870
|
+
type +
|
|
871
|
+
" " +
|
|
872
|
+
tag +
|
|
873
|
+
'json:"' +
|
|
874
|
+
jsonName +
|
|
875
|
+
// A bare "-" tag omits the field; "-," names a property that is literally "-".
|
|
876
|
+
(required.has(jsonName) ? (jsonName === "-" ? "," : "") : ",omitempty") +
|
|
877
|
+
'"' +
|
|
878
|
+
tag,
|
|
879
|
+
)
|
|
880
|
+
}
|
|
881
|
+
return fields.join("\n")
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function renderGoModels(catalog: SchemaCatalog, tracker: OpaqueTracker): string {
|
|
885
|
+
const rendered: string[] = []
|
|
886
|
+
let index = 0
|
|
887
|
+
while (index < catalog.models.size) {
|
|
888
|
+
const entries = [...catalog.models.entries()]
|
|
889
|
+
const [name, schema] = entries[index++]!
|
|
890
|
+
const fields = goModelFields(schema, name, catalog, tracker)
|
|
891
|
+
if (isObjectModel(schema)) {
|
|
892
|
+
rendered.push(`type ${name} struct {\n${fields}\n}`)
|
|
893
|
+
} else {
|
|
894
|
+
rendered.push(
|
|
895
|
+
`type ${name} = ${goType(schema, name, catalog, tracker, "schema", `model ${name}`)}`,
|
|
896
|
+
)
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return rendered.join("\n\n")
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
interface GoErrorInfo {
|
|
903
|
+
readonly name: string
|
|
904
|
+
readonly definitions: string
|
|
905
|
+
readonly decoders: string
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function goErrorInfo(
|
|
909
|
+
entry: OperationEntry,
|
|
910
|
+
name: string,
|
|
911
|
+
catalog: SchemaCatalog,
|
|
912
|
+
tracker: OpaqueTracker,
|
|
913
|
+
): GoErrorInfo | undefined {
|
|
914
|
+
const errors = Object.entries(entry.operation.responses ?? {})
|
|
915
|
+
.filter(([status]) => ERROR_STATUS.test(status))
|
|
916
|
+
.sort(([a], [b]) => codeUnitOrder(a, b))
|
|
917
|
+
if (errors.length === 0) return undefined
|
|
918
|
+
const operationNameValue = pascal(name, "Request")
|
|
919
|
+
const interfaceName = `${operationNameValue}ErrorBody`
|
|
920
|
+
const types: Array<{ status: string; type: string }> = []
|
|
921
|
+
const definitions: string[] = []
|
|
922
|
+
const markers: string[] = []
|
|
923
|
+
for (const [status, response] of errors) {
|
|
924
|
+
const schema = responseSchema(response)
|
|
925
|
+
let type = "struct{}"
|
|
926
|
+
if (schema !== undefined) {
|
|
927
|
+
type = goType(
|
|
928
|
+
schema,
|
|
929
|
+
`${operationNameValue}Error${status}`,
|
|
930
|
+
catalog,
|
|
931
|
+
tracker,
|
|
932
|
+
operationLabel(entry),
|
|
933
|
+
`response ${status}`,
|
|
934
|
+
)
|
|
935
|
+
if (
|
|
936
|
+
type === "string" ||
|
|
937
|
+
type === "int64" ||
|
|
938
|
+
type === "float64" ||
|
|
939
|
+
type === "bool" ||
|
|
940
|
+
type.startsWith("[]") ||
|
|
941
|
+
type.startsWith("map[")
|
|
942
|
+
) {
|
|
943
|
+
const alias = `${operationNameValue}Error${status}`
|
|
944
|
+
definitions.push(`type ${alias} ${type}`)
|
|
945
|
+
type = alias
|
|
946
|
+
}
|
|
947
|
+
if (type !== "any") markers.push(`func (v ${type}) is${interfaceName}() {}`)
|
|
948
|
+
}
|
|
949
|
+
types.push({ status, type })
|
|
950
|
+
}
|
|
951
|
+
const hasUnknown = types.some((item) => item.type === "any")
|
|
952
|
+
const bodyInterface = hasUnknown ? "any" : interfaceName
|
|
953
|
+
if (!hasUnknown) {
|
|
954
|
+
definitions.unshift(`type ${interfaceName} interface {\n\tis${interfaceName}()\n}`)
|
|
955
|
+
definitions.push(...markers)
|
|
956
|
+
}
|
|
957
|
+
definitions.unshift(
|
|
958
|
+
"type " +
|
|
959
|
+
operationNameValue +
|
|
960
|
+
"Error struct {\n\tStatus int\n\tBody " +
|
|
961
|
+
bodyInterface +
|
|
962
|
+
"\n\tCause error\n}\n\nfunc (e *" +
|
|
963
|
+
operationNameValue +
|
|
964
|
+
'Error) Error() string {\n\treturn fmt.Sprintf("nifra API request failed with HTTP %d", e.Status)\n}',
|
|
965
|
+
)
|
|
966
|
+
const decoderLines = [
|
|
967
|
+
"func decode" +
|
|
968
|
+
operationNameValue +
|
|
969
|
+
"Error(status int, raw []byte, cause error) *" +
|
|
970
|
+
operationNameValue +
|
|
971
|
+
"Error {",
|
|
972
|
+
`\tout := &${operationNameValue}Error{Status: status, Cause: cause}`,
|
|
973
|
+
"\tswitch status {",
|
|
974
|
+
]
|
|
975
|
+
for (const item of types) {
|
|
976
|
+
if (item.type === "struct{}" || item.type === "any") continue
|
|
977
|
+
decoderLines.push(
|
|
978
|
+
`\tcase ${item.status}:`,
|
|
979
|
+
`\t\tvar body ${item.type}`,
|
|
980
|
+
"\t\tif len(raw) > 0 { if err := json.Unmarshal(raw, &body); err != nil { out.Cause = err } }",
|
|
981
|
+
"\t\tout.Body = body",
|
|
982
|
+
)
|
|
983
|
+
}
|
|
984
|
+
decoderLines.push("\t}", "\treturn out", "}")
|
|
985
|
+
return {
|
|
986
|
+
name: `${operationNameValue}Error`,
|
|
987
|
+
definitions: definitions.join("\n\n"),
|
|
988
|
+
decoders: decoderLines.join("\n"),
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function goQueryDefinition(
|
|
993
|
+
entry: OperationEntry,
|
|
994
|
+
name: string,
|
|
995
|
+
catalog: SchemaCatalog,
|
|
996
|
+
tracker: OpaqueTracker,
|
|
997
|
+
): { readonly type: string; readonly source: string } | undefined {
|
|
998
|
+
const parameters = (entry.operation.parameters ?? []).filter(
|
|
999
|
+
(parameter) => parameter.in === "query",
|
|
1000
|
+
)
|
|
1001
|
+
if (parameters.length === 0) return undefined
|
|
1002
|
+
const type = `${pascal(name, "Request")}Query`
|
|
1003
|
+
const fields: string[] = []
|
|
1004
|
+
const values: string[] = []
|
|
1005
|
+
for (const parameter of parameters) {
|
|
1006
|
+
let fieldType = goType(
|
|
1007
|
+
parameter.schema,
|
|
1008
|
+
type + pascal(parameter.name, "Value"),
|
|
1009
|
+
catalog,
|
|
1010
|
+
tracker,
|
|
1011
|
+
operationLabel(entry),
|
|
1012
|
+
`query ${parameter.name}`,
|
|
1013
|
+
)
|
|
1014
|
+
if (
|
|
1015
|
+
!parameter.required &&
|
|
1016
|
+
fieldType !== "any" &&
|
|
1017
|
+
!fieldType.startsWith("*") &&
|
|
1018
|
+
fieldType !== "struct{}"
|
|
1019
|
+
)
|
|
1020
|
+
fieldType = `*${fieldType}`
|
|
1021
|
+
const field = goFieldName(parameter.name)
|
|
1022
|
+
fields.push(`\t${field} ${fieldType}`)
|
|
1023
|
+
const value = parameter.required ? `fmt.Sprint(q.${field})` : `fmt.Sprint(*q.${field})`
|
|
1024
|
+
values.push(
|
|
1025
|
+
parameter.required
|
|
1026
|
+
? `\tv.Set(${JSON.stringify(parameter.name)}, ${value})`
|
|
1027
|
+
: "\tif q." +
|
|
1028
|
+
field +
|
|
1029
|
+
" != nil {\n\t\tv.Set(" +
|
|
1030
|
+
JSON.stringify(parameter.name) +
|
|
1031
|
+
", " +
|
|
1032
|
+
value +
|
|
1033
|
+
")\n\t}",
|
|
1034
|
+
)
|
|
1035
|
+
}
|
|
1036
|
+
return {
|
|
1037
|
+
type,
|
|
1038
|
+
source:
|
|
1039
|
+
"type " +
|
|
1040
|
+
type +
|
|
1041
|
+
" struct {\n" +
|
|
1042
|
+
fields.join("\n") +
|
|
1043
|
+
"\n}\n\nfunc (q " +
|
|
1044
|
+
type +
|
|
1045
|
+
") values() url.Values {\n\tv := make(url.Values)\n" +
|
|
1046
|
+
values.join("\n") +
|
|
1047
|
+
"\n\treturn v\n}",
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function goSdk(document: OpenAPIDocument, options: SdkRenderOptions): string {
|
|
159
1052
|
const entries = operations(document)
|
|
160
1053
|
const names = uniqueNames(
|
|
161
1054
|
entries.map(({ method, path, operation }) => operationName(operation, method, path)),
|
|
162
1055
|
)
|
|
1056
|
+
const catalog = new SchemaCatalog(document.components?.schemas)
|
|
1057
|
+
const tracker = new OpaqueTracker()
|
|
163
1058
|
const methods: string[] = []
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
1059
|
+
const queries: string[] = []
|
|
1060
|
+
const errors: string[] = []
|
|
1061
|
+
const decoders: string[] = []
|
|
1062
|
+
const errorNames = new Map<number, string>()
|
|
1063
|
+
const queryTypes = new Map<number, string>()
|
|
1064
|
+
|
|
1065
|
+
entries.forEach((entry, index) => {
|
|
1066
|
+
const query = goQueryDefinition(entry, names[index]!, catalog, tracker)
|
|
1067
|
+
if (query !== undefined) {
|
|
1068
|
+
queries.push(query.source)
|
|
1069
|
+
queryTypes.set(index, query.type)
|
|
1070
|
+
}
|
|
1071
|
+
const error = goErrorInfo(entry, names[index]!, catalog, tracker)
|
|
1072
|
+
if (error !== undefined) {
|
|
1073
|
+
errors.push(error.definitions)
|
|
1074
|
+
decoders.push(error.decoders)
|
|
1075
|
+
errorNames.set(index, error.name)
|
|
1076
|
+
}
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
entries.forEach((entry, index) => {
|
|
1080
|
+
const { method, path, operation } = entry
|
|
1081
|
+
const name = names[index]!
|
|
1082
|
+
const label = operationLabel(entry)
|
|
1083
|
+
const pathNames = pathParameters(path)
|
|
1084
|
+
const args: string[] = []
|
|
1085
|
+
for (const parameterName of pathNames) {
|
|
1086
|
+
const parameter = (operation.parameters ?? []).find(
|
|
1087
|
+
(item) => item.in === "path" && item.name === parameterName,
|
|
1088
|
+
)
|
|
1089
|
+
const type = goType(
|
|
1090
|
+
parameter?.schema,
|
|
1091
|
+
`${pascal(name, "Request")}Path${pascal(parameterName, "Value")}`,
|
|
1092
|
+
catalog,
|
|
1093
|
+
tracker,
|
|
1094
|
+
label,
|
|
1095
|
+
`path ${parameterName}`,
|
|
1096
|
+
)
|
|
1097
|
+
args.push(`${goFieldName(parameterName)} ${type}`)
|
|
1098
|
+
}
|
|
1099
|
+
const queryType = queryTypes.get(index)
|
|
1100
|
+
if (queryType !== undefined) args.push(`query ${queryType}`)
|
|
1101
|
+
const bodySchema = firstSchema(operation.requestBody)
|
|
1102
|
+
const bodyType =
|
|
1103
|
+
operation.requestBody === undefined
|
|
1104
|
+
? undefined
|
|
1105
|
+
: goType(
|
|
1106
|
+
bodySchema,
|
|
1107
|
+
`${pascal(name, "Request")}Body`,
|
|
1108
|
+
catalog,
|
|
1109
|
+
tracker,
|
|
1110
|
+
label,
|
|
1111
|
+
"request body",
|
|
1112
|
+
)
|
|
1113
|
+
if (bodyType !== undefined) args.push(`body ${bodyType}`)
|
|
1114
|
+
const success = Object.entries(operation.responses ?? {}).find(([status]) =>
|
|
1115
|
+
SUCCESS_STATUS.test(status),
|
|
1116
|
+
)
|
|
1117
|
+
const responseType =
|
|
1118
|
+
success === undefined
|
|
1119
|
+
? "struct{}"
|
|
1120
|
+
: goType(
|
|
1121
|
+
responseSchema(success[1]),
|
|
1122
|
+
`${pascal(name, "Request")}Response`,
|
|
1123
|
+
catalog,
|
|
1124
|
+
tracker,
|
|
1125
|
+
label,
|
|
1126
|
+
`response ${success[0]}`,
|
|
1127
|
+
)
|
|
1128
|
+
const errorName = errorNames.get(index)
|
|
1129
|
+
const errorType = errorName === undefined ? "error" : `*${errorName}`
|
|
168
1130
|
const pathMap =
|
|
169
|
-
|
|
1131
|
+
pathNames.length === 0
|
|
170
1132
|
? "nil"
|
|
171
|
-
:
|
|
1133
|
+
: "map[string]string{" +
|
|
1134
|
+
pathNames
|
|
1135
|
+
.map((value) => `${JSON.stringify(value)}: fmt.Sprint(${goFieldName(value)})`)
|
|
1136
|
+
.join(", ") +
|
|
1137
|
+
"}"
|
|
1138
|
+
const queryValue = queryType === undefined ? "nil" : "query.values()"
|
|
1139
|
+
const bodyValue = bodyType === undefined ? "nil" : "body"
|
|
1140
|
+
const zeroError = errorName === undefined ? "err" : `&${errorName}{Status: 0, Cause: err}`
|
|
1141
|
+
const statusError =
|
|
1142
|
+
errorName === undefined
|
|
1143
|
+
? "&NifraApiError{Status: status, Body: string(raw)}"
|
|
1144
|
+
: `decode${errorName}(status, raw, nil)`
|
|
1145
|
+
const unmarshalError =
|
|
1146
|
+
errorName === undefined ? "err" : `&${errorName}{Status: status, Cause: err}`
|
|
172
1147
|
methods.push(
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
1148
|
+
"func (c *Client) " +
|
|
1149
|
+
pascal(name, "Request") +
|
|
1150
|
+
"(" +
|
|
1151
|
+
args.join(", ") +
|
|
1152
|
+
") (" +
|
|
1153
|
+
responseType +
|
|
1154
|
+
", " +
|
|
1155
|
+
errorType +
|
|
1156
|
+
") {\n" +
|
|
1157
|
+
"\tstatus, raw, err := c.request(http.Method" +
|
|
1158
|
+
method[0]!.toUpperCase() +
|
|
1159
|
+
method.slice(1) +
|
|
1160
|
+
", " +
|
|
1161
|
+
JSON.stringify(path) +
|
|
1162
|
+
", " +
|
|
1163
|
+
pathMap +
|
|
1164
|
+
", " +
|
|
1165
|
+
queryValue +
|
|
1166
|
+
", " +
|
|
1167
|
+
bodyValue +
|
|
1168
|
+
")\n" +
|
|
1169
|
+
"\tvar value " +
|
|
1170
|
+
responseType +
|
|
1171
|
+
"\n\tif err != nil {\n\t\treturn value, " +
|
|
1172
|
+
zeroError +
|
|
1173
|
+
"\n\t}\n" +
|
|
1174
|
+
"\tif status < 200 || status >= 300 {\n\t\treturn value, " +
|
|
1175
|
+
statusError +
|
|
1176
|
+
"\n\t}\n" +
|
|
1177
|
+
"\tif len(raw) > 0 {\n\t\tif err := json.Unmarshal(raw, &value); err != nil {\n\t\t\treturn value, " +
|
|
1178
|
+
unmarshalError +
|
|
1179
|
+
"\n\t\t}\n\t}\n\treturn value, nil\n}",
|
|
176
1180
|
)
|
|
177
1181
|
})
|
|
178
|
-
return `// Code generated by nifra. Regenerate with: nifra sdk --lang go
|
|
179
|
-
package nifrasdk
|
|
180
|
-
|
|
181
|
-
import (
|
|
182
|
-
"bytes"
|
|
183
|
-
"encoding/json"
|
|
184
|
-
"fmt"
|
|
185
|
-
"io"
|
|
186
|
-
"net/http"
|
|
187
|
-
"net/url"
|
|
188
|
-
"strings"
|
|
189
|
-
)
|
|
190
1182
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
1183
|
+
const models = renderGoModels(catalog, tracker)
|
|
1184
|
+
if (options.strict === true && tracker.issues.length > 0)
|
|
1185
|
+
throw new SdkGenerationError(tracker.issues)
|
|
1186
|
+
return [
|
|
1187
|
+
"// Code generated by nifra. Regenerate with: nifra sdk --lang go",
|
|
1188
|
+
"package nifrasdk",
|
|
1189
|
+
"",
|
|
1190
|
+
"import (",
|
|
1191
|
+
'\t"bytes"',
|
|
1192
|
+
'\t"encoding/json"',
|
|
1193
|
+
'\t"fmt"',
|
|
1194
|
+
'\t"io"',
|
|
1195
|
+
'\t"net/http"',
|
|
1196
|
+
'\t"net/url"',
|
|
1197
|
+
'\t"strings"',
|
|
1198
|
+
")",
|
|
1199
|
+
"",
|
|
1200
|
+
"type Client struct {",
|
|
1201
|
+
"\tBaseURL string",
|
|
1202
|
+
"\tHTTP *http.Client",
|
|
1203
|
+
"\tHeaders http.Header",
|
|
1204
|
+
"}",
|
|
1205
|
+
"",
|
|
1206
|
+
"func NewClient(baseURL string) *Client {",
|
|
1207
|
+
'\treturn &Client{BaseURL: strings.TrimRight(baseURL, "/"), HTTP: http.DefaultClient, Headers: make(http.Header)}',
|
|
1208
|
+
"}",
|
|
1209
|
+
"",
|
|
1210
|
+
"type NifraApiError struct { Status int; Body string }",
|
|
1211
|
+
'func (e *NifraApiError) Error() string { return fmt.Sprintf("nifra API request failed with HTTP %d", e.Status) }',
|
|
1212
|
+
"",
|
|
1213
|
+
"func (c *Client) request(method, path string, pathParams map[string]string, query url.Values, body any) (int, []byte, error) {",
|
|
1214
|
+
"\troute := path",
|
|
1215
|
+
'\tfor key, value := range pathParams { route = strings.ReplaceAll(route, "{"+key+"}", url.PathEscape(value)) }',
|
|
1216
|
+
"\t// A `.` or `..` segment is a step to another path, never a value: anything between this client",
|
|
1217
|
+
"\t// and the server may resolve it, so the call is refused instead of sent elsewhere.",
|
|
1218
|
+
'\tfor _, segment := range strings.Split(route, "/") { if segment == "." || segment == ".." { return 0, nil, fmt.Errorf("a path parameter cannot make a %q or %q path segment", ".", "..") } }',
|
|
1219
|
+
"\ttarget := c.BaseURL + route",
|
|
1220
|
+
"\tparsed, err := url.Parse(target)",
|
|
1221
|
+
"\tif err != nil { return 0, nil, err }",
|
|
1222
|
+
"\tif query != nil { parsed.RawQuery = query.Encode() }",
|
|
1223
|
+
"\tvar reader io.Reader",
|
|
1224
|
+
"\tif body != nil { payload, err := json.Marshal(body); if err != nil { return 0, nil, err }; reader = bytes.NewReader(payload) }",
|
|
1225
|
+
"\trequest, err := http.NewRequest(method, parsed.String(), reader)",
|
|
1226
|
+
"\tif err != nil { return 0, nil, err }",
|
|
1227
|
+
"\tfor key, values := range c.Headers { for _, value := range values { request.Header.Add(key, value) } }",
|
|
1228
|
+
'\tif body != nil { request.Header.Set("Content-Type", "application/json") }',
|
|
1229
|
+
"\tresponse, err := c.HTTP.Do(request)",
|
|
1230
|
+
"\tif err != nil { return 0, nil, err }",
|
|
1231
|
+
"\tdefer response.Body.Close()",
|
|
1232
|
+
"\traw, err := io.ReadAll(response.Body)",
|
|
1233
|
+
"\treturn response.StatusCode, raw, err",
|
|
1234
|
+
"}",
|
|
1235
|
+
"",
|
|
1236
|
+
models || "// No object models were found.",
|
|
1237
|
+
"",
|
|
1238
|
+
queries.join("\n\n") || "// No typed query models were needed.",
|
|
1239
|
+
"",
|
|
1240
|
+
errors.join("\n\n") || "// No typed error bodies were declared.",
|
|
1241
|
+
"",
|
|
1242
|
+
decoders.join("\n\n"),
|
|
1243
|
+
"",
|
|
1244
|
+
methods.join("\n\n") || "// No operations were found.",
|
|
1245
|
+
"",
|
|
1246
|
+
].join("\n")
|
|
195
1247
|
}
|
|
196
1248
|
|
|
197
|
-
|
|
198
|
-
|
|
1249
|
+
/** Render a generated SDK. Strict mode refuses opaque request/response portions. */
|
|
1250
|
+
export function renderSdk(
|
|
1251
|
+
document: OpenAPIDocument,
|
|
1252
|
+
language: SdkLanguage,
|
|
1253
|
+
options: SdkRenderOptions = {},
|
|
1254
|
+
): string {
|
|
1255
|
+
return language === "python" ? pythonSdk(document, options) : goSdk(document, options)
|
|
199
1256
|
}
|
|
200
1257
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
1258
|
+
function unsupportedTransports(backend: unknown): readonly string[] {
|
|
1259
|
+
const routes = (
|
|
1260
|
+
backend as {
|
|
1261
|
+
routes?: () => readonly {
|
|
1262
|
+
method?: unknown
|
|
1263
|
+
path?: unknown
|
|
1264
|
+
schema?: { sse?: unknown }
|
|
1265
|
+
}[]
|
|
205
1266
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
reader = bytes.NewReader(payload)
|
|
1267
|
+
).routes
|
|
1268
|
+
if (typeof routes !== "function") return []
|
|
1269
|
+
const result: string[] = []
|
|
1270
|
+
for (const route of routes.call(backend)) {
|
|
1271
|
+
const method = typeof route.method === "string" ? route.method : "?"
|
|
1272
|
+
const path = typeof route.path === "string" ? route.path : "?"
|
|
1273
|
+
if (route.schema?.sse !== undefined) {
|
|
1274
|
+
result.push(`${method} ${path} uses SSE; generated SDKs currently cover JSON HTTP only`)
|
|
215
1275
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
for key, values := range c.Headers { for _, value := range values { request.Header.Add(key, value) } }
|
|
219
|
-
if body != nil { request.Header.Set("Content-Type", "application/json") }
|
|
220
|
-
response, err := c.HTTP.Do(request)
|
|
221
|
-
if err != nil { return nil, err }
|
|
222
|
-
defer response.Body.Close()
|
|
223
|
-
raw, err := io.ReadAll(response.Body)
|
|
224
|
-
if err != nil { return nil, err }
|
|
225
|
-
var value any
|
|
226
|
-
if len(raw) > 0 { if err := json.Unmarshal(raw, &value); err != nil { value = string(raw) } }
|
|
227
|
-
if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, fmt.Errorf("nifra API request failed with HTTP %d: %v", response.StatusCode, value) }
|
|
228
|
-
return value, nil
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
${methods.length === 0 ? "// No operations were found.\n" : `${methods.join("\n\n")}\n`}`
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** Render a generated SDK. Python uses the standard library; Go uses net/http. */
|
|
235
|
-
export function renderSdk(document: OpenAPIDocument, language: SdkLanguage): string {
|
|
236
|
-
return language === "python" ? pythonSdk(document) : goSdk(document)
|
|
1276
|
+
}
|
|
1277
|
+
return result
|
|
237
1278
|
}
|
|
238
1279
|
|
|
239
|
-
/** Load
|
|
1280
|
+
/** Load backend/app.ts, generate an SDK, and write it to the project. */
|
|
240
1281
|
export async function runSdk(
|
|
241
1282
|
cwd: string,
|
|
242
|
-
options: { readonly language: SdkLanguage; readonly out?: string },
|
|
1283
|
+
options: { readonly language: SdkLanguage; readonly out?: string; readonly strict?: boolean },
|
|
243
1284
|
): Promise<void> {
|
|
244
|
-
const backendPath = resolve(cwd,
|
|
1285
|
+
const backendPath = resolve(cwd, BACKEND_APP_FILE)
|
|
245
1286
|
if (!existsSync(backendPath)) {
|
|
246
|
-
throw new Error(
|
|
1287
|
+
throw new Error(
|
|
1288
|
+
`[nifra] no ${BACKEND_APP_FILE} in ${cwd} - SDK generation needs the API contract.`,
|
|
1289
|
+
)
|
|
247
1290
|
}
|
|
248
1291
|
const backend = ((await import(backendPath)) as { backend?: unknown }).backend
|
|
249
|
-
if (backend === undefined) throw new Error(`[nifra] ${backendPath} does not export
|
|
1292
|
+
if (backend === undefined) throw new Error(`[nifra] ${backendPath} does not export backend.`)
|
|
1293
|
+
const transportIssues = unsupportedTransports(backend)
|
|
1294
|
+
if (options.strict === true && transportIssues.length > 0) {
|
|
1295
|
+
throw new SdkGenerationError(
|
|
1296
|
+
transportIssues.map((reason) => ({ operation: "transport", location: "SSE", reason })),
|
|
1297
|
+
)
|
|
1298
|
+
}
|
|
1299
|
+
for (const warning of transportIssues) console.warn(`[nifra] SDK warning: ${warning}`)
|
|
250
1300
|
const document = toOpenAPI(backend as Parameters<typeof toOpenAPI>[0], {
|
|
251
1301
|
title: `${basename(cwd)} API`,
|
|
252
1302
|
version: "1.0.0",
|
|
253
1303
|
})
|
|
254
1304
|
const extension = options.language === "python" ? "py" : "go"
|
|
255
1305
|
const out = resolve(cwd, options.out ?? `nifra_sdk.${extension}`)
|
|
256
|
-
await Bun.write(out, renderSdk(document, options.language))
|
|
1306
|
+
await Bun.write(out, renderSdk(document, options.language, options))
|
|
257
1307
|
console.log(
|
|
258
|
-
|
|
1308
|
+
"[nifra] wrote " +
|
|
1309
|
+
options.language +
|
|
1310
|
+
" SDK with " +
|
|
1311
|
+
Object.keys(document.paths).length +
|
|
1312
|
+
" path" +
|
|
1313
|
+
(Object.keys(document.paths).length === 1 ? "" : "s") +
|
|
1314
|
+
" to " +
|
|
1315
|
+
out,
|
|
259
1316
|
)
|
|
260
1317
|
}
|