@nifrajs/cli 3.5.0 → 4.0.2

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.
Files changed (404) hide show
  1. package/README.md +42 -3
  2. package/dist/app-files.d.ts +11 -0
  3. package/dist/app-files.d.ts.map +1 -0
  4. package/dist/app-files.js +34 -0
  5. package/dist/app-files.js.map +1 -0
  6. package/dist/assure-hydration.d.ts +7 -1
  7. package/dist/assure-hydration.d.ts.map +1 -1
  8. package/dist/assure-hydration.js +128 -43
  9. package/dist/assure-hydration.js.map +1 -1
  10. package/dist/assure.d.ts.map +1 -1
  11. package/dist/assure.js +16 -2
  12. package/dist/assure.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts +5 -1
  14. package/dist/capabilities-tool.d.ts.map +1 -1
  15. package/dist/capabilities-tool.js +32 -10
  16. package/dist/capabilities-tool.js.map +1 -1
  17. package/dist/cdn-check.d.ts +41 -0
  18. package/dist/cdn-check.d.ts.map +1 -0
  19. package/dist/cdn-check.js +207 -0
  20. package/dist/cdn-check.js.map +1 -0
  21. package/dist/check-diagnostics.d.ts +4 -0
  22. package/dist/check-diagnostics.d.ts.map +1 -1
  23. package/dist/check-diagnostics.js +13 -0
  24. package/dist/check-diagnostics.js.map +1 -1
  25. package/dist/check-scan.d.ts +49 -7
  26. package/dist/check-scan.d.ts.map +1 -1
  27. package/dist/check-scan.js +291 -62
  28. package/dist/check-scan.js.map +1 -1
  29. package/dist/check.d.ts.map +1 -1
  30. package/dist/check.js +99 -63
  31. package/dist/check.js.map +1 -1
  32. package/dist/cli.d.ts +42 -16
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +262 -82
  35. package/dist/cli.js.map +1 -1
  36. package/dist/command-catalog.d.ts +62 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +275 -35
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/config-target.d.ts +8 -0
  41. package/dist/config-target.d.ts.map +1 -0
  42. package/dist/config-target.js +45 -0
  43. package/dist/config-target.js.map +1 -0
  44. package/dist/contracts.d.ts.map +1 -1
  45. package/dist/contracts.js +52 -13
  46. package/dist/contracts.js.map +1 -1
  47. package/dist/db-audit.d.ts +37 -0
  48. package/dist/db-audit.d.ts.map +1 -0
  49. package/dist/db-audit.js +99 -0
  50. package/dist/db-audit.js.map +1 -0
  51. package/dist/db-child.d.ts +64 -0
  52. package/dist/db-child.d.ts.map +1 -0
  53. package/dist/db-child.js +213 -0
  54. package/dist/db-child.js.map +1 -0
  55. package/dist/db-config.d.ts +60 -0
  56. package/dist/db-config.d.ts.map +1 -0
  57. package/dist/db-config.js +176 -0
  58. package/dist/db-config.js.map +1 -0
  59. package/dist/db-tool.d.ts +84 -0
  60. package/dist/db-tool.d.ts.map +1 -0
  61. package/dist/db-tool.js +537 -0
  62. package/dist/db-tool.js.map +1 -0
  63. package/dist/dev-bun-config.d.ts +20 -13
  64. package/dist/dev-bun-config.d.ts.map +1 -1
  65. package/dist/dev-bun-config.js +98 -41
  66. package/dist/dev-bun-config.js.map +1 -1
  67. package/dist/dev-feed-tool.d.ts +106 -0
  68. package/dist/dev-feed-tool.d.ts.map +1 -0
  69. package/dist/dev-feed-tool.js +713 -0
  70. package/dist/dev-feed-tool.js.map +1 -0
  71. package/dist/dev-server-client.d.ts +67 -0
  72. package/dist/dev-server-client.d.ts.map +1 -0
  73. package/dist/dev-server-client.js +195 -0
  74. package/dist/dev-server-client.js.map +1 -0
  75. package/dist/diff-tool.d.ts +3 -3
  76. package/dist/diff-tool.d.ts.map +1 -1
  77. package/dist/diff-tool.js +6 -5
  78. package/dist/diff-tool.js.map +1 -1
  79. package/dist/docs-search.d.ts.map +1 -1
  80. package/dist/docs-search.js +12 -4
  81. package/dist/docs-search.js.map +1 -1
  82. package/dist/doctor.d.ts +17 -0
  83. package/dist/doctor.d.ts.map +1 -1
  84. package/dist/doctor.js +76 -15
  85. package/dist/doctor.js.map +1 -1
  86. package/dist/env-file.d.ts +15 -0
  87. package/dist/env-file.d.ts.map +1 -1
  88. package/dist/env-file.js +73 -1
  89. package/dist/env-file.js.map +1 -1
  90. package/dist/fix-recipes.d.ts.map +1 -1
  91. package/dist/fix-recipes.js +48 -22
  92. package/dist/fix-recipes.js.map +1 -1
  93. package/dist/frontend-guidance.js +5 -5
  94. package/dist/frontend-guidance.js.map +1 -1
  95. package/dist/i18n-check.d.ts +16 -0
  96. package/dist/i18n-check.d.ts.map +1 -0
  97. package/dist/i18n-check.js +154 -0
  98. package/dist/i18n-check.js.map +1 -0
  99. package/dist/init-agents.d.ts +49 -13
  100. package/dist/init-agents.d.ts.map +1 -1
  101. package/dist/init-agents.js +239 -34
  102. package/dist/init-agents.js.map +1 -1
  103. package/dist/internal/code-unit-order.d.ts +4 -0
  104. package/dist/internal/code-unit-order.d.ts.map +1 -0
  105. package/dist/internal/code-unit-order.js +6 -0
  106. package/dist/internal/code-unit-order.js.map +1 -0
  107. package/dist/internal/script-kind.d.ts +7 -0
  108. package/dist/internal/script-kind.d.ts.map +1 -0
  109. package/dist/internal/script-kind.js +12 -0
  110. package/dist/internal/script-kind.js.map +1 -0
  111. package/dist/internal/source-facts.d.ts +5 -0
  112. package/dist/internal/source-facts.d.ts.map +1 -1
  113. package/dist/internal/source-facts.js +61 -5
  114. package/dist/internal/source-facts.js.map +1 -1
  115. package/dist/internal/typescript-import.d.ts +49 -24
  116. package/dist/internal/typescript-import.d.ts.map +1 -1
  117. package/dist/internal/typescript-import.js +507 -67
  118. package/dist/internal/typescript-import.js.map +1 -1
  119. package/dist/introspect.d.ts +5 -1
  120. package/dist/introspect.d.ts.map +1 -1
  121. package/dist/introspect.js +92 -27
  122. package/dist/introspect.js.map +1 -1
  123. package/dist/learn.js +3 -3
  124. package/dist/learn.js.map +1 -1
  125. package/dist/levels-tool.d.ts +10 -0
  126. package/dist/levels-tool.d.ts.map +1 -1
  127. package/dist/levels-tool.js +33 -3
  128. package/dist/levels-tool.js.map +1 -1
  129. package/dist/load.d.ts +67 -17
  130. package/dist/load.d.ts.map +1 -1
  131. package/dist/load.js +78 -18
  132. package/dist/load.js.map +1 -1
  133. package/dist/manifest-tool.d.ts.map +1 -1
  134. package/dist/manifest-tool.js +3 -2
  135. package/dist/manifest-tool.js.map +1 -1
  136. package/dist/mcp-app-tool.d.ts.map +1 -1
  137. package/dist/mcp-app-tool.js +2 -0
  138. package/dist/mcp-app-tool.js.map +1 -1
  139. package/dist/mcp-context.js +1 -1
  140. package/dist/mcp-context.js.map +1 -1
  141. package/dist/mcp-delegate.d.ts +57 -0
  142. package/dist/mcp-delegate.d.ts.map +1 -0
  143. package/dist/mcp-delegate.js +130 -0
  144. package/dist/mcp-delegate.js.map +1 -0
  145. package/dist/mcp-docs-tools.d.ts.map +1 -1
  146. package/dist/mcp-docs-tools.js +23 -5
  147. package/dist/mcp-docs-tools.js.map +1 -1
  148. package/dist/mcp-exec.d.ts +2 -0
  149. package/dist/mcp-exec.d.ts.map +1 -1
  150. package/dist/mcp-exec.js +132 -108
  151. package/dist/mcp-exec.js.map +1 -1
  152. package/dist/mcp-http.d.ts.map +1 -1
  153. package/dist/mcp-http.js +17 -5
  154. package/dist/mcp-http.js.map +1 -1
  155. package/dist/mcp-io.d.ts +14 -0
  156. package/dist/mcp-io.d.ts.map +1 -1
  157. package/dist/mcp-io.js +45 -0
  158. package/dist/mcp-io.js.map +1 -1
  159. package/dist/mcp-isolate.d.ts +48 -0
  160. package/dist/mcp-isolate.d.ts.map +1 -0
  161. package/dist/mcp-isolate.js +222 -0
  162. package/dist/mcp-isolate.js.map +1 -0
  163. package/dist/mcp-project-child.d.ts +74 -0
  164. package/dist/mcp-project-child.d.ts.map +1 -0
  165. package/dist/mcp-project-child.js +130 -0
  166. package/dist/mcp-project-child.js.map +1 -0
  167. package/dist/mcp-render.js +2 -2
  168. package/dist/mcp-render.js.map +1 -1
  169. package/dist/mcp-root.d.ts +37 -9
  170. package/dist/mcp-root.d.ts.map +1 -1
  171. package/dist/mcp-root.js +110 -23
  172. package/dist/mcp-root.js.map +1 -1
  173. package/dist/mcp-run.d.ts +18 -1
  174. package/dist/mcp-run.d.ts.map +1 -1
  175. package/dist/mcp-run.js +82 -9
  176. package/dist/mcp-run.js.map +1 -1
  177. package/dist/mcp-ws.js +4 -1
  178. package/dist/mcp-ws.js.map +1 -1
  179. package/dist/mcp.d.ts +16 -3
  180. package/dist/mcp.d.ts.map +1 -1
  181. package/dist/mcp.js +81 -53
  182. package/dist/mcp.js.map +1 -1
  183. package/dist/migrate-layout.d.ts +44 -0
  184. package/dist/migrate-layout.d.ts.map +1 -0
  185. package/dist/migrate-layout.js +1155 -0
  186. package/dist/migrate-layout.js.map +1 -0
  187. package/dist/openapi-tool.d.ts +2 -2
  188. package/dist/openapi-tool.d.ts.map +1 -1
  189. package/dist/openapi-tool.js.map +1 -1
  190. package/dist/openapi-types.d.ts +2 -1
  191. package/dist/openapi-types.d.ts.map +1 -1
  192. package/dist/openapi-types.js +240 -12
  193. package/dist/openapi-types.js.map +1 -1
  194. package/dist/pipeline-report.d.ts +3 -2
  195. package/dist/pipeline-report.d.ts.map +1 -1
  196. package/dist/pipeline-report.js +19 -16
  197. package/dist/pipeline-report.js.map +1 -1
  198. package/dist/port.d.ts +5 -4
  199. package/dist/port.d.ts.map +1 -1
  200. package/dist/port.js +46 -29
  201. package/dist/port.js.map +1 -1
  202. package/dist/project-facts.d.ts +5 -0
  203. package/dist/project-facts.d.ts.map +1 -1
  204. package/dist/project-facts.js.map +1 -1
  205. package/dist/recipes/4.0.0.d.ts +9 -0
  206. package/dist/recipes/4.0.0.d.ts.map +1 -0
  207. package/dist/recipes/4.0.0.js +36 -0
  208. package/dist/recipes/4.0.0.js.map +1 -0
  209. package/dist/recipes/index.d.ts.map +1 -1
  210. package/dist/recipes/index.js +2 -0
  211. package/dist/recipes/index.js.map +1 -1
  212. package/dist/release-verification.d.ts +11 -1
  213. package/dist/release-verification.d.ts.map +1 -1
  214. package/dist/release-verification.js +47 -4
  215. package/dist/release-verification.js.map +1 -1
  216. package/dist/review.d.ts.map +1 -1
  217. package/dist/review.js +12 -8
  218. package/dist/review.js.map +1 -1
  219. package/dist/route-types.d.ts +28 -0
  220. package/dist/route-types.d.ts.map +1 -0
  221. package/dist/route-types.js +106 -0
  222. package/dist/route-types.js.map +1 -0
  223. package/dist/rules/codes.d.ts +9 -0
  224. package/dist/rules/codes.d.ts.map +1 -1
  225. package/dist/rules/codes.js +9 -0
  226. package/dist/rules/codes.js.map +1 -1
  227. package/dist/rules/data-guard.d.ts +12 -0
  228. package/dist/rules/data-guard.d.ts.map +1 -0
  229. package/dist/rules/data-guard.js +213 -0
  230. package/dist/rules/data-guard.js.map +1 -0
  231. package/dist/rules/index.d.ts +3 -0
  232. package/dist/rules/index.d.ts.map +1 -1
  233. package/dist/rules/index.js +17 -5
  234. package/dist/rules/index.js.map +1 -1
  235. package/dist/rules/islands.d.ts.map +1 -1
  236. package/dist/rules/islands.js +7 -4
  237. package/dist/rules/islands.js.map +1 -1
  238. package/dist/rules/legacy.d.ts.map +1 -1
  239. package/dist/rules/legacy.js +71 -38
  240. package/dist/rules/legacy.js.map +1 -1
  241. package/dist/rules/nano.d.ts.map +1 -1
  242. package/dist/rules/nano.js +15 -6
  243. package/dist/rules/nano.js.map +1 -1
  244. package/dist/rules/pages.d.ts +15 -0
  245. package/dist/rules/pages.d.ts.map +1 -0
  246. package/dist/rules/pages.js +88 -0
  247. package/dist/rules/pages.js.map +1 -0
  248. package/dist/rules/routes.d.ts +11 -0
  249. package/dist/rules/routes.d.ts.map +1 -1
  250. package/dist/rules/routes.js +180 -2
  251. package/dist/rules/routes.js.map +1 -1
  252. package/dist/rules/secrets.d.ts +10 -0
  253. package/dist/rules/secrets.d.ts.map +1 -0
  254. package/dist/rules/secrets.js +169 -0
  255. package/dist/rules/secrets.js.map +1 -0
  256. package/dist/rules/security.d.ts +7 -0
  257. package/dist/rules/security.d.ts.map +1 -1
  258. package/dist/rules/security.js +171 -65
  259. package/dist/rules/security.js.map +1 -1
  260. package/dist/rules/typescript.d.ts +16 -0
  261. package/dist/rules/typescript.d.ts.map +1 -0
  262. package/dist/rules/typescript.js +59 -0
  263. package/dist/rules/typescript.js.map +1 -0
  264. package/dist/rules/zones.d.ts +17 -0
  265. package/dist/rules/zones.d.ts.map +1 -0
  266. package/dist/rules/zones.js +174 -0
  267. package/dist/rules/zones.js.map +1 -0
  268. package/dist/scaffold.d.ts +25 -12
  269. package/dist/scaffold.d.ts.map +1 -1
  270. package/dist/scaffold.js +132 -87
  271. package/dist/scaffold.js.map +1 -1
  272. package/dist/sdk.d.ts +17 -3
  273. package/dist/sdk.d.ts.map +1 -1
  274. package/dist/sdk.js +941 -153
  275. package/dist/sdk.js.map +1 -1
  276. package/dist/search-terms.d.ts +2 -0
  277. package/dist/search-terms.d.ts.map +1 -1
  278. package/dist/search-terms.js +23 -8
  279. package/dist/search-terms.js.map +1 -1
  280. package/dist/smoke.d.ts +79 -0
  281. package/dist/smoke.d.ts.map +1 -0
  282. package/dist/smoke.js +494 -0
  283. package/dist/smoke.js.map +1 -0
  284. package/dist/sync-manifest.d.ts +10 -2
  285. package/dist/sync-manifest.d.ts.map +1 -1
  286. package/dist/sync-manifest.js +44 -19
  287. package/dist/sync-manifest.js.map +1 -1
  288. package/dist/sync-routes.d.ts +4 -3
  289. package/dist/sync-routes.d.ts.map +1 -1
  290. package/dist/sync-routes.js +4 -3
  291. package/dist/sync-routes.js.map +1 -1
  292. package/dist/test-tool.d.ts.map +1 -1
  293. package/dist/test-tool.js +30 -11
  294. package/dist/test-tool.js.map +1 -1
  295. package/dist/types-search.d.ts.map +1 -1
  296. package/dist/types-search.js +11 -5
  297. package/dist/types-search.js.map +1 -1
  298. package/dist/upgrade.d.ts +36 -6
  299. package/dist/upgrade.d.ts.map +1 -1
  300. package/dist/upgrade.js +167 -29
  301. package/dist/upgrade.js.map +1 -1
  302. package/dist/verification-plan.d.ts.map +1 -1
  303. package/dist/verification-plan.js +4 -0
  304. package/dist/verification-plan.js.map +1 -1
  305. package/dist/verification.d.ts.map +1 -1
  306. package/dist/verification.js +45 -12
  307. package/dist/verification.js.map +1 -1
  308. package/dist/web-app-options.d.ts +19 -0
  309. package/dist/web-app-options.d.ts.map +1 -0
  310. package/dist/web-app-options.js +49 -0
  311. package/dist/web-app-options.js.map +1 -0
  312. package/dist/work-graph.d.ts.map +1 -1
  313. package/dist/work-graph.js +29 -13
  314. package/dist/work-graph.js.map +1 -1
  315. package/docs/examples.json +185 -29
  316. package/docs/llms-full.txt +3807 -580
  317. package/docs/types.json +14229 -10939
  318. package/package.json +19 -12
  319. package/src/app-files.ts +40 -0
  320. package/src/assure-hydration.ts +139 -46
  321. package/src/assure.ts +17 -2
  322. package/src/capabilities-tool.ts +35 -9
  323. package/src/cdn-check.ts +268 -0
  324. package/src/check-diagnostics.ts +17 -0
  325. package/src/check-scan.ts +358 -63
  326. package/src/check.ts +97 -62
  327. package/src/cli.ts +296 -87
  328. package/src/command-catalog.ts +389 -48
  329. package/src/config-target.ts +47 -0
  330. package/src/contracts.ts +54 -12
  331. package/src/db-audit.ts +128 -0
  332. package/src/db-child.ts +285 -0
  333. package/src/db-config.ts +224 -0
  334. package/src/db-tool.ts +690 -0
  335. package/src/dev-bun-config.ts +116 -40
  336. package/src/dev-feed-tool.ts +912 -0
  337. package/src/dev-server-client.ts +267 -0
  338. package/src/diff-tool.ts +6 -5
  339. package/src/docs-search.ts +10 -3
  340. package/src/doctor.ts +98 -15
  341. package/src/env-file.ts +78 -1
  342. package/src/fix-recipes.ts +53 -32
  343. package/src/frontend-guidance.ts +5 -5
  344. package/src/i18n-check.ts +179 -0
  345. package/src/init-agents.ts +301 -38
  346. package/src/internal/code-unit-order.ts +5 -0
  347. package/src/internal/script-kind.ts +11 -0
  348. package/src/internal/source-facts.ts +96 -22
  349. package/src/internal/typescript-import.ts +663 -68
  350. package/src/introspect.ts +90 -27
  351. package/src/learn.ts +3 -3
  352. package/src/levels-tool.ts +47 -2
  353. package/src/load.ts +140 -33
  354. package/src/manifest-tool.ts +3 -2
  355. package/src/mcp-app-tool.ts +2 -0
  356. package/src/mcp-context.ts +1 -1
  357. package/src/mcp-delegate.ts +162 -0
  358. package/src/mcp-docs-tools.ts +23 -5
  359. package/src/mcp-exec.ts +160 -132
  360. package/src/mcp-http.ts +19 -5
  361. package/src/mcp-io.ts +57 -0
  362. package/src/mcp-isolate.ts +287 -0
  363. package/src/mcp-project-child.ts +182 -0
  364. package/src/mcp-render.ts +2 -2
  365. package/src/mcp-root.ts +117 -22
  366. package/src/mcp-run.ts +104 -10
  367. package/src/mcp-ws.ts +4 -1
  368. package/src/mcp.ts +106 -64
  369. package/src/migrate-layout.ts +1338 -0
  370. package/src/openapi-tool.ts +7 -3
  371. package/src/openapi-types.ts +315 -13
  372. package/src/pipeline-report.ts +22 -18
  373. package/src/port.ts +53 -30
  374. package/src/project-facts.ts +5 -0
  375. package/src/recipes/4.0.0.ts +37 -0
  376. package/src/recipes/index.ts +2 -0
  377. package/src/release-verification.ts +74 -5
  378. package/src/review.ts +13 -8
  379. package/src/route-types.ts +133 -0
  380. package/src/rules/codes.ts +9 -0
  381. package/src/rules/data-guard.ts +230 -0
  382. package/src/rules/index.ts +18 -5
  383. package/src/rules/islands.ts +12 -4
  384. package/src/rules/legacy.ts +71 -39
  385. package/src/rules/nano.ts +18 -6
  386. package/src/rules/pages.ts +99 -0
  387. package/src/rules/routes.ts +181 -2
  388. package/src/rules/secrets.ts +174 -0
  389. package/src/rules/security.ts +198 -70
  390. package/src/rules/typescript.ts +77 -0
  391. package/src/rules/zones.ts +195 -0
  392. package/src/scaffold.ts +147 -95
  393. package/src/sdk.ts +1215 -158
  394. package/src/search-terms.ts +24 -7
  395. package/src/smoke.ts +607 -0
  396. package/src/sync-manifest.ts +50 -19
  397. package/src/sync-routes.ts +4 -3
  398. package/src/test-tool.ts +32 -11
  399. package/src/types-search.ts +11 -4
  400. package/src/upgrade.ts +205 -25
  401. package/src/verification-plan.ts +13 -0
  402. package/src/verification.ts +50 -12
  403. package/src/web-app-options.ts +57 -0
  404. package/src/work-graph.ts +27 -13
