@nifrajs/cli 2.9.0 → 2.10.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 +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +35 -3
- package/dist/cli.js.map +1 -1
- package/dist/introspect.d.ts +26 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +85 -1
- package/dist/introspect.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/sdk.d.ts +10 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +229 -0
- package/dist/sdk.js.map +1 -0
- package/docs/llms-full.txt +47 -5
- package/docs/types.json +639 -13
- package/package.json +9 -9
- package/src/cli.ts +33 -3
- package/src/introspect.ts +118 -4
- package/src/mcp-http.ts +1 -1
- package/src/sdk.ts +260 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"description": "The nifra CLI - zero-config dev/build/start for a nifra app (`nifra dev`, `nifra build`, `nifra start`).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"access": "public"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@nifrajs/client": "2.
|
|
45
|
-
"@nifrajs/core": "2.
|
|
46
|
-
"@nifrajs/mcp": "2.
|
|
47
|
-
"@nifrajs/runner": "2.
|
|
48
|
-
"@nifrajs/schema": "2.
|
|
49
|
-
"@nifrajs/testing": "2.
|
|
50
|
-
"@nifrajs/web": "2.
|
|
51
|
-
"create-nifra": "2.
|
|
44
|
+
"@nifrajs/client": "2.10.0",
|
|
45
|
+
"@nifrajs/core": "2.10.0",
|
|
46
|
+
"@nifrajs/mcp": "2.10.0",
|
|
47
|
+
"@nifrajs/runner": "2.10.0",
|
|
48
|
+
"@nifrajs/schema": "2.10.0",
|
|
49
|
+
"@nifrajs/testing": "2.10.0",
|
|
50
|
+
"@nifrajs/web": "2.10.0",
|
|
51
|
+
"create-nifra": "2.10.0"
|
|
52
52
|
},
|
|
53
53
|
"repository": {
|
|
54
54
|
"type": "git",
|
package/src/cli.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { inProcessClient } from "@nifrajs/client"
|
|
|
15
15
|
import { createWebApp, DEFAULT_DEV_PORT, type RenderAdapter } from "@nifrajs/web"
|
|
16
16
|
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
17
17
|
import type { BunPlugin } from "bun"
|
|
18
|
-
import { describeProject, describeRoutes } from "./introspect.ts"
|
|
18
|
+
import { describeProject, describeRouteGraph, describeRoutes } from "./introspect.ts"
|
|
19
19
|
import { type LoadedApp, loadApp } from "./load.ts"
|
|
20
20
|
import { chooseBuildPipeline } from "./pipeline-guard.ts"
|
|
21
21
|
|
|
@@ -67,6 +67,8 @@ Usage:
|
|
|
67
67
|
routes (routes/) + the in-process backend's API routes,
|
|
68
68
|
marking which API routes are auto-mounted under apiPrefix.
|
|
69
69
|
--json for agents (see POST /api/x is/isn't served, not via 405).
|
|
70
|
+
nifra routes --graph [--json] Render the public route surface as a Mermaid graph, or as
|
|
71
|
+
stable JSON for contract explorers.
|
|
70
72
|
nifra routes --modes [--target <t>] Show each page route's RENDER MODE (static | isr | ssr),
|
|
71
73
|
hydration, and cache policy in one table. With --target, gate
|
|
72
74
|
against it: a route the target can't honour (an ssr route on a
|
|
@@ -105,6 +107,8 @@ Usage:
|
|
|
105
107
|
request field or a removed response field breaks; widening a
|
|
106
108
|
request enum or adding a response field doesn't) and fails
|
|
107
109
|
closed. Exits non-zero on any breaking change - run it in CI.
|
|
110
|
+
nifra sdk --lang <python|go> [--out <file>]
|
|
111
|
+
Generate a deterministic non-TypeScript SDK from backend.ts.
|
|
108
112
|
nifra assure [--config <file>] [--json] Route-assurance gate: load nifra.assurance.ts, classify every
|
|
109
113
|
reflected backend route, and fail when required enforcement
|
|
110
114
|
evidence is missing/forbidden or a route is unclassified.
|
|
@@ -148,7 +152,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
|
|
|
148
152
|
\`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
|
|
149
153
|
|
|
150
154
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
151
|
-
const CLI_VERSION = "2.
|
|
155
|
+
const CLI_VERSION = "2.10.0"
|
|
152
156
|
|
|
153
157
|
// A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
|
|
154
158
|
const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
|
|
@@ -619,6 +623,30 @@ async function main(): Promise<void> {
|
|
|
619
623
|
}
|
|
620
624
|
return
|
|
621
625
|
}
|
|
626
|
+
if (command === "sdk") {
|
|
627
|
+
const languageIndex = argv.indexOf("--lang")
|
|
628
|
+
const language = languageIndex === -1 ? undefined : argv[languageIndex + 1]
|
|
629
|
+
const outIndex = argv.indexOf("--out")
|
|
630
|
+
const out = outIndex === -1 ? undefined : argv[outIndex + 1]
|
|
631
|
+
if (language !== "python" && language !== "go") {
|
|
632
|
+
console.error("[nifra] sdk needs --lang python or --lang go")
|
|
633
|
+
process.exitCode = 1
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
if (outIndex !== -1 && (out === undefined || out.startsWith("-"))) {
|
|
637
|
+
console.error("[nifra] --out needs a file path")
|
|
638
|
+
process.exitCode = 1
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
const { runSdk } = await import("./sdk.ts")
|
|
643
|
+
await runSdk(process.cwd(), { language, ...(out !== undefined ? { out } : {}) })
|
|
644
|
+
} catch (err) {
|
|
645
|
+
console.error(formatCliError(err))
|
|
646
|
+
process.exitCode = 1
|
|
647
|
+
}
|
|
648
|
+
return
|
|
649
|
+
}
|
|
622
650
|
// Route assurance intentionally loads only its explicit config (which imports the backend it audits),
|
|
623
651
|
// not the full SSR app. Like snapshot/diff, this works for API-only projects.
|
|
624
652
|
if (command === "assure") {
|
|
@@ -823,7 +851,9 @@ async function main(): Promise<void> {
|
|
|
823
851
|
else if (command === "build") await buildForTarget(app, flags.target, flags)
|
|
824
852
|
else if (command === "context") console.log(describeProject(app))
|
|
825
853
|
else if (command === "routes") {
|
|
826
|
-
if (argv.includes("--
|
|
854
|
+
if (argv.includes("--graph")) {
|
|
855
|
+
console.log(await describeRouteGraph(app, { json: argv.includes("--json") }))
|
|
856
|
+
} else if (argv.includes("--modes")) {
|
|
827
857
|
// `--modes` answers a different question than the default table: not "what URLs exist" but "how
|
|
828
858
|
// does each render, and can the target I'm about to deploy to actually honour that". Resolves
|
|
829
859
|
// against `--target` when given, so a `static` build's server-needing route fails here, in one
|
package/src/introspect.ts
CHANGED
|
@@ -430,6 +430,106 @@ export function routeTableToJson(rows: readonly RouteTableEntry[]): {
|
|
|
430
430
|
}
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
export interface RouteGraphNode {
|
|
434
|
+
readonly id: string
|
|
435
|
+
readonly kind: "root" | RouteKind
|
|
436
|
+
readonly path: string
|
|
437
|
+
readonly methods?: readonly string[]
|
|
438
|
+
readonly file?: string
|
|
439
|
+
readonly autoMounted?: boolean
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export interface RouteGraphEdge {
|
|
443
|
+
readonly from: string
|
|
444
|
+
readonly to: string
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export interface RouteGraph {
|
|
448
|
+
readonly nodes: readonly RouteGraphNode[]
|
|
449
|
+
readonly edges: readonly RouteGraphEdge[]
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function parentPath(path: string): string | undefined {
|
|
453
|
+
if (path === "/") return undefined
|
|
454
|
+
const trimmed = path.replace(/\/$/, "")
|
|
455
|
+
const slash = trimmed.lastIndexOf("/")
|
|
456
|
+
return slash <= 0 ? "/" : trimmed.slice(0, slash)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Build a stable parent/child graph from the public route table. Pure and topology-neutral. */
|
|
460
|
+
export function buildRouteGraph(rows: readonly RouteTableEntry[]): RouteGraph {
|
|
461
|
+
const sorted = [...rows].sort(
|
|
462
|
+
(a, b) => a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind),
|
|
463
|
+
)
|
|
464
|
+
const nodes: RouteGraphNode[] = [
|
|
465
|
+
{ id: "root", kind: "root", path: "/" },
|
|
466
|
+
...sorted.map((row) => ({
|
|
467
|
+
id: `${row.kind}:${row.path}`,
|
|
468
|
+
kind: row.kind,
|
|
469
|
+
path: row.path,
|
|
470
|
+
methods: row.methods,
|
|
471
|
+
...(row.file === undefined ? {} : { file: row.file }),
|
|
472
|
+
...(row.kind === "api" ? { autoMounted: row.autoMounted === true } : {}),
|
|
473
|
+
})),
|
|
474
|
+
]
|
|
475
|
+
const nodeIds = new Set(nodes.map((node) => node.id))
|
|
476
|
+
const edges: RouteGraphEdge[] = []
|
|
477
|
+
for (const row of sorted) {
|
|
478
|
+
let parent = parentPath(row.path)
|
|
479
|
+
let from = "root"
|
|
480
|
+
while (parent !== undefined) {
|
|
481
|
+
const candidate = `${row.kind}:${parent}`
|
|
482
|
+
if (nodeIds.has(candidate)) {
|
|
483
|
+
from = candidate
|
|
484
|
+
break
|
|
485
|
+
}
|
|
486
|
+
parent = parentPath(parent)
|
|
487
|
+
}
|
|
488
|
+
edges.push({ from, to: `${row.kind}:${row.path}` })
|
|
489
|
+
}
|
|
490
|
+
return { nodes, edges }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Stable JSON shape for agents and contract explorers. */
|
|
494
|
+
export function routeGraphToJson(graph: RouteGraph): RouteGraph {
|
|
495
|
+
return {
|
|
496
|
+
nodes: graph.nodes.map((node) => ({
|
|
497
|
+
...node,
|
|
498
|
+
...(node.methods === undefined ? {} : { methods: [...node.methods] }),
|
|
499
|
+
})),
|
|
500
|
+
edges: graph.edges.map((edge) => ({ ...edge })),
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function mermaidText(value: string): string {
|
|
505
|
+
return value
|
|
506
|
+
.replaceAll("&", "&")
|
|
507
|
+
.replaceAll('"', """)
|
|
508
|
+
.replaceAll("<", "<")
|
|
509
|
+
.replaceAll(">", ">")
|
|
510
|
+
.replaceAll(/[\r\n]/g, " ")
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function mermaidLabel(node: RouteGraphNode): string {
|
|
514
|
+
if (node.kind === "root") return "routes"
|
|
515
|
+
const methods = node.methods?.join(", ") ?? ""
|
|
516
|
+
const mounted = node.autoMounted === true ? " (auto-mounted)" : ""
|
|
517
|
+
return mermaidText(`${node.kind}: ${methods} ${node.path}${mounted}`)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Render a route graph as Mermaid for terminals, docs, and agent-facing previews. */
|
|
521
|
+
export function renderRouteGraph(graph: RouteGraph): string {
|
|
522
|
+
const ids = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`]))
|
|
523
|
+
const lines = ["flowchart TD"]
|
|
524
|
+
for (const node of graph.nodes) {
|
|
525
|
+
lines.push(` ${ids.get(node.id)}["${mermaidLabel(node)}"]`)
|
|
526
|
+
}
|
|
527
|
+
for (const edge of graph.edges) {
|
|
528
|
+
lines.push(` ${ids.get(edge.from)} --> ${ids.get(edge.to)}`)
|
|
529
|
+
}
|
|
530
|
+
return lines.join("\n")
|
|
531
|
+
}
|
|
532
|
+
|
|
433
533
|
/** A route module that may export an `action` (→ the page serves POST). */
|
|
434
534
|
interface MaybeActionModule {
|
|
435
535
|
readonly action?: unknown
|
|
@@ -441,10 +541,7 @@ interface MaybeActionModule {
|
|
|
441
541
|
* text (default) or JSON (`--json`). The page-route load mirrors what SSR would do; a load failure
|
|
442
542
|
* degrades to GET-only for that route (never throws the whole command).
|
|
443
543
|
*/
|
|
444
|
-
|
|
445
|
-
app: LoadedApp,
|
|
446
|
-
opts: { readonly json?: boolean } = {},
|
|
447
|
-
): Promise<string> {
|
|
544
|
+
async function collectRouteTable(app: LoadedApp): Promise<RouteTableEntry[]> {
|
|
448
545
|
let manifest: Manifest | undefined
|
|
449
546
|
try {
|
|
450
547
|
manifest = discoverRoutes(app.routesDir)
|
|
@@ -463,5 +560,22 @@ export async function describeRoutes(
|
|
|
463
560
|
pages.push({ pattern: route.pattern, file: route.file, hasAction })
|
|
464
561
|
}
|
|
465
562
|
const rows = buildRouteTable({ pages, api: backendRoutes(app.backend) })
|
|
563
|
+
return rows
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export async function describeRoutes(
|
|
567
|
+
app: LoadedApp,
|
|
568
|
+
opts: { readonly json?: boolean } = {},
|
|
569
|
+
): Promise<string> {
|
|
570
|
+
const rows = await collectRouteTable(app)
|
|
466
571
|
return opts.json ? JSON.stringify(routeTableToJson(rows), null, 2) : renderRouteTable(rows)
|
|
467
572
|
}
|
|
573
|
+
|
|
574
|
+
/** Gather and render the public route graph for `nifra routes --graph`. */
|
|
575
|
+
export async function describeRouteGraph(
|
|
576
|
+
app: LoadedApp,
|
|
577
|
+
opts: { readonly json?: boolean } = {},
|
|
578
|
+
): Promise<string> {
|
|
579
|
+
const graph = buildRouteGraph(await collectRouteTable(app))
|
|
580
|
+
return opts.json ? JSON.stringify(routeGraphToJson(graph), null, 2) : renderRouteGraph(graph)
|
|
581
|
+
}
|
package/src/mcp-http.ts
CHANGED
|
@@ -29,7 +29,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
|
|
|
29
29
|
export type { TypeEntry } from "./types-search.ts"
|
|
30
30
|
|
|
31
31
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
32
|
-
const VERSION = "2.
|
|
32
|
+
const VERSION = "2.10.0"
|
|
33
33
|
const SERVER_INFO = { name: "nifra-docs", version: VERSION }
|
|
34
34
|
// Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
|
|
35
35
|
const docsHealth = (tools: McpTool[]): string =>
|
package/src/sdk.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/** Deterministic, dependency-free SDK generation from the backend's OpenAPI contract. */
|
|
2
|
+
import { existsSync } from "node:fs"
|
|
3
|
+
import { basename, resolve } from "node:path"
|
|
4
|
+
import { type OpenAPIDocument, toOpenAPI } from "@nifrajs/schema/openapi"
|
|
5
|
+
|
|
6
|
+
export type SdkLanguage = "python" | "go"
|
|
7
|
+
|
|
8
|
+
interface OperationLike {
|
|
9
|
+
readonly operationId?: string
|
|
10
|
+
readonly parameters?: readonly {
|
|
11
|
+
readonly name: string
|
|
12
|
+
readonly in: "path" | "query"
|
|
13
|
+
readonly required: boolean
|
|
14
|
+
}[]
|
|
15
|
+
readonly requestBody?: unknown
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] as const
|
|
19
|
+
|
|
20
|
+
function operations(
|
|
21
|
+
document: OpenAPIDocument,
|
|
22
|
+
): readonly { method: string; path: string; operation: OperationLike }[] {
|
|
23
|
+
const result: { method: string; path: string; operation: OperationLike }[] = []
|
|
24
|
+
for (const [path, item] of Object.entries(document.paths).sort(([a], [b]) =>
|
|
25
|
+
a.localeCompare(b),
|
|
26
|
+
)) {
|
|
27
|
+
for (const method of HTTP_METHODS) {
|
|
28
|
+
const operation = (item as Record<string, unknown>)[method]
|
|
29
|
+
if (operation !== null && typeof operation === "object") {
|
|
30
|
+
result.push({ method, path, operation: operation as OperationLike })
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return result
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function identifier(value: string, fallback: string): string {
|
|
38
|
+
const startsWithDigit = /^\d/.test(value)
|
|
39
|
+
const normalized = value
|
|
40
|
+
.replace(/[^A-Za-z0-9_]+/g, "_")
|
|
41
|
+
.replace(/_+/g, "_")
|
|
42
|
+
.replace(/^_+|_+$/g, "")
|
|
43
|
+
const safe = startsWithDigit ? `_${normalized}` : normalized
|
|
44
|
+
return safe === "" || safe === "_" ? fallback : safe
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function operationName(operation: OperationLike, method: string, path: string): string {
|
|
48
|
+
return identifier(operation.operationId ?? `${method}_${path}`, `${method}_request`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function uniqueNames(items: readonly string[]): readonly string[] {
|
|
52
|
+
const counts = new Map<string, number>()
|
|
53
|
+
return items.map((item) => {
|
|
54
|
+
const count = (counts.get(item) ?? 0) + 1
|
|
55
|
+
counts.set(item, count)
|
|
56
|
+
return count === 1 ? item : `${item}_${count}`
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pathParameters(path: string): readonly string[] {
|
|
61
|
+
return [...path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]!).filter(Boolean)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function pythonSdk(document: OpenAPIDocument): string {
|
|
65
|
+
const entries = operations(document)
|
|
66
|
+
const names = uniqueNames(
|
|
67
|
+
entries.map(({ method, path, operation }) => operationName(operation, method, path)),
|
|
68
|
+
)
|
|
69
|
+
const methods: string[] = []
|
|
70
|
+
entries.forEach(({ method, path, operation }, index) => {
|
|
71
|
+
const name = names[index]!
|
|
72
|
+
const pathParams = pathParameters(path)
|
|
73
|
+
const queryParams = (operation.parameters ?? []).filter((parameter) => parameter.in === "query")
|
|
74
|
+
const used = new Set(["self", "body", ...pathParams])
|
|
75
|
+
const queryNames = queryParams.map((parameter) => {
|
|
76
|
+
let value = identifier(parameter.name, "query")
|
|
77
|
+
while (used.has(value)) value += "_"
|
|
78
|
+
used.add(value)
|
|
79
|
+
return value
|
|
80
|
+
})
|
|
81
|
+
const args = pathParams.map((parameter) => `${identifier(parameter, "param")}: Any`)
|
|
82
|
+
const keywordArgs = queryParams.map((parameter, queryIndex) => {
|
|
83
|
+
const required = parameter.required ? "" : " = None"
|
|
84
|
+
return `${queryNames[queryIndex]!}: Any${required}`
|
|
85
|
+
})
|
|
86
|
+
if (operation.requestBody !== undefined) keywordArgs.push("body: Any = None")
|
|
87
|
+
const signature = [...args, ...(keywordArgs.length > 0 ? ["*", ...keywordArgs] : [])].join(", ")
|
|
88
|
+
const query =
|
|
89
|
+
queryNames.length === 0
|
|
90
|
+
? "None"
|
|
91
|
+
: `{${queryNames.map((value) => `${JSON.stringify(value)}: ${value}`).join(", ")}}`
|
|
92
|
+
const paths =
|
|
93
|
+
pathParams.length === 0
|
|
94
|
+
? "None"
|
|
95
|
+
: `{${pathParams.map((value) => `${JSON.stringify(value)}: ${identifier(value, "param")}`).join(", ")}}`
|
|
96
|
+
const body = operation.requestBody === undefined ? "None" : "body"
|
|
97
|
+
methods.push(
|
|
98
|
+
` def ${name}(self${signature ? `, ${signature}` : ""}) -> Any:\n` +
|
|
99
|
+
` return self._request(${JSON.stringify(method.toUpperCase())}, ${JSON.stringify(path)}, path_params=${paths}, query=${query}, body=${body})`,
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
return `# Generated by nifra. Regenerate with: nifra sdk --lang python
|
|
103
|
+
from __future__ import annotations
|
|
104
|
+
|
|
105
|
+
import json
|
|
106
|
+
import urllib.error
|
|
107
|
+
import urllib.parse
|
|
108
|
+
import urllib.request
|
|
109
|
+
from typing import Any, Mapping
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class NifraApiError(RuntimeError):
|
|
113
|
+
def __init__(self, status: int, body: Any):
|
|
114
|
+
super().__init__(f"Nifra API request failed with HTTP {status}")
|
|
115
|
+
self.status = status
|
|
116
|
+
self.body = body
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Client:
|
|
120
|
+
def __init__(self, base_url: str, headers: Mapping[str, str] | None = None, timeout: float = 30.0):
|
|
121
|
+
self.base_url = base_url.rstrip("/")
|
|
122
|
+
self.headers = dict(headers or {})
|
|
123
|
+
self.timeout = timeout
|
|
124
|
+
|
|
125
|
+
def _request(self, method: str, path: str, *, path_params: Mapping[str, Any] | None, query: Mapping[str, Any] | None, body: Any) -> Any:
|
|
126
|
+
route = path
|
|
127
|
+
for key, value in (path_params or {}).items():
|
|
128
|
+
route = route.replace("{" + key + "}", urllib.parse.quote(str(value), safe=""))
|
|
129
|
+
params = [(key, value) for key, value in (query or {}).items() if value is not None]
|
|
130
|
+
url = self.base_url + route
|
|
131
|
+
if params:
|
|
132
|
+
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params, doseq=True)
|
|
133
|
+
payload = None if body is None else json.dumps(body).encode("utf-8")
|
|
134
|
+
request = urllib.request.Request(url, data=payload, method=method, headers=self.headers)
|
|
135
|
+
if payload is not None:
|
|
136
|
+
request.add_header("Content-Type", "application/json")
|
|
137
|
+
try:
|
|
138
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
139
|
+
raw = response.read()
|
|
140
|
+
return json.loads(raw) if raw else None
|
|
141
|
+
except urllib.error.HTTPError as error:
|
|
142
|
+
raw = error.read()
|
|
143
|
+
try:
|
|
144
|
+
value = json.loads(raw) if raw else None
|
|
145
|
+
except json.JSONDecodeError:
|
|
146
|
+
value = raw.decode("utf-8", errors="replace")
|
|
147
|
+
raise NifraApiError(error.code, value) from error
|
|
148
|
+
|
|
149
|
+
${methods.length === 0 ? " pass" : methods.join("\n\n")}
|
|
150
|
+
`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function goExportName(value: string): string {
|
|
154
|
+
const name = identifier(value, "Request")
|
|
155
|
+
return name[0]!.toUpperCase() + name.slice(1)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function goSdk(document: OpenAPIDocument): string {
|
|
159
|
+
const entries = operations(document)
|
|
160
|
+
const names = uniqueNames(
|
|
161
|
+
entries.map(({ method, path, operation }) => operationName(operation, method, path)),
|
|
162
|
+
)
|
|
163
|
+
const methods: string[] = []
|
|
164
|
+
entries.forEach(({ method, path }, index) => {
|
|
165
|
+
const pathParams = pathParameters(path)
|
|
166
|
+
const args = pathParams.map((parameter) => `${identifier(parameter, "param")} string`)
|
|
167
|
+
args.push("query url.Values", "body any")
|
|
168
|
+
const pathMap =
|
|
169
|
+
pathParams.length === 0
|
|
170
|
+
? "nil"
|
|
171
|
+
: `map[string]string{${pathParams.map((value) => `${JSON.stringify(value)}: ${identifier(value, "param")}`).join(", ")}}`
|
|
172
|
+
methods.push(
|
|
173
|
+
`func (c *Client) ${goExportName(names[index]!)}(${args.join(", ")}) (any, error) {\n` +
|
|
174
|
+
` return c.request(http.Method${method[0]!.toUpperCase()}${method.slice(1)}, ${JSON.stringify(path)}, ${pathMap}, query, body)\n` +
|
|
175
|
+
`}`,
|
|
176
|
+
)
|
|
177
|
+
})
|
|
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
|
+
|
|
191
|
+
type Client struct {
|
|
192
|
+
BaseURL string
|
|
193
|
+
HTTP *http.Client
|
|
194
|
+
Headers http.Header
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
func NewClient(baseURL string) *Client {
|
|
198
|
+
return &Client{BaseURL: strings.TrimRight(baseURL, "/"), HTTP: http.DefaultClient, Headers: make(http.Header)}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
func (c *Client) request(method, path string, pathParams map[string]string, query url.Values, body any) (any, error) {
|
|
202
|
+
route := path
|
|
203
|
+
for key, value := range pathParams {
|
|
204
|
+
route = strings.ReplaceAll(route, "{"+key+"}", url.PathEscape(value))
|
|
205
|
+
}
|
|
206
|
+
target := c.BaseURL + route
|
|
207
|
+
parsed, err := url.Parse(target)
|
|
208
|
+
if err != nil { return nil, err }
|
|
209
|
+
parsed.RawQuery = query.Encode()
|
|
210
|
+
var reader io.Reader
|
|
211
|
+
if body != nil {
|
|
212
|
+
payload, err := json.Marshal(body)
|
|
213
|
+
if err != nil { return nil, err }
|
|
214
|
+
reader = bytes.NewReader(payload)
|
|
215
|
+
}
|
|
216
|
+
request, err := http.NewRequest(method, parsed.String(), reader)
|
|
217
|
+
if err != nil { return nil, err }
|
|
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)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Load `backend.ts`, generate an SDK, and write it to the project. */
|
|
240
|
+
export async function runSdk(
|
|
241
|
+
cwd: string,
|
|
242
|
+
options: { readonly language: SdkLanguage; readonly out?: string },
|
|
243
|
+
): Promise<void> {
|
|
244
|
+
const backendPath = resolve(cwd, "backend.ts")
|
|
245
|
+
if (!existsSync(backendPath)) {
|
|
246
|
+
throw new Error(`[nifra] no backend.ts in ${cwd} - SDK generation needs the API contract.`)
|
|
247
|
+
}
|
|
248
|
+
const backend = ((await import(backendPath)) as { backend?: unknown }).backend
|
|
249
|
+
if (backend === undefined) throw new Error(`[nifra] ${backendPath} does not export \`backend\`.`)
|
|
250
|
+
const document = toOpenAPI(backend as Parameters<typeof toOpenAPI>[0], {
|
|
251
|
+
title: `${basename(cwd)} API`,
|
|
252
|
+
version: "1.0.0",
|
|
253
|
+
})
|
|
254
|
+
const extension = options.language === "python" ? "py" : "go"
|
|
255
|
+
const out = resolve(cwd, options.out ?? `nifra_sdk.${extension}`)
|
|
256
|
+
await Bun.write(out, renderSdk(document, options.language))
|
|
257
|
+
console.log(
|
|
258
|
+
`[nifra] wrote ${options.language} SDK with ${Object.keys(document.paths).length} path${Object.keys(document.paths).length === 1 ? "" : "s"} to ${out}`,
|
|
259
|
+
)
|
|
260
|
+
}
|