package/src/sdk.ts CHANGED
@@ -1,28 +1,92 @@
1
- /** Deterministic, dependency-free SDK generation from the backend's OpenAPI contract. */
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
- readonly name: string
12
- readonly in: "path" | "query"
13
- readonly required: boolean
14
- }[]
15
- readonly requestBody?: unknown
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
- function operations(
21
- document: OpenAPIDocument,
22
- ): readonly { method: string; path: string; operation: OperationLike }[] {
23
- const result: { method: string; path: string; operation: OperationLike }[] = []
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.localeCompare(b),
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 = startsWithDigit ? `_${normalized}` : normalized
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 pythonSdk(document: OpenAPIDocument): string {
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
- entries.forEach(({ method, path, operation }, index) => {
487
+
488
+ entries.forEach((entry, index) => {
489
+ const { method, path, operation } = entry
71
490
  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")
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 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}`
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
- if (operation.requestBody !== undefined) keywordArgs.push("body: Any = None")
87
- const signature = [...args, ...(keywordArgs.length > 0 ? ["*", ...keywordArgs] : [])].join(", ")
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
- : `{${queryNames.map((value) => `${JSON.stringify(value)}: ${value}`).join(", ")}}`
556
+ : "{" +
557
+ queryNames
558
+ .map(
559
+ (value, queryIndex) =>
560
+ `${JSON.stringify(queryParameters[queryIndex]!.name)}: ${value}`,
561
+ )
562
+ .join(", ") +
563
+ "}"
92
564
  const paths =
93
- pathParams.length === 0
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
- : `{${pathParams.map((value) => `${JSON.stringify(value)}: ${identifier(value, "param")}`).join(", ")}}`
96
- const body = operation.requestBody === undefined ? "None" : "body"
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
- ` 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})`,
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
- 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 {
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
- 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")
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
- pathParams.length === 0
1131
+ pathNames.length === 0
170
1132
  ? "nil"
171
- : `map[string]string{${pathParams.map((value) => `${JSON.stringify(value)}: ${identifier(value, "param")}`).join(", ")}}`
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
- `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
- `}`,
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
- type Client struct {
192
- BaseURL string
193
- HTTP *http.Client
194
- Headers http.Header
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
- func NewClient(baseURL string) *Client {
198
- return &Client{BaseURL: strings.TrimRight(baseURL, "/"), HTTP: http.DefaultClient, Headers: make(http.Header)}
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
- 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))
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
- 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)
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
- 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)
1276
+ }
1277
+ return result
237
1278
  }
238
1279
 
239
- /** Load `backend.ts`, generate an SDK, and write it to the project. */
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, "backend.ts")
1285
+ const backendPath = resolve(cwd, BACKEND_APP_FILE)
245
1286
  if (!existsSync(backendPath)) {
246
- throw new Error(`[nifra] no backend.ts in ${cwd} - SDK generation needs the API contract.`)
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 \`backend\`.`)
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
- `[nifra] wrote ${options.language} SDK with ${Object.keys(document.paths).length} path${Object.keys(document.paths).length === 1 ? "" : "s"} to ${out}`,
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
  }