diff --git a/biome.jsonc b/biome.jsonc index 25f161f98..425386355 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -20,13 +20,15 @@ "!!**/packages/openapi-code-generator/src/core/schemas/openapi-3.0-specification-validator.ts", "!!**/packages/openapi-code-generator/src/core/schemas/openapi-3.1-specification-validator.ts", "!!**/integration-tests/typescript-angular/src/generated/**/*", + "!!**/integration-tests/typescript-nextjs/src/app/**/*", "!!**/integration-tests-definitions/**/*", "!**/schemas/**/*.json", "!**/package.json", "!**/package.json", "!!**/.next", "!!**/.angular", - "!!**/dist" + "!!**/dist", + "!!**/*.svg" ] }, "formatter": { diff --git a/integration-tests/typescript-angular/package.json b/integration-tests/typescript-angular/package.json index 372011fc8..661e69789 100644 --- a/integration-tests/typescript-angular/package.json +++ b/integration-tests/typescript-angular/package.json @@ -16,8 +16,8 @@ "@angular/forms": "catalog:", "@angular/platform-browser": "catalog:", "@angular/router": "catalog:", - "rxjs": "~7.8.0", - "tslib": "^2.3.0" + "rxjs": "~7.8.2", + "tslib": "^2.8.1" }, "devDependencies": { "@angular/build": "catalog:", diff --git a/integration-tests/typescript-nextjs/.gitignore b/integration-tests/typescript-nextjs/.gitignore new file mode 100644 index 000000000..fd3dbb571 --- /dev/null +++ b/integration-tests/typescript-nextjs/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/integration-tests/typescript-nextjs/README.md b/integration-tests/typescript-nextjs/README.md new file mode 100644 index 000000000..c4033664f --- /dev/null +++ b/integration-tests/typescript-nextjs/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/integration-tests/typescript-nextjs/next.config.mjs b/integration-tests/typescript-nextjs/next.config.mjs new file mode 100644 index 000000000..1d6147825 --- /dev/null +++ b/integration-tests/typescript-nextjs/next.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {} + +export default nextConfig diff --git a/integration-tests/typescript-nextjs/package.json b/integration-tests/typescript-nextjs/package.json new file mode 100644 index 000000000..30ed9a85d --- /dev/null +++ b/integration-tests/typescript-nextjs/package.json @@ -0,0 +1,28 @@ +{ + "name": "@integration/typescript-nextjs", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "clean": "rm -rf ./.next && rm -rf ./src/generated && rm -rf -- ./src/app/*/", + "validate": "tsc -p ./tsconfig.json" + }, + "dependencies": { + "@nahkies/typescript-fetch-runtime": "workspace:*", + "@nahkies/typescript-nextjs-runtime": "workspace:*", + "joi": "catalog:", + "next": "catalog:", + "react": "catalog:", + "react-dom":"catalog:", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "^19.2.3", + "typescript": "catalog:typescript6" + } +} diff --git a/integration-tests/typescript-nextjs/public/next.svg b/integration-tests/typescript-nextjs/public/next.svg new file mode 100644 index 000000000..5174b28c5 --- /dev/null +++ b/integration-tests/typescript-nextjs/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/integration-tests/typescript-nextjs/public/vercel.svg b/integration-tests/typescript-nextjs/public/vercel.svg new file mode 100644 index 000000000..d2f842227 --- /dev/null +++ b/integration-tests/typescript-nextjs/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/integration-tests/typescript-nextjs/src/app/favicon.ico b/integration-tests/typescript-nextjs/src/app/favicon.ico new file mode 100644 index 000000000..718d6fea4 Binary files /dev/null and b/integration-tests/typescript-nextjs/src/app/favicon.ico differ diff --git a/integration-tests/typescript-nextjs/src/app/globals.css b/integration-tests/typescript-nextjs/src/app/globals.css new file mode 100644 index 000000000..acf750815 --- /dev/null +++ b/integration-tests/typescript-nextjs/src/app/globals.css @@ -0,0 +1,108 @@ +:root { + --max-width: 1100px; + --border-radius: 12px; + --font-mono: + ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", + "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", + "Fira Mono", "Droid Sans Mono", "Courier New", monospace; + + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 214, 219, 220; + --background-end-rgb: 255, 255, 255; + + --primary-glow: conic-gradient( + from 180deg at 50% 50%, + #16abff33 0deg, + #0885ff33 55deg, + #54d6ff33 120deg, + #0071ff33 160deg, + transparent 360deg + ); + --secondary-glow: radial-gradient( + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 0) + ); + + --tile-start-rgb: 239, 245, 249; + --tile-end-rgb: 228, 232, 233; + --tile-border: conic-gradient( + #00000080, + #00000040, + #00000030, + #00000020, + #00000010, + #00000010, + #00000080 + ); + + --callout-rgb: 238, 240, 241; + --callout-border-rgb: 172, 175, 176; + --card-rgb: 180, 185, 188; + --card-border-rgb: 131, 134, 135; +} + +@media (prefers-color-scheme: dark) { + :root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; + + --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); + --secondary-glow: linear-gradient( + to bottom right, + rgba(1, 65, 255, 0), + rgba(1, 65, 255, 0), + rgba(1, 65, 255, 0.3) + ); + + --tile-start-rgb: 2, 13, 46; + --tile-end-rgb: 2, 5, 19; + --tile-border: conic-gradient( + #ffffff80, + #ffffff40, + #ffffff30, + #ffffff20, + #ffffff10, + #ffffff10, + #ffffff80 + ); + + --callout-rgb: 20, 20, 20; + --callout-border-rgb: 108, 108, 108; + --card-rgb: 100, 100, 100; + --card-border-rgb: 200, 200, 200; + } +} + +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +html, +body { + max-width: 100vw; + overflow-x: hidden; +} + +body { + color: rgb(var(--foreground-rgb)); + background: linear-gradient( + to bottom, + transparent, + rgb(var(--background-end-rgb)) + ) + rgb(var(--background-start-rgb)); +} + +a { + color: inherit; + text-decoration: none; +} + +@media (prefers-color-scheme: dark) { + html { + color-scheme: dark; + } +} diff --git a/integration-tests/typescript-nextjs/src/app/layout.tsx b/integration-tests/typescript-nextjs/src/app/layout.tsx new file mode 100644 index 000000000..24de49cbb --- /dev/null +++ b/integration-tests/typescript-nextjs/src/app/layout.tsx @@ -0,0 +1,22 @@ +import type {Metadata} from "next" +import {Inter} from "next/font/google" +import "./globals.css" + +const inter = Inter({subsets: ["latin"]}) + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + {children} + + ) +} diff --git a/integration-tests/typescript-nextjs/src/app/page.module.css b/integration-tests/typescript-nextjs/src/app/page.module.css new file mode 100644 index 000000000..d979f776c --- /dev/null +++ b/integration-tests/typescript-nextjs/src/app/page.module.css @@ -0,0 +1,232 @@ +.main { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + padding: 6rem; + min-height: 100vh; +} + +.description { + display: inherit; + justify-content: inherit; + align-items: inherit; + font-size: 0.85rem; + max-width: var(--max-width); + width: 100%; + z-index: 2; + font-family: var(--font-mono); +} + +.description a { + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; +} + +.description p { + position: relative; + margin: 0; + padding: 1rem; + background-color: rgba(var(--callout-rgb), 0.5); + border: 1px solid rgba(var(--callout-border-rgb), 0.3); + border-radius: var(--border-radius); +} + +.code { + font-weight: 700; + font-family: var(--font-mono); +} + +.grid { + display: grid; + grid-template-columns: repeat(4, minmax(25%, auto)); + max-width: 100%; + width: var(--max-width); +} + +.card { + padding: 1rem 1.2rem; + border-radius: var(--border-radius); + background: rgba(var(--card-rgb), 0); + border: 1px solid rgba(var(--card-border-rgb), 0); + transition: + background 200ms, + border 200ms; +} + +.card span { + display: inline-block; + transition: transform 200ms; +} + +.card h2 { + font-weight: 600; + margin-bottom: 0.7rem; +} + +.card p { + margin: 0; + opacity: 0.6; + font-size: 0.9rem; + line-height: 1.5; + max-width: 30ch; + text-wrap: balance; +} + +.center { + display: flex; + justify-content: center; + align-items: center; + position: relative; + padding: 4rem 0; +} + +.center::before { + background: var(--secondary-glow); + border-radius: 50%; + width: 480px; + height: 360px; + margin-left: -400px; +} + +.center::after { + background: var(--primary-glow); + width: 240px; + height: 180px; + z-index: -1; +} + +.center::before, +.center::after { + content: ""; + left: 50%; + position: absolute; + filter: blur(45px); + transform: translateZ(0); +} + +.logo { + position: relative; +} +/* Enable hover only on non-touch devices */ +@media (hover: hover) and (pointer: fine) { + .card:hover { + background: rgba(var(--card-rgb), 0.1); + border: 1px solid rgba(var(--card-border-rgb), 0.15); + } + + .card:hover span { + transform: translateX(4px); + } +} + +@media (prefers-reduced-motion) { + .card:hover span { + transform: none; + } +} + +/* Mobile */ +@media (max-width: 700px) { + .content { + padding: 4rem; + } + + .grid { + grid-template-columns: 1fr; + margin-bottom: 120px; + max-width: 320px; + text-align: center; + } + + .card { + padding: 1rem 2.5rem; + } + + .card h2 { + margin-bottom: 0.5rem; + } + + .center { + padding: 8rem 0 6rem; + } + + .center::before { + transform: none; + height: 300px; + } + + .description { + font-size: 0.8rem; + } + + .description a { + padding: 1rem; + } + + .description p, + .description div { + display: flex; + justify-content: center; + position: fixed; + width: 100%; + } + + .description p { + align-items: center; + inset: 0 0 auto; + padding: 2rem 1rem 1.4rem; + border-radius: 0; + border: none; + border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); + background: linear-gradient( + to bottom, + rgba(var(--background-start-rgb), 1), + rgba(var(--callout-rgb), 0.5) + ); + background-clip: padding-box; + backdrop-filter: blur(24px); + } + + .description div { + align-items: flex-end; + pointer-events: none; + inset: auto 0 0; + padding: 2rem; + height: 200px; + background: linear-gradient( + to bottom, + transparent 0%, + rgb(var(--background-end-rgb)) 40% + ); + z-index: 1; + } +} + +/* Tablet and Smaller Desktop */ +@media (min-width: 701px) and (max-width: 1120px) { + .grid { + grid-template-columns: repeat(2, 50%); + } +} + +@media (prefers-color-scheme: dark) { + .vercelLogo { + filter: invert(1); + } + + .logo { + filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70); + } +} + +@keyframes rotate { + from { + transform: rotate(360deg); + } + to { + transform: rotate(0deg); + } +} diff --git a/integration-tests/typescript-nextjs/src/app/page.tsx b/integration-tests/typescript-nextjs/src/app/page.tsx new file mode 100644 index 000000000..c6b15f1be --- /dev/null +++ b/integration-tests/typescript-nextjs/src/app/page.tsx @@ -0,0 +1,95 @@ +import Image from "next/image" +import styles from "./page.module.css" + +export default function Home() { + return ( +
+
+

+ Get started by editing  + src/app/page.tsx +

+
+ + By{" "} + Vercel Logo + +
+
+ +
+ Next.js Logo +
+ +
+ +

+ Docs -> +

+

Find in-depth information about Next.js features and API.

+
+ + +

+ Learn -> +

+

Learn about Next.js in an interactive course with quizzes!

+
+ + +

+ Templates -> +

+

Explore starter templates for Next.js.

+
+ + +

+ Deploy -> +

+

+ Instantly deploy your Next.js site to a shareable URL with Vercel. +

+
+
+
+ ) +} diff --git a/integration-tests/typescript-nextjs/src/app/todo-lists.yaml/attachments/[id]/route.ts b/integration-tests/typescript-nextjs/src/app/todo-lists.yaml/attachments/[id]/route.ts new file mode 100644 index 000000000..aae0592e4 --- /dev/null +++ b/integration-tests/typescript-nextjs/src/app/todo-lists.yaml/attachments/[id]/route.ts @@ -0,0 +1,14 @@ +import {_PUT} from "@/generated/todo-lists.yaml/attachments/[id]/route" + +export const PUT = _PUT( + async ({params, body}, respond, request) => { + // TODO: implementation + return respond.withStatus(501).body({message: "not implemented"} as any) + }, + async (err) => { + // TODO: implementation + return new Response(JSON.stringify({message: "not implemented"}), { + status: 501, + }) + }, +) diff --git a/integration-tests/typescript-nextjs/src/app/todo-lists.yaml/attachments/route.ts b/integration-tests/typescript-nextjs/src/app/todo-lists.yaml/attachments/route.ts new file mode 100644 index 000000000..aa413967d --- /dev/null +++ b/integration-tests/typescript-nextjs/src/app/todo-lists.yaml/attachments/route.ts @@ -0,0 +1,26 @@ +import {_GET, _POST} from "@/generated/todo-lists.yaml/attachments/route" + +export const GET = _GET( + async (respond, request) => { + // TODO: implementation + return respond.withStatus(501).body({message: "not implemented"} as any) + }, + async (err) => { + // TODO: implementation + return new Response(JSON.stringify({message: "not implemented"}), { + status: 501, + }) + }, +) +export const POST = _POST( + async ({body}, respond, request) => { + // TODO: implementation + return respond.withStatus(501).body({message: "not implemented"} as any) + }, + async (err) => { + // TODO: implementation + return new Response(JSON.stringify({message: "not implemented"}), { + status: 501, + }) + }, +) diff --git a/integration-tests/typescript-nextjs/src/app/todo-lists.yaml/list/[listId]/items/route.ts b/integration-tests/typescript-nextjs/src/app/todo-lists.yaml/list/[listId]/items/route.ts new file mode 100644 index 000000000..15aec8628 --- /dev/null +++ b/integration-tests/typescript-nextjs/src/app/todo-lists.yaml/list/[listId]/items/route.ts @@ -0,0 +1,29 @@ +import { + _GET, + _POST, +} from "@/generated/todo-lists.yaml/list/[listId]/items/route" + +export const GET = _GET( + async ({params}, respond, request) => { + // TODO: implementation + return respond.withStatus(501).body({message: "not implemented"} as any) + }, + async (err) => { + // TODO: implementation + return new Response(JSON.stringify({message: "not implemented"}), { + status: 501, + }) + }, +) +export const POST = _POST( + async ({params, body}, respond, request) => { + // TODO: implementation + return respond.withStatus(501).body({message: "not implemented"} as any) + }, + async (err) => { + // TODO: implementation + return new Response(JSON.stringify({message: "not implemented"}), { + status: 501, + }) + }, +) diff --git a/integration-tests/typescript-nextjs/src/app/todo-lists.yaml/list/[listId]/route.ts b/integration-tests/typescript-nextjs/src/app/todo-lists.yaml/list/[listId]/route.ts new file mode 100644 index 000000000..c7d404f79 --- /dev/null +++ b/integration-tests/typescript-nextjs/src/app/todo-lists.yaml/list/[listId]/route.ts @@ -0,0 +1,42 @@ +import { + _DELETE, + _GET, + _PUT, +} from "@/generated/todo-lists.yaml/list/[listId]/route" + +export const GET = _GET( + async ({params}, respond, request) => { + // TODO: implementation + return respond.withStatus(501).body({message: "not implemented"} as any) + }, + async (err) => { + // TODO: implementation + return new Response(JSON.stringify({message: "not implemented"}), { + status: 501, + }) + }, +) +export const PUT = _PUT( + async ({params, body}, respond, request) => { + // TODO: implementation + return respond.withStatus(501).body({message: "not implemented"} as any) + }, + async (err) => { + // TODO: implementation + return new Response(JSON.stringify({message: "not implemented"}), { + status: 501, + }) + }, +) +export const DELETE = _DELETE( + async ({params}, respond, request) => { + // TODO: implementation + return respond.withStatus(501).body({message: "not implemented"} as any) + }, + async (err) => { + // TODO: implementation + return new Response(JSON.stringify({message: "not implemented"}), { + status: 501, + }) + }, +) diff --git a/integration-tests/typescript-nextjs/src/app/todo-lists.yaml/list/route.ts b/integration-tests/typescript-nextjs/src/app/todo-lists.yaml/list/route.ts new file mode 100644 index 000000000..be9556729 --- /dev/null +++ b/integration-tests/typescript-nextjs/src/app/todo-lists.yaml/list/route.ts @@ -0,0 +1,14 @@ +import {_GET} from "@/generated/todo-lists.yaml/list/route" + +export const GET = _GET( + async ({query}, respond, request) => { + // TODO: implementation + return respond.withStatus(501).body({message: "not implemented"} as any) + }, + async (err) => { + // TODO: implementation + return new Response(JSON.stringify({message: "not implemented"}), { + status: 501, + }) + }, +) diff --git a/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/attachments/[id]/route.ts b/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/attachments/[id]/route.ts new file mode 100644 index 000000000..b23b0e1e4 --- /dev/null +++ b/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/attachments/[id]/route.ts @@ -0,0 +1,93 @@ +/** AUTOGENERATED - DO NOT EDIT **/ +/* tslint:disable */ +/* eslint-disable */ + +import { + OpenAPIRuntimeError, + RequestInputType, +} from "@nahkies/typescript-nextjs-runtime/errors" +import { + type OpenAPIRuntimeResponder, + OpenAPIRuntimeResponse, + type Params, + type StatusCode, +} from "@nahkies/typescript-nextjs-runtime/server" +import { + parseRequestInput, + responseValidationFactory, +} from "@nahkies/typescript-nextjs-runtime/zod-v4" +import type {NextRequest} from "next/server" +import {z} from "zod/v4" +import type {t_ReplaceAttachmentParamSchema} from "@/generated/todo-lists.yaml/models" + +// /attachments/{id} +export type ReplaceAttachmentResponder = { + with202(): OpenAPIRuntimeResponse +} & OpenAPIRuntimeResponder + +export type ReplaceAttachment = ( + params: Params, + respond: ReplaceAttachmentResponder, + request: NextRequest, +) => Promise> + +const replaceAttachmentParamSchema = z.object({id: z.string()}) + +export const _PUT = + ( + implementation: ReplaceAttachment, + onError: (err: unknown) => Promise, + ) => + async ( + request: NextRequest, + {params}: {params: Promise}, + ): Promise => { + try { + const input = { + params: parseRequestInput( + replaceAttachmentParamSchema, + await params, + RequestInputType.RouteParam, + ), + query: undefined, + body: parseRequestInput( + z.any(), + await request.blob(), + RequestInputType.RequestBody, + ), + headers: undefined, + } + const responder = { + with202() { + return new OpenAPIRuntimeResponse(202) + }, + withStatus(status: StatusCode) { + return new OpenAPIRuntimeResponse(status) + }, + } + const responseValidator = responseValidationFactory( + [["202", z.undefined()]], + undefined, + ) + + const res = await implementation(input, responder, request) + .then((it) => { + if (it instanceof Response) { + return it + } + const {status, body} = it.unpack() + const validatedBody = responseValidator(status, body) + + return validatedBody !== undefined + ? Response.json(validatedBody, {status}) + : new Response(undefined, {status}) + }) + .catch((err) => { + throw OpenAPIRuntimeError.HandlerError(err) + }) + + return res + } catch (err) { + return await onError(err) + } + } diff --git a/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/attachments/route.ts b/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/attachments/route.ts new file mode 100644 index 000000000..5609c9a6a --- /dev/null +++ b/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/attachments/route.ts @@ -0,0 +1,137 @@ +/** AUTOGENERATED - DO NOT EDIT **/ +/* tslint:disable */ +/* eslint-disable */ + +import { + OpenAPIRuntimeError, + RequestInputType, +} from "@nahkies/typescript-nextjs-runtime/errors" +import { + type OpenAPIRuntimeResponder, + OpenAPIRuntimeResponse, + type Params, + type StatusCode, +} from "@nahkies/typescript-nextjs-runtime/server" +import { + parseRequestInput, + responseValidationFactory, +} from "@nahkies/typescript-nextjs-runtime/zod-v4" +import type {NextRequest} from "next/server" +import {z} from "zod/v4" +import type {t_UnknownObject} from "@/generated/todo-lists.yaml/models" +import {s_UnknownObject} from "@/generated/todo-lists.yaml/schemas" + +// /attachments +export type ListAttachmentsResponder = { + with200(): OpenAPIRuntimeResponse +} & OpenAPIRuntimeResponder + +export type ListAttachments = ( + respond: ListAttachmentsResponder, + request: NextRequest, +) => Promise> + +export type UploadAttachmentResponder = { + with202(): OpenAPIRuntimeResponse +} & OpenAPIRuntimeResponder + +export type UploadAttachment = ( + params: Params, + respond: UploadAttachmentResponder, + request: NextRequest, +) => Promise> + +export const _GET = + ( + implementation: ListAttachments, + onError: (err: unknown) => Promise, + ) => + async (request: NextRequest): Promise => { + try { + const responder = { + with200() { + return new OpenAPIRuntimeResponse(200) + }, + withStatus(status: StatusCode) { + return new OpenAPIRuntimeResponse(status) + }, + } + const responseValidator = responseValidationFactory( + [["200", z.array(s_UnknownObject)]], + undefined, + ) + + const res = await implementation(responder, request) + .then((it) => { + if (it instanceof Response) { + return it + } + const {status, body} = it.unpack() + const validatedBody = responseValidator(status, body) + + return validatedBody !== undefined + ? Response.json(validatedBody, {status}) + : new Response(undefined, {status}) + }) + .catch((err) => { + throw OpenAPIRuntimeError.HandlerError(err) + }) + + return res + } catch (err) { + return await onError(err) + } + } + +export const _POST = + ( + implementation: UploadAttachment, + onError: (err: unknown) => Promise, + ) => + async (request: NextRequest): Promise => { + try { + const input = { + params: undefined, + query: undefined, + // todo: request bodies with content-type 'multipart/form-data' not yet supported + body: parseRequestInput( + z.never(), + await request.formData(), + RequestInputType.RequestBody, + ) as never, + headers: undefined, + } + const responder = { + with202() { + return new OpenAPIRuntimeResponse(202) + }, + withStatus(status: StatusCode) { + return new OpenAPIRuntimeResponse(status) + }, + } + const responseValidator = responseValidationFactory( + [["202", z.undefined()]], + undefined, + ) + + const res = await implementation(input, responder, request) + .then((it) => { + if (it instanceof Response) { + return it + } + const {status, body} = it.unpack() + const validatedBody = responseValidator(status, body) + + return validatedBody !== undefined + ? Response.json(validatedBody, {status}) + : new Response(undefined, {status}) + }) + .catch((err) => { + throw OpenAPIRuntimeError.HandlerError(err) + }) + + return res + } catch (err) { + return await onError(err) + } + } diff --git a/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/client.ts b/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/client.ts new file mode 100644 index 000000000..4d7e747bd --- /dev/null +++ b/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/client.ts @@ -0,0 +1,345 @@ +/** AUTOGENERATED - DO NOT EDIT **/ +/* tslint:disable */ +/* eslint-disable */ + +import { + AbstractFetchClient, + type AbstractFetchClientConfig, + type Res, + type Server, + type StatusCode, + type StatusCode4xx, + type StatusCode5xx, +} from "@nahkies/typescript-fetch-runtime/main" +import type { + t_CreateTodoListItemRequestBody, + t_CreateUpdateTodoList, + t_Error, + t_Statuses, + t_TodoList, + t_UnknownObject, +} from "@/generated/todo-lists.yaml/models" + +export class ApiClientServersOperations { + static listAttachments(url?: "{schema}://{tenant}.attachments.example.com"): { + build: ( + schema?: "http" | "https", + tenant?: string, + ) => Server<"listAttachments_ApiClient"> + } + static listAttachments(url?: "https://attachments.example.com"): { + build: () => Server<"listAttachments_ApiClient"> + } + static listAttachments( + url: string = "{schema}://{tenant}.attachments.example.com", + ): unknown { + switch (url) { + case "{schema}://{tenant}.attachments.example.com": + return { + build( + schema: "http" | "https" = "https", + tenant = "your-slug", + ): Server<"listAttachments_ApiClient"> { + return "{schema}://{tenant}.attachments.example.com" + .replace("{schema}", schema) + .replace( + "{tenant}", + tenant, + ) as Server<"listAttachments_ApiClient"> + }, + } + + case "https://attachments.example.com": + return { + build(): Server<"listAttachments_ApiClient"> { + return "https://attachments.example.com" as Server<"listAttachments_ApiClient"> + }, + } + + default: + throw new Error(`no matching server for url '${url}'`) + } + } + + static uploadAttachment( + url?: "{schema}://{tenant}.attachments.example.com", + ): { + build: ( + schema?: "http" | "https", + tenant?: string, + ) => Server<"uploadAttachment_ApiClient"> + } + static uploadAttachment(url?: "https://attachments.example.com"): { + build: () => Server<"uploadAttachment_ApiClient"> + } + static uploadAttachment( + url: string = "{schema}://{tenant}.attachments.example.com", + ): unknown { + switch (url) { + case "{schema}://{tenant}.attachments.example.com": + return { + build( + schema: "http" | "https" = "https", + tenant = "your-slug", + ): Server<"uploadAttachment_ApiClient"> { + return "{schema}://{tenant}.attachments.example.com" + .replace("{schema}", schema) + .replace( + "{tenant}", + tenant, + ) as Server<"uploadAttachment_ApiClient"> + }, + } + + case "https://attachments.example.com": + return { + build(): Server<"uploadAttachment_ApiClient"> { + return "https://attachments.example.com" as Server<"uploadAttachment_ApiClient"> + }, + } + + default: + throw new Error(`no matching server for url '${url}'`) + } + } +} + +export class ApiClientServers { + static default(): Server<"ApiClient"> { + return ApiClientServers.server().build() + } + + static server(url?: "{schema}://{tenant}.todo-lists.example.com"): { + build: (schema?: "http" | "https", tenant?: string) => Server<"ApiClient"> + } + static server(url?: "https://todo-lists.example.com"): { + build: () => Server<"ApiClient"> + } + static server( + url: string = "{schema}://{tenant}.todo-lists.example.com", + ): unknown { + switch (url) { + case "{schema}://{tenant}.todo-lists.example.com": + return { + build( + schema: "http" | "https" = "https", + tenant = "your-slug", + ): Server<"ApiClient"> { + return "{schema}://{tenant}.todo-lists.example.com" + .replace("{schema}", schema) + .replace("{tenant}", tenant) as Server<"ApiClient"> + }, + } + + case "https://todo-lists.example.com": + return { + build(): Server<"ApiClient"> { + return "https://todo-lists.example.com" as Server<"ApiClient"> + }, + } + + default: + throw new Error(`no matching server for url '${url}'`) + } + } + + static readonly operations = ApiClientServersOperations +} + +export interface ApiClientConfig extends AbstractFetchClientConfig { + basePath: Server<"ApiClient"> | string +} + +export class ApiClient extends AbstractFetchClient { + constructor(config: ApiClientConfig) { + super(config) + } + + async getTodoLists( + p: { + created?: string + statuses?: t_Statuses + tags?: string[] + } = {}, + timeout?: number, + opts: RequestInit = {}, + ): Promise> { + const url = this.basePath + `/list` + const headers = this._headers({Accept: "application/json"}, opts.headers) + const query = this._query( + {created: p["created"], statuses: p["statuses"], tags: p["tags"]}, + { + statuses: { + style: "form", + explode: true, + }, + tags: { + style: "form", + explode: true, + }, + }, + ) + + return this._fetch(url + query, {method: "GET", ...opts, headers}, timeout) + } + + async getTodoListById( + p: { + listId: string + }, + timeout?: number, + opts: RequestInit = {}, + ): Promise< + | Res<200, t_TodoList> + | Res + | Res, void> + > { + const url = this.basePath + `/list/${p["listId"]}` + const headers = this._headers({Accept: "application/json"}, opts.headers) + + return this._fetch(url, {method: "GET", ...opts, headers}, timeout) + } + + async updateTodoListById( + p: { + listId: string + requestBody: t_CreateUpdateTodoList + }, + timeout?: number, + opts: RequestInit = {}, + ): Promise< + | Res<200, t_TodoList> + | Res + | Res, void> + > { + const url = this.basePath + `/list/${p["listId"]}` + const headers = this._headers( + {Accept: "application/json", "Content-Type": "application/json"}, + opts.headers, + ) + const body = JSON.stringify(p.requestBody) + + return this._fetch(url, {method: "PUT", body, ...opts, headers}, timeout) + } + + async deleteTodoListById( + p: { + listId: string + }, + timeout?: number, + opts: RequestInit = {}, + ): Promise< + | Res<204, void> + | Res + | Res, void> + > { + const url = this.basePath + `/list/${p["listId"]}` + const headers = this._headers({Accept: "application/json"}, opts.headers) + + return this._fetch(url, {method: "DELETE", ...opts, headers}, timeout) + } + + async getTodoListItems( + p: { + listId: string + }, + timeout?: number, + opts: RequestInit = {}, + ): Promise< + | Res< + 200, + { + completedAt?: string + content: string + createdAt: string + id: string + } + > + | Res< + StatusCode5xx, + { + code: string + message: string + } + > + > { + const url = this.basePath + `/list/${p["listId"]}/items` + const headers = this._headers({Accept: "application/json"}, opts.headers) + + return this._fetch(url, {method: "GET", ...opts, headers}, timeout) + } + + async createTodoListItem( + p: { + listId: string + requestBody: t_CreateTodoListItemRequestBody + }, + timeout?: number, + opts: RequestInit = {}, + ): Promise> { + const url = this.basePath + `/list/${p["listId"]}/items` + const headers = this._headers( + {Accept: "application/json", "Content-Type": "application/json"}, + opts.headers, + ) + const body = JSON.stringify(p.requestBody) + + return this._fetch(url, {method: "POST", body, ...opts, headers}, timeout) + } + + async listAttachments( + basePath: + | Server<"listAttachments_ApiClient"> + | string = ApiClientServers.operations.listAttachments().build(), + timeout?: number, + opts: RequestInit = {}, + ): Promise> { + const url = basePath + `/attachments` + const headers = this._headers({Accept: "application/json"}, opts.headers) + + return this._fetch(url, {method: "GET", ...opts, headers}, timeout) + } + + async uploadAttachment( + p: { + requestBody: never + }, + basePath: + | Server<"uploadAttachment_ApiClient"> + | string = ApiClientServers.operations.uploadAttachment().build(), + timeout?: number, + opts: RequestInit = {}, + ): Promise> { + const url = basePath + `/attachments` + const headers = this._headers({Accept: "application/json"}, opts.headers) + + return this._fetch( + url, + { + method: "POST", + // todo: request bodies with content-type 'multipart/form-data' not yet supported, + ...opts, + headers, + }, + timeout, + ) + } + + async replaceAttachment( + p: { + id: string + requestBody: Blob + }, + timeout?: number, + opts: RequestInit = {}, + ): Promise> { + const url = this.basePath + `/attachments/${p["id"]}` + const headers = this._headers( + {Accept: "application/json", "Content-Type": "application/octet-stream"}, + opts.headers, + ) + const body = p.requestBody + + return this._fetch(url, {method: "PUT", body, ...opts, headers}, timeout) + } +} diff --git a/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/list/[listId]/items/route.ts b/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/list/[listId]/items/route.ts new file mode 100644 index 000000000..72aa20b70 --- /dev/null +++ b/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/list/[listId]/items/route.ts @@ -0,0 +1,202 @@ +/** AUTOGENERATED - DO NOT EDIT **/ +/* tslint:disable */ +/* eslint-disable */ + +import { + OpenAPIRuntimeError, + RequestInputType, +} from "@nahkies/typescript-nextjs-runtime/errors" +import { + type OpenAPIRuntimeResponder, + OpenAPIRuntimeResponse, + type Params, + type StatusCode, + type StatusCode5xx, +} from "@nahkies/typescript-nextjs-runtime/server" +import { + parseRequestInput, + responseValidationFactory, +} from "@nahkies/typescript-nextjs-runtime/zod-v4" +import type {NextRequest} from "next/server" +import {z} from "zod/v4" +import type { + t_CreateTodoListItemParamSchema, + t_CreateTodoListItemRequestBody, + t_GetTodoListItemsParamSchema, +} from "@/generated/todo-lists.yaml/models" +import {s_CreateTodoListItemRequestBody} from "@/generated/todo-lists.yaml/schemas" + +// /list/{listId}/items +export type GetTodoListItemsResponder = { + with200(): OpenAPIRuntimeResponse<{ + completedAt?: string + content: string + createdAt: string + id: string + }> + withStatusCode5xx(status: StatusCode5xx): OpenAPIRuntimeResponse<{ + code: string + message: string + }> +} & OpenAPIRuntimeResponder + +export type GetTodoListItems = ( + params: Params, + respond: GetTodoListItemsResponder, + request: NextRequest, +) => Promise> + +export type CreateTodoListItemResponder = { + with204(): OpenAPIRuntimeResponse +} & OpenAPIRuntimeResponder + +export type CreateTodoListItem = ( + params: Params< + t_CreateTodoListItemParamSchema, + void, + t_CreateTodoListItemRequestBody, + void + >, + respond: CreateTodoListItemResponder, + request: NextRequest, +) => Promise> + +const getTodoListItemsParamSchema = z.object({listId: z.string()}) + +export const _GET = + ( + implementation: GetTodoListItems, + onError: (err: unknown) => Promise, + ) => + async ( + request: NextRequest, + {params}: {params: Promise}, + ): Promise => { + try { + const input = { + params: parseRequestInput( + getTodoListItemsParamSchema, + await params, + RequestInputType.RouteParam, + ), + query: undefined, + body: undefined, + headers: undefined, + } + const responder = { + with200() { + return new OpenAPIRuntimeResponse<{ + completedAt?: string + content: string + createdAt: string + id: string + }>(200) + }, + withStatusCode5xx(status: StatusCode5xx) { + return new OpenAPIRuntimeResponse<{ + code: string + message: string + }>(status) + }, + withStatus(status: StatusCode) { + return new OpenAPIRuntimeResponse(status) + }, + } + const responseValidator = responseValidationFactory( + [ + [ + "200", + z.object({ + id: z.string(), + content: z.string(), + createdAt: z.iso.datetime({offset: true}), + completedAt: z.iso.datetime({offset: true}).optional(), + }), + ], + ["5XX", z.object({message: z.string(), code: z.string()})], + ], + undefined, + ) + + const res = await implementation(input, responder, request) + .then((it) => { + if (it instanceof Response) { + return it + } + const {status, body} = it.unpack() + const validatedBody = responseValidator(status, body) + + return validatedBody !== undefined + ? Response.json(validatedBody, {status}) + : new Response(undefined, {status}) + }) + .catch((err) => { + throw OpenAPIRuntimeError.HandlerError(err) + }) + + return res + } catch (err) { + return await onError(err) + } + } + +const createTodoListItemParamSchema = z.object({listId: z.string()}) + +export const _POST = + ( + implementation: CreateTodoListItem, + onError: (err: unknown) => Promise, + ) => + async ( + request: NextRequest, + {params}: {params: Promise}, + ): Promise => { + try { + const input = { + params: parseRequestInput( + createTodoListItemParamSchema, + await params, + RequestInputType.RouteParam, + ), + query: undefined, + body: parseRequestInput( + s_CreateTodoListItemRequestBody, + await request.json(), + RequestInputType.RequestBody, + ), + headers: undefined, + } + const responder = { + with204() { + return new OpenAPIRuntimeResponse(204) + }, + withStatus(status: StatusCode) { + return new OpenAPIRuntimeResponse(status) + }, + } + const responseValidator = responseValidationFactory( + [["204", z.undefined()]], + undefined, + ) + + const res = await implementation(input, responder, request) + .then((it) => { + if (it instanceof Response) { + return it + } + const {status, body} = it.unpack() + const validatedBody = responseValidator(status, body) + + return validatedBody !== undefined + ? Response.json(validatedBody, {status}) + : new Response(undefined, {status}) + }) + .catch((err) => { + throw OpenAPIRuntimeError.HandlerError(err) + }) + + return res + } catch (err) { + return await onError(err) + } + } diff --git a/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/list/[listId]/route.ts b/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/list/[listId]/route.ts new file mode 100644 index 000000000..a86b3aa36 --- /dev/null +++ b/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/list/[listId]/route.ts @@ -0,0 +1,278 @@ +/** AUTOGENERATED - DO NOT EDIT **/ +/* tslint:disable */ +/* eslint-disable */ + +import { + OpenAPIRuntimeError, + RequestInputType, +} from "@nahkies/typescript-nextjs-runtime/errors" +import { + type OpenAPIRuntimeResponder, + OpenAPIRuntimeResponse, + type Params, + type StatusCode, + type StatusCode4xx, +} from "@nahkies/typescript-nextjs-runtime/server" +import { + parseRequestInput, + responseValidationFactory, +} from "@nahkies/typescript-nextjs-runtime/zod-v4" +import type {NextRequest} from "next/server" +import {z} from "zod/v4" +import type { + t_CreateUpdateTodoList, + t_DeleteTodoListByIdParamSchema, + t_Error, + t_GetTodoListByIdParamSchema, + t_TodoList, + t_UpdateTodoListByIdParamSchema, +} from "@/generated/todo-lists.yaml/models" +import { + s_CreateUpdateTodoList, + s_Error, + s_TodoList, +} from "@/generated/todo-lists.yaml/schemas" + +// /list/{listId} +export type GetTodoListByIdResponder = { + with200(): OpenAPIRuntimeResponse + withStatusCode4xx(status: StatusCode4xx): OpenAPIRuntimeResponse + withDefault(status: StatusCode): OpenAPIRuntimeResponse +} & OpenAPIRuntimeResponder + +export type GetTodoListById = ( + params: Params, + respond: GetTodoListByIdResponder, + request: NextRequest, +) => Promise> + +export type UpdateTodoListByIdResponder = { + with200(): OpenAPIRuntimeResponse + withStatusCode4xx(status: StatusCode4xx): OpenAPIRuntimeResponse + withDefault(status: StatusCode): OpenAPIRuntimeResponse +} & OpenAPIRuntimeResponder + +export type UpdateTodoListById = ( + params: Params< + t_UpdateTodoListByIdParamSchema, + void, + t_CreateUpdateTodoList, + void + >, + respond: UpdateTodoListByIdResponder, + request: NextRequest, +) => Promise> + +export type DeleteTodoListByIdResponder = { + with204(): OpenAPIRuntimeResponse + withStatusCode4xx(status: StatusCode4xx): OpenAPIRuntimeResponse + withDefault(status: StatusCode): OpenAPIRuntimeResponse +} & OpenAPIRuntimeResponder + +export type DeleteTodoListById = ( + params: Params, + respond: DeleteTodoListByIdResponder, + request: NextRequest, +) => Promise> + +const getTodoListByIdParamSchema = z.object({listId: z.string()}) + +export const _GET = + ( + implementation: GetTodoListById, + onError: (err: unknown) => Promise, + ) => + async ( + request: NextRequest, + {params}: {params: Promise}, + ): Promise => { + try { + const input = { + params: parseRequestInput( + getTodoListByIdParamSchema, + await params, + RequestInputType.RouteParam, + ), + query: undefined, + body: undefined, + headers: undefined, + } + const responder = { + with200() { + return new OpenAPIRuntimeResponse(200) + }, + withStatusCode4xx(status: StatusCode4xx) { + return new OpenAPIRuntimeResponse(status) + }, + withDefault(status: StatusCode) { + return new OpenAPIRuntimeResponse(status) + }, + withStatus(status: StatusCode) { + return new OpenAPIRuntimeResponse(status) + }, + } + const responseValidator = responseValidationFactory( + [ + ["200", s_TodoList], + ["4XX", s_Error], + ], + z.undefined(), + ) + + const res = await implementation(input, responder, request) + .then((it) => { + if (it instanceof Response) { + return it + } + const {status, body} = it.unpack() + const validatedBody = responseValidator(status, body) + + return validatedBody !== undefined + ? Response.json(validatedBody, {status}) + : new Response(undefined, {status}) + }) + .catch((err) => { + throw OpenAPIRuntimeError.HandlerError(err) + }) + + return res + } catch (err) { + return await onError(err) + } + } + +const updateTodoListByIdParamSchema = z.object({listId: z.string()}) + +export const _PUT = + ( + implementation: UpdateTodoListById, + onError: (err: unknown) => Promise, + ) => + async ( + request: NextRequest, + {params}: {params: Promise}, + ): Promise => { + try { + const input = { + params: parseRequestInput( + updateTodoListByIdParamSchema, + await params, + RequestInputType.RouteParam, + ), + query: undefined, + body: parseRequestInput( + s_CreateUpdateTodoList, + await request.json(), + RequestInputType.RequestBody, + ), + headers: undefined, + } + const responder = { + with200() { + return new OpenAPIRuntimeResponse(200) + }, + withStatusCode4xx(status: StatusCode4xx) { + return new OpenAPIRuntimeResponse(status) + }, + withDefault(status: StatusCode) { + return new OpenAPIRuntimeResponse(status) + }, + withStatus(status: StatusCode) { + return new OpenAPIRuntimeResponse(status) + }, + } + const responseValidator = responseValidationFactory( + [ + ["200", s_TodoList], + ["4XX", s_Error], + ], + z.undefined(), + ) + + const res = await implementation(input, responder, request) + .then((it) => { + if (it instanceof Response) { + return it + } + const {status, body} = it.unpack() + const validatedBody = responseValidator(status, body) + + return validatedBody !== undefined + ? Response.json(validatedBody, {status}) + : new Response(undefined, {status}) + }) + .catch((err) => { + throw OpenAPIRuntimeError.HandlerError(err) + }) + + return res + } catch (err) { + return await onError(err) + } + } + +const deleteTodoListByIdParamSchema = z.object({listId: z.string()}) + +export const _DELETE = + ( + implementation: DeleteTodoListById, + onError: (err: unknown) => Promise, + ) => + async ( + request: NextRequest, + {params}: {params: Promise}, + ): Promise => { + try { + const input = { + params: parseRequestInput( + deleteTodoListByIdParamSchema, + await params, + RequestInputType.RouteParam, + ), + query: undefined, + body: undefined, + headers: undefined, + } + const responder = { + with204() { + return new OpenAPIRuntimeResponse(204) + }, + withStatusCode4xx(status: StatusCode4xx) { + return new OpenAPIRuntimeResponse(status) + }, + withDefault(status: StatusCode) { + return new OpenAPIRuntimeResponse(status) + }, + withStatus(status: StatusCode) { + return new OpenAPIRuntimeResponse(status) + }, + } + const responseValidator = responseValidationFactory( + [ + ["204", z.undefined()], + ["4XX", s_Error], + ], + z.undefined(), + ) + + const res = await implementation(input, responder, request) + .then((it) => { + if (it instanceof Response) { + return it + } + const {status, body} = it.unpack() + const validatedBody = responseValidator(status, body) + + return validatedBody !== undefined + ? Response.json(validatedBody, {status}) + : new Response(undefined, {status}) + }) + .catch((err) => { + throw OpenAPIRuntimeError.HandlerError(err) + }) + + return res + } catch (err) { + return await onError(err) + } + } diff --git a/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/list/route.ts b/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/list/route.ts new file mode 100644 index 000000000..315c3186c --- /dev/null +++ b/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/list/route.ts @@ -0,0 +1,104 @@ +/** AUTOGENERATED - DO NOT EDIT **/ +/* tslint:disable */ +/* eslint-disable */ + +import { + OpenAPIRuntimeError, + RequestInputType, +} from "@nahkies/typescript-nextjs-runtime/errors" +import { + type OpenAPIRuntimeResponder, + OpenAPIRuntimeResponse, + type Params, + type StatusCode, +} from "@nahkies/typescript-nextjs-runtime/server" +import { + parseRequestInput, + responseValidationFactory, +} from "@nahkies/typescript-nextjs-runtime/zod-v4" +import type {NextRequest} from "next/server" +import {z} from "zod/v4" +import type { + t_GetTodoListsQuerySchema, + t_TodoList, +} from "@/generated/todo-lists.yaml/models" +import {s_Statuses, s_TodoList} from "@/generated/todo-lists.yaml/schemas" + +// /list +export type GetTodoListsResponder = { + with200(): OpenAPIRuntimeResponse +} & OpenAPIRuntimeResponder + +export type GetTodoLists = ( + params: Params, + respond: GetTodoListsResponder, + request: NextRequest, +) => Promise> + +const getTodoListsQuerySchema = z.object({ + created: z.iso.datetime({offset: true}).optional(), + statuses: z + .preprocess( + (it: unknown) => (Array.isArray(it) || it === undefined ? it : [it]), + s_Statuses, + ) + .optional(), + tags: z + .preprocess( + (it: unknown) => (Array.isArray(it) || it === undefined ? it : [it]), + z.array(z.string()), + ) + .optional(), +}) + +export const _GET = + ( + implementation: GetTodoLists, + onError: (err: unknown) => Promise, + ) => + async (request: NextRequest): Promise => { + try { + const input = { + params: undefined, + query: parseRequestInput( + getTodoListsQuerySchema, + Object.fromEntries(request.nextUrl.searchParams.entries()), + RequestInputType.QueryString, + ), + body: undefined, + headers: undefined, + } + const responder = { + with200() { + return new OpenAPIRuntimeResponse(200) + }, + withStatus(status: StatusCode) { + return new OpenAPIRuntimeResponse(status) + }, + } + const responseValidator = responseValidationFactory( + [["200", z.array(s_TodoList)]], + undefined, + ) + + const res = await implementation(input, responder, request) + .then((it) => { + if (it instanceof Response) { + return it + } + const {status, body} = it.unpack() + const validatedBody = responseValidator(status, body) + + return validatedBody !== undefined + ? Response.json(validatedBody, {status}) + : new Response(undefined, {status}) + }) + .catch((err) => { + throw OpenAPIRuntimeError.HandlerError(err) + }) + + return res + } catch (err) { + return await onError(err) + } + } diff --git a/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/models.ts b/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/models.ts new file mode 100644 index 000000000..8633abcde --- /dev/null +++ b/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/models.ts @@ -0,0 +1,61 @@ +/** AUTOGENERATED - DO NOT EDIT **/ +/* tslint:disable */ +/* eslint-disable */ + +export type t_Error = { + code?: number + message?: string +} + +export type t_CreateUpdateTodoList = { + name: string +} + +export type t_Statuses = ("incomplete" | "complete")[] + +export type t_TodoList = { + created: string + id: string + incompleteItemCount: number + name: string + totalItemCount: number + updated: string +} + +export type t_CreateTodoListItemParamSchema = { + listId: string +} + +export type t_CreateTodoListItemRequestBody = { + completedAt?: string + content: string + id: string +} + +export type t_DeleteTodoListByIdParamSchema = { + listId: string +} + +export type t_UnknownObject = Record + +export type t_GetTodoListByIdParamSchema = { + listId: string +} + +export type t_GetTodoListItemsParamSchema = { + listId: string +} + +export type t_GetTodoListsQuerySchema = { + created?: string + statuses?: t_Statuses + tags?: string[] +} + +export type t_ReplaceAttachmentParamSchema = { + id: string +} + +export type t_UpdateTodoListByIdParamSchema = { + listId: string +} diff --git a/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/schemas.ts b/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/schemas.ts new file mode 100644 index 000000000..8c92f6005 --- /dev/null +++ b/integration-tests/typescript-nextjs/src/generated/todo-lists.yaml/schemas.ts @@ -0,0 +1,31 @@ +/** AUTOGENERATED - DO NOT EDIT **/ +/* tslint:disable */ +/* eslint-disable */ + +import {z} from "zod/v4" + +export const s_CreateUpdateTodoList = z.object({name: z.string()}) + +export const s_Error = z.object({ + message: z.string().optional(), + code: z.coerce.number().optional(), +}) + +export const s_Statuses = z.array(z.enum(["incomplete", "complete"])) + +export const s_TodoList = z.object({ + id: z.string(), + name: z.string(), + totalItemCount: z.coerce.number(), + incompleteItemCount: z.coerce.number(), + created: z.iso.datetime({offset: true}), + updated: z.iso.datetime({offset: true}), +}) + +export const s_UnknownObject = z.record(z.string(), z.unknown()) + +export const s_CreateTodoListItemRequestBody = z.object({ + id: z.string(), + content: z.string(), + completedAt: z.iso.datetime({offset: true}).optional(), +}) diff --git a/integration-tests/typescript-nextjs/tsconfig.json b/integration-tests/typescript-nextjs/tsconfig.json new file mode 100644 index 000000000..7b2858930 --- /dev/null +++ b/integration-tests/typescript-nextjs/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/documentation/src/app/guides/server-templates/typescript-nextjs/page.mdx b/packages/documentation/src/app/guides/server-templates/typescript-nextjs/page.mdx new file mode 100644 index 000000000..836fb5cf0 --- /dev/null +++ b/packages/documentation/src/app/guides/server-templates/typescript-nextjs/page.mdx @@ -0,0 +1,173 @@ +import {Tabs} from 'nextra/components' + +# Using the `typescript-nextjs` template + +> ⚠️ **Alpha Template** ⚠️ +> +> This template is currently in **alpha**. APIs and features are subject to change.
+> It might break in unexpected ways, or **mangle** your code. +> +> You can see an example of it used on a real project here:
+> https://github.com/mnahkies/spdx-dependency-track + + +The `typescript-nextjs` template outputs scaffolding code that handles the following: + +- Generates route handlers in the Next.js App Router (app/api/.../route.ts) for every operation in your OpenAPI spec +- Parses and validates request input (query, params, headers, and body) using `zod`, or `joi` +- Validates response types at runtime before sending them, ensuring they conform to your OpenAPI spec +- Enforces full type safety for each handler’s inputs and outputs +- Additionally, emits a [typescript-fetch](../client-templates/typescript-fetch) client for making requests to the routes from your react code + +See [integration-tests/typescript-nextjs](https://github.com/mnahkies/openapi-code-generator/tree/main/integration-tests/typescript-nextjs) for more samples. + +### Install dependencies +First install the CLI and the required runtime packages to your project: +```sh npm2yarn +npm i --dev @nahkies/openapi-code-generator +npm i @nahkies/typescript-nextjs-runtime next zod +``` + +See also [quick start](../../getting-started/quick-start) guide + +### Run generation + + + + ```sh npm2yarn + npm run openapi-code-generator \ + --input ./openapi.yaml \ + --input-type openapi3 \ + --output ./src \ + --template typescript-nextjs \ + --schema-builder zod + ``` + + + ```sh npm2yarn + npm run openapi-code-generator \ + --input ./typespec.tsp \ + --input-type typespec \ + --output ./src \ + --template typescript-nextjs \ + --schema-builder zod + ``` + + + + +### Using the generated code +Running the above will output a bunch of files into `./src`. Here's an example of the files output for a todo-list api specification: +```shell +src +├── app +│ ├── api +│ │ └── list +│ │ ├── [listId] +│ │ │ ├── items +│ │ │ │ └── route.ts +│ │ │ └── route.ts +│ │ └── route.ts +│ ├── layout.tsx +│ └── page.tsx +└── generated + ├── api + │ └── list + │ ├── [listId] + │ │ ├── items + │ │ │ └── route.ts + │ │ └── route.ts + │ └── route.ts + ├── client.ts + ├── models.ts + └── schemas.ts +```` + +`./src/app/../route.ts` +- a `route.ts` file is generated per operation, following Next.js App Router conventions +- exports handlers (GET, POST, etc.) for each HTTP method defined +- safe to edit, your route handler implementations go here +- calls into `./src/generated/...` for input/output validation logic + +`./src/generated/../route.ts` +- mirror structure of the `./src/app/.../route.ts` files +- contains glue code that parses input, validates responses, and calls your implementation + +`./src/generated/models.ts` +- exports plain TypeScript types for all schemas in your OpenAPI spec + +`./src/generated/schemas.ts` +- exports runtime schema validators (`zod` / `joi` depending on configuration) + +`./src/generated/client.ts` +- exports a [typescript-fetch](../client-templates/typescript-fetch) client for calling your API from frontend code +- see [use-with-react-query](../use-with-react-query) for integration with `react-query` + +#### Implementing routes + +Once generated usage should look something like this: + +```typescript +import {db} from "../../../../../db" +import { + _GET, + _POST, +} from "../../../../../generated/todo-lists.yaml/list/[listId]/items/route" + +export const GET = _GET(async ({params}, respond, request) => { + const items = db.getTodoItems({listId: params.listId}) + + if (items) { + return respond.with200().body(items) + } + return respond + .with404() + .body({code: "not-found", message: `listId ${params.listId} not found`}) +}) + +export const POST = _POST(async ({params, body}, respond, request) => { + await db.insertTodoItem({ + listId: params.listId, + itemId: body.id, + content: body.content, + completedAt: body.completedAt, + }) + + return respond.with204() +}) + +``` + +#### Its safe to regenerate! +The template uses [ts-morph](https://ts-morph.com/) to **non-destructively generate and update** route.ts files. + +This means you can safely add your own logic to the scaffolded files, and future regenerations will preserve your +implementation code while updating the generated boilerplate. + +#### Error Handling + +> 🚧 Under construction +> +> Errors will be thrown for req/res validation issues, but currently its impossible to catch them. +> More thought is needed... + +### Escape Hatches + +> 🚧 Under construction +> +> The raw nextjs `request` object is passed to your implementation, however there is not yet +> a way to skip response processing. + +Most APIs won't need this, but in some cases (e.g. unsupported features), you can use escape hatches to drop out of +the generated scaffolding. + +For example, we pass the raw nextjs `request` object to your handler implementations, +allowing you full control where its needed. +```typescript +export const GET = _GET(async ({params}, respond, request) => { + console.log(request.nextUrl.buildId) + // ...your implementation here +}) +``` + +Use sparingly - the goal is to reduce the need for escape hatches over time. diff --git a/packages/documentation/src/lib/playground/load-runtime-types.tsx b/packages/documentation/src/lib/playground/load-runtime-types.tsx index 57cfd3310..5e8a14475 100644 --- a/packages/documentation/src/lib/playground/load-runtime-types.tsx +++ b/packages/documentation/src/lib/playground/load-runtime-types.tsx @@ -20,7 +20,8 @@ export const loadRuntimeTypes = async ( | "typescript-fetch" | "typescript-axios" | "typescript-koa" - | "typescript-express", + | "typescript-express" + | "typescript-nextjs", ) => { const fileRootPath = "file:///" @@ -119,6 +120,29 @@ export const loadRuntimeTypes = async ( path: "/node_modules/@nahkies/typescript-express-runtime/joi.d.mts", }, ], + // TODO: adjust + "typescript-nextjs": [ + { + uri: "https://unpkg.com/@nahkies/typescript-koa-runtime@latest/package.json", + path: "/node_modules/@nahkies/typescript-koa-runtime/package.json", + }, + { + uri: "https://unpkg.com/@nahkies/typescript-koa-runtime@latest/dist/server.d.ts", + path: "/node_modules/@nahkies/typescript-koa-runtime/server.d.ts", + }, + { + uri: "https://unpkg.com/@nahkies/typescript-koa-runtime@latest/dist/errors.d.ts", + path: "/node_modules/@nahkies/typescript-koa-runtime/errors.d.ts", + }, + { + uri: "https://unpkg.com/@nahkies/typescript-koa-runtime@latest/dist/zod.d.ts", + path: "/node_modules/@nahkies/typescript-koa-runtime/zod.d.ts", + }, + { + uri: "https://unpkg.com/@nahkies/typescript-koa-runtime@latest/dist/joi.d.ts", + path: "/node_modules/@nahkies/typescript-koa-runtime/joi.d.ts", + }, + ], } for (const file of files[template]) { diff --git a/packages/openapi-code-generator/package.json b/packages/openapi-code-generator/package.json index 802f9066b..1809eb034 100644 --- a/packages/openapi-code-generator/package.json +++ b/packages/openapi-code-generator/package.json @@ -69,11 +69,11 @@ "@nahkies/typescript-common-runtime": "workspace:^", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.24", - "@typespec/compiler": "^1.12.0", + "@typespec/compiler": "^1.13.0", "@typespec/events": "0.83.0", - "@typespec/http": "^1.12.0", - "@typespec/openapi": "^1.12.0", - "@typespec/openapi3": "^1.12.0", + "@typespec/http": "^1.13.0", + "@typespec/openapi": "^1.13.0", + "@typespec/openapi3": "^1.13.0", "@typespec/rest": "0.83.0", "@typespec/sse": "0.83.0", "@typespec/streams": "0.83.0", @@ -95,6 +95,7 @@ "js-yaml": "catalog:", "json5": "^2.2.3", "lodash": "^4.18.1", + "ts-morph": "^26.0.0", "tslib": "^2.8.1", "typescript": "catalog:typescript6", "zod": "^4.4.3" diff --git a/packages/openapi-code-generator/src/config.ts b/packages/openapi-code-generator/src/config.ts index 1123b81dd..01edf7c4e 100644 --- a/packages/openapi-code-generator/src/config.ts +++ b/packages/openapi-code-generator/src/config.ts @@ -17,6 +17,7 @@ export type Config = { | "typescript-angular" | "typescript-koa" | "typescript-express" + | "typescript-nextjs" schemaBuilder: "zod-v3" | "zod-v4" | "joi" enableRuntimeResponseValidation: boolean enableTypedBasePaths: boolean diff --git a/packages/openapi-code-generator/src/core/input.ts b/packages/openapi-code-generator/src/core/input.ts index b7d767458..567a31458 100644 --- a/packages/openapi-code-generator/src/core/input.ts +++ b/packages/openapi-code-generator/src/core/input.ts @@ -30,7 +30,11 @@ import { import {camelCase, coalesce, isDefined, isHttpMethod} from "./utils.ts" export type OperationGroup = {name: string; operations: IROperation[]} -export type OperationGroupStrategy = "none" | "first-tag" | "first-slug" +export type OperationGroupStrategy = + | "none" + | "first-tag" + | "first-slug" + | "route" export type InputConfig = { extractInlineSchemas: boolean @@ -45,7 +49,7 @@ export interface ISchemaProvider { export class Input implements ISchemaProvider { constructor( - private loader: OpenapiLoader, + public loader: OpenapiLoader, readonly config: InputConfig, private readonly syntheticNameGenerator: SyntheticNameGenerator = defaultSyntheticNameGenerator, private readonly schemaNormalizer = new SchemaNormalizer(config, this), @@ -98,6 +102,8 @@ export class Input implements ISchemaProvider { return this.operationsByFirstTag() case "first-slug": return this.operationsByFirstSlug() + case "route": + return this.operationsByRoute() default: throw new Error(`unsupported grouping strategy '${strategy}'`) } @@ -205,6 +211,12 @@ export class Input implements ISchemaProvider { }) } + private operationsByRoute(): OperationGroup[] { + return this.groupOperations((operation) => { + return operation.route + }) + } + private groupOperations( groupBy: (operation: IROperation) => string | undefined, ): OperationGroup[] { diff --git a/packages/openapi-code-generator/src/core/loaders/openapi-loader.ts b/packages/openapi-code-generator/src/core/loaders/openapi-loader.ts index 9c216e3cf..982fa78cd 100644 --- a/packages/openapi-code-generator/src/core/loaders/openapi-loader.ts +++ b/packages/openapi-code-generator/src/core/loaders/openapi-loader.ts @@ -23,7 +23,7 @@ export class OpenapiLoader { private readonly library = new Map() private constructor( - private readonly entryPointKey: string, + public readonly entryPointKey: string, private readonly config: {titleOverride: string | undefined}, private readonly validator: IOpenapiValidator, private readonly genericLoader: GenericLoader, diff --git a/packages/openapi-code-generator/src/core/utils.ts b/packages/openapi-code-generator/src/core/utils.ts index d6029a95d..c92a3bdd9 100644 --- a/packages/openapi-code-generator/src/core/utils.ts +++ b/packages/openapi-code-generator/src/core/utils.ts @@ -5,6 +5,10 @@ export function isDefined(it: T | undefined): it is T { return it !== undefined } +export function isTruthy(it: T | undefined | null | "" | 0): it is T { + return Boolean(it) +} + export function hasSingleElement(it: T[]): it is [T] { return it.length === 1 } diff --git a/packages/openapi-code-generator/src/generate.ts b/packages/openapi-code-generator/src/generate.ts index 9cf1db502..b2c300d76 100644 --- a/packages/openapi-code-generator/src/generate.ts +++ b/packages/openapi-code-generator/src/generate.ts @@ -84,6 +84,7 @@ export async function generate( await generator.run({ input, emitter, + fsAdaptor, schemaBuilder: config.schemaBuilder, enableRuntimeResponseValidation: config.enableRuntimeResponseValidation, enableTypedBasePaths: config.enableTypedBasePaths, diff --git a/packages/openapi-code-generator/src/templates.ts b/packages/openapi-code-generator/src/templates.ts index 83183ca4e..fc2a291ab 100644 --- a/packages/openapi-code-generator/src/templates.ts +++ b/packages/openapi-code-generator/src/templates.ts @@ -5,6 +5,7 @@ import {generateTypescriptAxios} from "./typescript/client/typescript-axios/type import {generateTypescriptFetch} from "./typescript/client/typescript-fetch/typescript-fetch.generator.ts" import {generateTypescriptExpress} from "./typescript/server/typescript-express/typescript-express.generator.ts" import {generateTypescriptKoa} from "./typescript/server/typescript-koa/typescript-koa.generator.ts" +import {generateTypescriptNextJS} from "./typescript/server/typescript-nextjs/typescript-nextjs.generator.ts" export const templates = { "typescript-fetch": { @@ -37,6 +38,12 @@ export const templates = { run: generateTypescriptExpress, syntheticNameGenerator: defaultSyntheticNameGenerator, }, + "typescript-nextjs": { + language: "typescript", + type: "server", + run: generateTypescriptNextJS, + syntheticNameGenerator: defaultSyntheticNameGenerator, + }, } satisfies {[key: string]: OpenapiGenerator} export const templateNames = [ @@ -45,4 +52,5 @@ export const templateNames = [ "typescript-angular", "typescript-koa", "typescript-express", + "typescript-nextjs", ] as const satisfies Array diff --git a/packages/openapi-code-generator/src/templates.types.ts b/packages/openapi-code-generator/src/templates.types.ts index 892fde2b1..4cea958ea 100644 --- a/packages/openapi-code-generator/src/templates.types.ts +++ b/packages/openapi-code-generator/src/templates.types.ts @@ -1,3 +1,4 @@ +import type {IFsAdaptor} from "./core/file-system/fs-adaptor.ts" import type {Input, OperationGroupStrategy} from "./core/input.ts" import type {CompilerOptions} from "./core/loaders/tsconfig.loader.ts" import type {SyntheticNameGenerator} from "./core/synthetic-name-generator.ts" @@ -21,6 +22,7 @@ export interface OpenapiGeneratorConfig { enableTypedBasePaths: boolean groupingStrategy: OperationGroupStrategy filenameConvention: IdentifierConvention + fsAdaptor: IFsAdaptor } export interface OpenapiTypescriptGeneratorConfig diff --git a/packages/openapi-code-generator/src/typescript/common/compilation-units.ts b/packages/openapi-code-generator/src/typescript/common/compilation-units.ts index d3fa04adc..6239df1ff 100644 --- a/packages/openapi-code-generator/src/typescript/common/compilation-units.ts +++ b/packages/openapi-code-generator/src/typescript/common/compilation-units.ts @@ -15,6 +15,7 @@ export class CompilationUnit { readonly filename: string, readonly imports: ImportBuilder | undefined, readonly code: string, + readonly isAutogenerated: boolean = true, ) {} hasCode() { @@ -29,7 +30,7 @@ export class CompilationUnit { includeHeader?: boolean }): string { return [ - includeHeader ? FILE_HEADER : "", + includeHeader && this.isAutogenerated ? FILE_HEADER : "", this.imports ? `${this.imports.toString(allowUnusedImports ? "" : this.code)}\n` : "", diff --git a/packages/openapi-code-generator/src/typescript/common/import-builder.ts b/packages/openapi-code-generator/src/typescript/common/import-builder.ts index 1566131c4..c4377b384 100644 --- a/packages/openapi-code-generator/src/typescript/common/import-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/import-builder.ts @@ -89,6 +89,7 @@ export function categorizeImportSource(source: string): ImportCategory { export type ImportBuilderConfig = { unit?: {filename: string} | undefined + importAlias?: string | undefined includeFileExtensions: boolean } @@ -203,7 +204,7 @@ export class ImportBuilder { isType: boolean, ): void { // biome-ignore lint/style/noParameterAssign: normalization - from = this.normalizeFrom(from) + from = this.normalizeFrom(from, this.config.unit?.filename) if (!this.imports[from]) { this.imports[from] = { @@ -233,7 +234,7 @@ export class ImportBuilder { } } - private normalizeFrom(from: string) { + public normalizeFrom(from: string, filename?: string) { if (!this.config.includeFileExtensions && from.endsWith(".ts")) { // biome-ignore lint/style/noParameterAssign: normalization from = from.substring(0, from.length - ".ts".length) @@ -243,20 +244,29 @@ export class ImportBuilder { from = normalizeToUnix(from) } - if (this.config.unit) { - const unitFilename = this.config.unit.filename + const unitFilename = filename ?? this.config.unit?.filename + + if (unitFilename) { + const normalizedUnitFilename = normalizeToUnix(unitFilename) const isFromPath = from.startsWith("./") || from.startsWith("../") || path.posix.isAbsolute(from) if (isFromPath) { - const root = path.posix.isAbsolute(unitFilename) - ? path.posix.parse(unitFilename).root + if (this.config.importAlias) { + return ( + this.config.importAlias + + from.split(path.posix.sep).slice(1).join(path.posix.sep) + ) + } + + const root = path.posix.isAbsolute(normalizedUnitFilename) + ? path.posix.parse(normalizedUnitFilename).root : "/" const unitDir = path.posix.dirname( - path.posix.resolve(root, unitFilename), + path.posix.resolve(root, normalizedUnitFilename), ) const fromDir = path.posix.dirname(path.posix.resolve(root, from)) diff --git a/packages/openapi-code-generator/src/typescript/common/typescript-emitter.ts b/packages/openapi-code-generator/src/typescript/common/typescript-emitter.ts index b5867658e..d12d53f61 100644 --- a/packages/openapi-code-generator/src/typescript/common/typescript-emitter.ts +++ b/packages/openapi-code-generator/src/typescript/common/typescript-emitter.ts @@ -8,7 +8,7 @@ export class TypescriptEmitter { constructor( private readonly fsAdaptor: IFsAdaptor, private readonly formatter: IFormatter, - private readonly config: { + public readonly config: { destinationDirectory: string allowUnusedImports: boolean }, diff --git a/packages/openapi-code-generator/src/typescript/server/abstract-router-builder.ts b/packages/openapi-code-generator/src/typescript/server/abstract-router-builder.ts index 30ad59b1e..59d93ab56 100644 --- a/packages/openapi-code-generator/src/typescript/server/abstract-router-builder.ts +++ b/packages/openapi-code-generator/src/typescript/server/abstract-router-builder.ts @@ -17,6 +17,8 @@ export abstract class AbstractRouterBuilder implements ICompilable { "application/scim+json", "application/merge-patch+json", "application/x-www-form-urlencoded", + // todo: + // "multipart/form-data", "application/octet-stream", "text/json", "text/plain", diff --git a/packages/openapi-code-generator/src/typescript/server/server-operation-builder.ts b/packages/openapi-code-generator/src/typescript/server/server-operation-builder.ts index f07e6815f..95996a47b 100644 --- a/packages/openapi-code-generator/src/typescript/server/server-operation-builder.ts +++ b/packages/openapi-code-generator/src/typescript/server/server-operation-builder.ts @@ -40,6 +40,7 @@ export type ServerOperationResponseSchemas = { } export type Parameters = { + hasParams: boolean type: string path: { name: string @@ -126,7 +127,16 @@ export class ServerOperationBuilder { ${header.type} >` - return {type, path, query, header, body} + return { + hasParams: Boolean( + path.schema || query.schema || header.schema || body.schema, + ), + type, + path, + query, + header, + body, + } } responseValidator(): string { diff --git a/packages/openapi-code-generator/src/typescript/server/typescript-nextjs/typescript-nextjs-app-router-builder.ts b/packages/openapi-code-generator/src/typescript/server/typescript-nextjs/typescript-nextjs-app-router-builder.ts new file mode 100644 index 000000000..6a072c089 --- /dev/null +++ b/packages/openapi-code-generator/src/typescript/server/typescript-nextjs/typescript-nextjs-app-router-builder.ts @@ -0,0 +1,212 @@ +import { + type SourceFile, + StructureKind, + ts, + VariableDeclarationKind, +} from "ts-morph" +import type {Input} from "../../../core/input.ts" +import type {IROperation} from "../../../core/openapi-types-normalized.ts" +import { + type HttpMethod, + isDefined, + isHttpMethod, + titleCase, +} from "../../../core/utils.ts" +import { + CompilationUnit, + type ICompilable, +} from "../../common/compilation-units.ts" +import type {ImportBuilder} from "../../common/import-builder.ts" +import type {SchemaBuilder} from "../../common/schema-builders/schema-builder.ts" +import type {TypeBuilder} from "../../common/type-builder/type-builder.ts" +import { + ServerOperationBuilder, + type ServerSymbols, +} from "../server-operation-builder.ts" + +import SyntaxKind = ts.SyntaxKind + +export class TypescriptNextjsAppRouterBuilder implements ICompilable { + protected readonly capabilities = { + requestBody: { + mediaTypes: [ + "application/json", + "application/scim+json", + "application/merge-patch+json", + "application/x-www-form-urlencoded", + "application/octet-stream", + "text/json", + "text/plain", + "text/x-markdown", + ], + }, + } + + constructor( + private readonly filename: string, + readonly name: string, + private readonly input: Input, + private readonly imports: ImportBuilder, + private readonly types: TypeBuilder, + private readonly schemaBuilder: SchemaBuilder, + private readonly companionFilename: string, + private readonly sourceFile: SourceFile, + ) {} + + private readonly httpMethodsUsed = new Set() + + add(operation: IROperation): void { + const builder = new ServerOperationBuilder( + operation, + this.input, + this.types, + this.schemaBuilder, + { + requestBody: { + supportedMediaTypes: this.capabilities.requestBody.mediaTypes, + defaultMaxSize: "10mb", + }, + }, + ) + + const params = builder.parameters() + + const sourceFile = this.sourceFile + + const wrappingMethod = `_${operation.method.toUpperCase()}` + + this.httpMethodsUsed.add(operation.method) + + // Get the existing function, or create a new default one + const variableDeclaration = + sourceFile + .getVariableDeclaration(operation.method.toUpperCase()) + ?.getVariableStatement() || + sourceFile.addVariableStatement({ + isExported: true, + declarationKind: VariableDeclarationKind.Const, + declarations: [ + { + name: operation.method.toUpperCase(), + kind: StructureKind.VariableDeclaration, + initializer: `${wrappingMethod}(async (input, respond, context) => { + // TODO: implementation + return respond.withStatus(501).body({message: "not implemented"} as any) + }, async (err) => { + // TODO: implementation + return new Response(JSON.stringify({message: "not implemented"}), {status: 501}) + })`, + }, + ], + }) + + // Replace the params based on what inputs we have + // biome-ignore lint/style/noNonNullAssertion: todo + const declarations = variableDeclaration.getDeclarations()[0]! + const callExpression = declarations.getInitializerIfKindOrThrow( + SyntaxKind.CallExpression, + ) + + // biome-ignore lint/style/noNonNullAssertion: todo + const implementationFunction = callExpression + .getArguments()[0]! + .asKind(SyntaxKind.ArrowFunction)! + + // biome-ignore lint/complexity/noForEach: todo + implementationFunction?.getParameters().forEach((parameter) => { + parameter.remove() + }) + + if (params.hasParams) { + implementationFunction?.addParameter({ + name: `{${[ + params.path.schema ? "params" : undefined, + params.query.schema ? "query" : undefined, + params.body.schema ? "body" : undefined, + params.header.schema ? "headers" : undefined, + ] + .filter(isDefined) + .join(",")}}`, + }) + } + + implementationFunction?.addParameter({name: "respond"}) + implementationFunction?.addParameter({name: "request"}) + + const onErrorFunction = callExpression.getArguments()[1] + + if (!onErrorFunction) { + callExpression.addArgument(`async (err) => { + // TODO: implementation + return new Response(JSON.stringify({message: "not implemented"}), {status: 501}) + }`) + } else if (onErrorFunction.getKind() === SyntaxKind.ArrowFunction) { + for (const param of onErrorFunction + .asKind(SyntaxKind.ArrowFunction) + ?.getParameters() ?? []) { + param.remove() + } + + onErrorFunction + ?.asKind(SyntaxKind.ArrowFunction) + ?.addParameter({name: "err"}) + } else if (onErrorFunction.getKind() === SyntaxKind.FunctionExpression) { + // todo + } + } + + // TODO: duplication - should be shared with router builder + protected operationSymbols(operationId: string): ServerSymbols { + return { + implPropName: operationId, + implTypeName: titleCase(operationId), + responderName: `${titleCase(operationId)}Responder`, + responseBodyValidator: `${operationId}ResponseValidator`, + } + } + + toString(): string { + return this.sourceFile.getFullText() + } + + toCompilationUnit(): CompilationUnit { + // Reconcile imports - attempt to find an existing one and replace it with correct one + const imports = this.sourceFile.getImportDeclarations() + const from = this.imports.normalizeFrom( + `./${this.companionFilename}`, + `./${this.filename}`, + ) + // biome-ignore lint/complexity/noForEach: todo + imports + .filter((it) => it.getModuleSpecifierValue().includes(from)) + .forEach((it) => { + it.remove() + }) + + this.sourceFile.addImportDeclaration({ + namedImports: Array.from(this.httpMethodsUsed) + .map((it) => `_${it}`) + .sort(), + moduleSpecifier: from, + }) + + // Remove any methods that were removed from the spec + // biome-ignore lint/complexity/noForEach: todo + this.sourceFile + .getVariableDeclarations() + .filter((it) => { + const name = it.getName() + return isHttpMethod(name) && !this.httpMethodsUsed.has(name) + }) + .forEach((it) => { + it.remove() + }) + + return new CompilationUnit( + this.filename, + this.imports, + this.toString(), + false, + ) + } +} diff --git a/packages/openapi-code-generator/src/typescript/server/typescript-nextjs/typescript-nextjs-router-builder.ts b/packages/openapi-code-generator/src/typescript/server/typescript-nextjs/typescript-nextjs-router-builder.ts new file mode 100644 index 000000000..5e9eff7d3 --- /dev/null +++ b/packages/openapi-code-generator/src/typescript/server/typescript-nextjs/typescript-nextjs-router-builder.ts @@ -0,0 +1,218 @@ +import type {Input} from "../../../core/input.ts" +import {isDefined, titleCase} from "../../../core/utils.ts" +import type {ImportBuilder} from "../../common/import-builder.ts" +import type {SchemaBuilder} from "../../common/schema-builders/schema-builder.ts" +import type {TypeBuilder} from "../../common/type-builder/type-builder.ts" +import {constStatement, object} from "../../common/type-utils.ts" +import {buildExport} from "../../common/typescript-common.ts" +import {AbstractRouterBuilder} from "../abstract-router-builder.ts" +import type { + ServerOperationBuilder, + ServerSymbols, +} from "../server-operation-builder.ts" + +export class TypescriptNextjsRouterBuilder extends AbstractRouterBuilder { + private readonly operationTypes: { + operationId: string + statements: string[] + }[] = [] + + // biome-ignore lint/complexity/noUselessConstructor: todo + constructor( + filename: string, + name: string, + input: Input, + imports: ImportBuilder, + types: TypeBuilder, + schemaBuilder: SchemaBuilder, + ) { + super(filename, name, input, imports, types, schemaBuilder) + } + + protected buildImports(): void { + this.imports + .from("@nahkies/typescript-nextjs-runtime/server") + .add("OpenAPIRuntimeResponse", "parseQueryParameters") + .addType( + "OpenAPIRuntimeResponder", + "Params", + "StatusCode2xx", + "StatusCode3xx", + "StatusCode4xx", + "StatusCode5xx", + "StatusCode", + ) + + this.imports.from("next/server").addType("NextRequest", "NextResponse") + + this.imports + .from("@nahkies/typescript-nextjs-runtime/errors") + .add("OpenAPIRuntimeError", "RequestInputType") + + const schemaBuilderType = this.schemaBuilder.type + + switch (schemaBuilderType) { + case "joi": { + this.imports + .from("@nahkies/typescript-nextjs-runtime/joi") + .add("parseRequestInput", "responseValidationFactory") + break + } + case "zod-v3": { + this.imports + .from("@nahkies/typescript-nextjs-runtime/zod-v3") + .add("parseRequestInput", "responseValidationFactory") + break + } + case "zod-v4": { + this.imports + .from("@nahkies/typescript-nextjs-runtime/zod-v4") + .add("parseRequestInput", "responseValidationFactory") + break + } + default: { + throw new Error( + `unsupported schema builder type '${schemaBuilderType satisfies never}'`, + ) + } + } + } + + protected buildOperation(builder: ServerOperationBuilder): string { + const statements: string[] = [] + + const symbols = this.operationSymbols(builder.operationId) + const params = builder.parameters() + + if (params.path.schema) { + statements.push(constStatement(params.path.name, params.path.schema)) + } + + if (params.query.schema) { + statements.push(constStatement(params.query.name, params.query.schema)) + } + + if (params.header.schema) { + statements.push(constStatement(params.header.name, params.header.schema)) + } + + const responder = builder.responder( + "OpenAPIRuntimeResponder", + "OpenAPIRuntimeResponse", + ) + + this.operationTypes.push({ + operationId: builder.operationId, + statements: [ + buildExport({ + name: symbols.responderName, + value: responder.type, + kind: "type", + }), + buildExport({ + name: symbols.implTypeName, + value: `(${[ + params.hasParams ? `params: ${params.type}` : undefined, + `respond: ${symbols.responderName}`, + "request: NextRequest", + ] + .filter(isDefined) + .join(",")}) => Promise>`, + kind: "type", + }), + ], + }) + + const inputObject = object([ + this.parseRequestInput("params", { + name: params.path.name, + schema: params.path.schema, + source: "await params", + type: "RequestInputType.RouteParam", + }), + this.parseRequestInput("query", { + name: params.query.name, + schema: params.query.schema, + source: params.query.isSimpleQuery + ? "Object.fromEntries(request.nextUrl.searchParams.entries())" + : `parseQueryParameters(request.nextUrl.search, ${JSON.stringify(params.query.parameters)})`, + type: "RequestInputType.QueryString", + }), + this.parseRequestInput("body", { + name: params.body.schema, + schema: params.body.schema, + source: + params.body.contentType === "application/octet-stream" + ? "await request.blob()" + : params.body.contentType === "application/x-www-form-urlencoded" || + params.body.contentType === "multipart/form-data" + ? "await request.formData()" + : "await request.json()", + type: "RequestInputType.RequestBody", + comment: + params.body.schema && !params.body.isSupported + ? `// todo: request bodies with content-type '${params.body.contentType}' not yet supported` + : "", + }) + (params.body.schema && !params.body.isSupported ? " as never" : ""), + this.parseRequestInput("headers", { + name: params.header.name, + schema: params.header.schema, + source: 'Reflect.get(request, "headers")', + type: "RequestInputType.RequestHeader", + }), + ]) + + statements.push( + buildExport({ + name: `_${builder.method.toUpperCase()}`, + kind: "const", + value: `(implementation: ${symbols.implTypeName}, onError: (err: unknown) => Promise) => async (${["request: NextRequest", params.path.schema ? "{params}: {params: Promise}" : undefined].filter(isDefined).join(",")}): Promise => { +try { + ${params.hasParams ? `const input = ${inputObject}` : ""} + const responder = ${responder.implementation} + const responseValidator = ${builder.responseValidator()} + + const res = await implementation(${[params.hasParams ? "input" : undefined, "responder", "request"].filter(isDefined).join(",")}) + .then(it => { + if(it instanceof Response) { + return it + } + const {status, body} = it.unpack() + const validatedBody = responseValidator(status, body) + + return validatedBody !== undefined ? Response.json(validatedBody, {status}) : new Response(undefined, {status}) + }) + .catch(err => { throw OpenAPIRuntimeError.HandlerError(err) }) + + return res + } catch (err) { + return await onError(err) + } + }`, + }), + ) + + return statements.join("\n\n") + } + + protected operationSymbols(operationId: string): ServerSymbols { + return { + implPropName: operationId, + implTypeName: titleCase(operationId), + responderName: `${titleCase(operationId)}Responder`, + responseBodyValidator: `${operationId}ResponseValidator`, + } + } + + protected buildRouter( + routerName: string, + routerStatements: string[], + ): string { + return ` +// ${routerName} +${this.operationTypes.flatMap((it) => it.statements).join("\n\n")} + +${routerStatements.join("\n\n")} +` + } +} diff --git a/packages/openapi-code-generator/src/typescript/server/typescript-nextjs/typescript-nextjs.generator.ts b/packages/openapi-code-generator/src/typescript/server/typescript-nextjs/typescript-nextjs.generator.ts new file mode 100644 index 000000000..0685f18aa --- /dev/null +++ b/packages/openapi-code-generator/src/typescript/server/typescript-nextjs/typescript-nextjs.generator.ts @@ -0,0 +1,196 @@ +// biome-ignore lint/style/useNodejsImportProtocol: todo +import path from "path" +import {Project, type SourceFile} from "ts-morph" +import type {IFsAdaptor} from "../../../core/file-system/fs-adaptor.ts" +import type {CompilerOptions} from "../../../core/loaders/tsconfig.loader.ts" +import {isTruthy} from "../../../core/utils.ts" +import type {OpenapiTypescriptGeneratorConfig} from "../../../templates.types.ts" +import {TypescriptFetchClientBuilder} from "../../client/typescript-fetch/typescript-fetch-client-builder.ts" +import {ImportBuilder} from "../../common/import-builder.ts" +import {schemaBuilderFactory} from "../../common/schema-builders/schema-builder.ts" +import {TypeBuilder} from "../../common/type-builder/type-builder.ts" +import {TypescriptNextjsAppRouterBuilder} from "./typescript-nextjs-app-router-builder.ts" +import {TypescriptNextjsRouterBuilder} from "./typescript-nextjs-router-builder.ts" + +function findImportAlias(dest: string, compilerOptions: CompilerOptions) { + const relative = `./${path.relative(process.cwd(), dest)}/*` + + const alias = Object.entries(compilerOptions.paths || {}).find(([, paths]) => + paths.some((p) => p === relative || relative.endsWith(p.substring(1))), + ) + + return alias ? alias[0].replace("*", "") : undefined +} + +export async function generateTypescriptNextJS( + config: OpenapiTypescriptGeneratorConfig, +): Promise { + const {input, emitter, allowAny} = config + + const importAlias = findImportAlias( + config.emitter.config.destinationDirectory, + config.compilerOptions, + ) + + // biome-ignore lint/complexity/useLiteralKeys: todo + const subDirectory = process.env["OPENAPI_INTEGRATION_TESTS"] + ? path.basename(config.input.loader.entryPointKey) + : "" + + const appDirectory = [".", "app", subDirectory] + .filter(isTruthy) + .join(path.sep) + + const generatedDirectory = [".", "generated", subDirectory] + .filter(isTruthy) + .join(path.sep) + + const importBuilderConfig = {includeFileExtensions: false} + const schemaBuilderImports = new ImportBuilder(importBuilderConfig) + + const rootTypeBuilder = await TypeBuilder.fromSchemaProvider( + [generatedDirectory, "models.ts"].join(path.sep), + input, + config.compilerOptions, + {allowAny}, + ) + + const rootSchemaBuilder = await schemaBuilderFactory( + [generatedDirectory, "schemas.ts"].join(path.sep), + input, + config.schemaBuilder, + {allowAny}, + schemaBuilderImports, + rootTypeBuilder, + ) + + const project = new Project({useInMemoryFileSystem: true}) + + const serverRouters = ( + await Promise.all( + input.groupedOperations("route").map(async (group) => { + const filename = path.join( + generatedDirectory, + routeToNextJSFilepath(group.name), + ) + + const routerImports = new ImportBuilder({ + unit: {filename}, + includeFileExtensions: false, + importAlias, + }) + + const routerBuilder = new TypescriptNextjsRouterBuilder( + filename, + group.name, + input, + routerImports, + rootTypeBuilder.withImports(routerImports), + rootSchemaBuilder.withImports(routerImports), + ) + + const nextJsAppRouterPath = path.join( + appDirectory, + routeToNextJSFilepath(group.name), + ) + + const appRouterImports = new ImportBuilder({ + unit: {filename: nextJsAppRouterPath}, + includeFileExtensions: false, + importAlias, + }) + + const sourceFile = await loadExistingRouteImplementation({ + fsAdaptor: config.fsAdaptor, + project, + destinationDirectory: emitter.config.destinationDirectory, + nextJsAppRouterPath, + }) + + const nextJSAppRouterBuilder = new TypescriptNextjsAppRouterBuilder( + nextJsAppRouterPath, + group.name, + input, + appRouterImports, + rootTypeBuilder.withImports(appRouterImports), + rootSchemaBuilder.withImports(appRouterImports), + filename, + sourceFile, + ) + + for (const operation of group.operations) { + routerBuilder.add(operation) + nextJSAppRouterBuilder.add(operation) + } + + return [ + routerBuilder.toCompilationUnit(), + nextJSAppRouterBuilder.toCompilationUnit(), + ] + }), + ) + ).flat() + + const clientOutputPath = [generatedDirectory, "client.ts"].join(path.sep) + const clientImportBuilder = new ImportBuilder({ + unit: {filename: clientOutputPath}, + importAlias, + includeFileExtensions: false, + }) + + const fetchClientBuilder = new TypescriptFetchClientBuilder( + clientOutputPath, + "ApiClient", + input, + clientImportBuilder, + rootTypeBuilder.withImports(clientImportBuilder), + rootSchemaBuilder.withImports(clientImportBuilder), + { + enableRuntimeResponseValidation: config.enableRuntimeResponseValidation, + enableTypedBasePaths: config.enableTypedBasePaths, + }, + ) + + input.allOperations().map((it) => fetchClientBuilder.add(it)) + + await emitter.emitGenerationResult([ + ...serverRouters, + fetchClientBuilder.toCompilationUnit(), + rootTypeBuilder.toCompilationUnit(), + rootSchemaBuilder.toCompilationUnit(), + ]) +} + +async function loadExistingRouteImplementation({ + fsAdaptor, + project, + destinationDirectory, + nextJsAppRouterPath, +}: { + fsAdaptor: IFsAdaptor + project: Project + destinationDirectory: string + nextJsAppRouterPath: string +}): Promise { + const exists = await fsAdaptor.exists( + path.join(destinationDirectory, nextJsAppRouterPath), + ) + + const source = exists + ? await fsAdaptor.readFile( + path.join(destinationDirectory, nextJsAppRouterPath), + ) + : "" + + return project.createSourceFile(nextJsAppRouterPath, source) +} + +function routeToNextJSFilepath(route: string): string { + const parts = route + .split("/") + .map((part) => part.replaceAll("{", "[").replaceAll("}", "]")) + + parts.push("route.ts") + + return path.join(...parts) +} diff --git a/packages/typescript-nextjs-runtime/README.md b/packages/typescript-nextjs-runtime/README.md new file mode 100644 index 000000000..483c37be5 --- /dev/null +++ b/packages/typescript-nextjs-runtime/README.md @@ -0,0 +1,10 @@ +# @nahkies/typescript-nextjs-runtime + +[![CI/CD](https://github.com/mnahkies/openapi-code-generator/actions/workflows/ci.yml/badge.svg)](https://github.com/mnahkies/openapi-code-generator/actions?query=branch%3Amain+event%3Apush) +[![npm](https://img.shields.io/npm/dm/%40nahkies%2Ftypescript-nextjs-runtime.svg)](https://www.npmjs.com/package/@nahkies/typescript-nextjs-runtime) + +This is a supporting package for code generated using [@nahkies/openapi-code-generator](https://www.npmjs.com/package/@nahkies/openapi-code-generator) using the Typescript NextJS server stubs template. + +You can [read the docs](https://openapi-code-generator.nahkies.co.nz/guides/server-templates/typescript-nextjs) to find out more! + +It's not intended by be used standalone. Similar in spirit to [tslib](https://www.npmjs.com/package/tslib) diff --git a/packages/typescript-nextjs-runtime/package.json b/packages/typescript-nextjs-runtime/package.json new file mode 100644 index 000000000..acf7db7c9 --- /dev/null +++ b/packages/typescript-nextjs-runtime/package.json @@ -0,0 +1,117 @@ +{ + "name": "@nahkies/typescript-nextjs-runtime", + "version": "0.20.1", + "description": "Runtime package for code generated by @nahkies/openapi-code-generator using the typescript-nextjs template", + "license": "MIT", + "author": { + "name": "Michael Nahkies", + "email": "support@nahkies.co.nz" + }, + "homepage": "https://openapi-code-generator.nahkies.co.nz/", + "repository": { + "type": "git", + "url": "https://github.com/mnahkies/openapi-code-generator.git", + "directory": "packages/typescript-nextjs-runtime" + }, + "bugs": { + "url": "https://github.com/mnahkies/openapi-code-generator/issues" + }, + "exports": { + "./errors": { + "import": { + "types": "./dist/esm/errors.d.mts", + "default": "./dist/esm/errors.mjs" + }, + "require": { + "types": "./dist/cjs/errors.d.cts", + "default": "./dist/cjs/errors.cjs" + } + }, + "./server": { + "import": { + "types": "./dist/esm/server.d.mts", + "default": "./dist/esm/server.mjs" + }, + "require": { + "types": "./dist/cjs/server.d.cts", + "default": "./dist/cjs/server.cjs" + } + }, + "./joi": { + "import": { + "types": "./dist/esm/joi.d.mts", + "default": "./dist/esm/joi.mjs" + }, + "require": { + "types": "./dist/cjs/joi.d.cts", + "default": "./dist/cjs/joi.cjs" + } + }, + "./zod-v3": { + "import": { + "types": "./dist/esm/zod-v3.d.mts", + "default": "./dist/esm/zod-v3.mjs" + }, + "require": { + "types": "./dist/cjs/zod-v3.d.cts", + "default": "./dist/cjs/zod-v3.cjs" + } + }, + "./zod-v4": { + "import": { + "types": "./dist/esm/zod-v4.d.mts", + "default": "./dist/esm/zod-v4.mjs" + }, + "require": { + "types": "./dist/cjs/zod-v4.d.cts", + "default": "./dist/cjs/zod-v4.cjs" + } + } + }, + "scripts": { + "clean": "rm -rf ./dist && rm -f tsconfig.tsbuildinfo", + "build": "tsc -p tsconfig.json", + "bundle": "tsdown", + "test": "vitest run" + }, + "dependencies": { + "@nahkies/typescript-common-runtime": "workspace:^" + }, + "peerDependencies": { + "joi": "^17.13.3 || ^18.0.1", + "next": "catalog:", + "zod": "^4.3.6" + }, + "peerDependenciesMeta": { + "joi": { + "optional": true + }, + "zod": { + "optional": true + } + }, + "devDependencies": { + "joi": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:typescript7", + "vitest": "catalog:", + "zod": "^4.4.3" + }, + "files": [ + "src", + "dist", + "README.md", + "CHANGELOG.md", + "tsconfig.json" + ], + "keywords": [ + "@nahkies/openapi-code-generator", + "runtime", + "typescript-nextjs", + "nextjs", + "zod" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/typescript-nextjs-runtime/src/errors.ts b/packages/typescript-nextjs-runtime/src/errors.ts new file mode 100644 index 000000000..4410ef1d5 --- /dev/null +++ b/packages/typescript-nextjs-runtime/src/errors.ts @@ -0,0 +1,50 @@ +export enum RequestInputType { + RouteParam = "route params", + QueryString = "querystring", + RequestBody = "request body", + RequestHeader = "request header", +} + +export class OpenAPIRuntimeError extends Error { + private constructor( + message: string, + cause: unknown, + public readonly phase: + | "request_validation" + | "request_handler" + | "response_validation", + ) { + super(message, {cause}) + } + + static RequestError( + cause: unknown, + inputType: RequestInputType, + ): OpenAPIRuntimeError { + return new OpenAPIRuntimeError( + `Request validation failed parsing ${inputType}`, + cause, + "request_validation", + ) + } + + static HandlerError(cause: unknown) { + return new OpenAPIRuntimeError( + "Request handler threw unhandled exception", + cause, + "request_handler", + ) + } + + static ResponseError(cause: unknown) { + return new OpenAPIRuntimeError( + "Response body failed validation", + cause, + "response_validation", + ) + } + + static isOpenAPIError(err: unknown): err is OpenAPIRuntimeError { + return err instanceof OpenAPIRuntimeError + } +} diff --git a/packages/typescript-nextjs-runtime/src/joi.ts b/packages/typescript-nextjs-runtime/src/joi.ts new file mode 100644 index 000000000..d5573006a --- /dev/null +++ b/packages/typescript-nextjs-runtime/src/joi.ts @@ -0,0 +1,80 @@ +import type {Schema as JoiSchema} from "joi" +import {OpenAPIRuntimeError, type RequestInputType} from "./errors" + +// Note: joi types don't appear to have an equivalent of z.infer, +// hence any seems about as good as we can do here. +export function parseRequestInput( + schema: Schema, + input: unknown, + type: RequestInputType, + // biome-ignore lint/suspicious/noExplicitAny: needed +): any +export function parseRequestInput( + schema: undefined, + input: unknown, + type: RequestInputType, +): undefined +export function parseRequestInput( + schema: Schema | undefined, + input: unknown, + type: RequestInputType, + // biome-ignore lint/suspicious/noExplicitAny: needed +): any { + try { + if (!schema) { + return undefined + } + + const result = schema.validate(input, {stripUnknown: true}) + + if (result.error) { + throw result.error + } + + return result.value + } catch (err) { + throw OpenAPIRuntimeError.RequestError(err, type) + } +} + +export function responseValidationFactory( + possibleResponses: [string, JoiSchema][], + defaultResponse?: JoiSchema, +) { + // Exploit the natural ordering matching the desired specificity of eg: 404 vs 4xx + possibleResponses.sort((x, y) => (x[0] < y[0] ? -1 : 1)) + + return (status: number, value: unknown) => { + try { + for (const [match, schema] of possibleResponses) { + const isMatch = + (/^\d+$/.test(match) && String(status) === match) || + (/^\d[xX]{2}$/.test(match) && String(status)[0] === match[0]) + + if (isMatch) { + const result = schema.validate(value) + + if (result.error) { + throw result.error + } + + return result.value + } + } + + if (defaultResponse) { + const result = defaultResponse.validate(value) + + if (result.error) { + throw result.error + } + + return result.value + } + + return value + } catch (err) { + throw OpenAPIRuntimeError.ResponseError(err) + } + } +} diff --git a/packages/typescript-nextjs-runtime/src/server.ts b/packages/typescript-nextjs-runtime/src/server.ts new file mode 100644 index 000000000..a25f6987b --- /dev/null +++ b/packages/typescript-nextjs-runtime/src/server.ts @@ -0,0 +1,41 @@ +import type { + Res, + // SkipResponse, + StatusCode, +} from "@nahkies/typescript-common-runtime/types" + +export {parseQueryParameters} from "@nahkies/typescript-common-runtime/query-parser" +export { + type Params, + type Res, + SkipResponse, + type StatusCode, + type StatusCode1xx, + type StatusCode2xx, + type StatusCode3xx, + type StatusCode4xx, + type StatusCode5xx, +} from "@nahkies/typescript-common-runtime/types" + +export class OpenAPIRuntimeResponse { + private _body?: Type + + constructor(private readonly status: StatusCode) {} + + body(body: Type): this { + this._body = body + return this + } + + unpack(): Res { + return {status: this.status, body: this._body} + } +} + +export type OpenAPIRuntimeResponder< + Status extends StatusCode = StatusCode, + // biome-ignore lint/suspicious/noExplicitAny: needed + Type = any, +> = { + withStatus: (status: Status) => OpenAPIRuntimeResponse +} diff --git a/packages/typescript-nextjs-runtime/src/zod-v3.ts b/packages/typescript-nextjs-runtime/src/zod-v3.ts new file mode 100644 index 000000000..3664aaf9d --- /dev/null +++ b/packages/typescript-nextjs-runtime/src/zod-v3.ts @@ -0,0 +1,56 @@ +import type {z} from "zod/v3" +import {OpenAPIRuntimeError, type RequestInputType} from "./errors" + +export function parseRequestInput( + schema: Schema, + input: unknown, + type: RequestInputType, +): z.infer +export function parseRequestInput( + schema: undefined, + input: unknown, + type: RequestInputType, +): undefined +export function parseRequestInput( + schema: Schema | undefined, + input: unknown, + type: RequestInputType, +): z.infer | undefined { + try { + return schema?.parse(input) + } catch (err) { + throw OpenAPIRuntimeError.RequestError(err, type) + } +} + +// TODO: optional response validation +export function responseValidationFactory( + possibleResponses: [string, z.ZodTypeAny][], + defaultResponse?: z.ZodTypeAny, +) { + // Exploit the natural ordering matching the desired specificity of eg: 404 vs 4xx + possibleResponses.sort((x, y) => (x[0] < y[0] ? -1 : 1)) + + return (status: number, value: unknown) => { + try { + for (const [match, schema] of possibleResponses) { + const isMatch = + (/^\d+$/.test(match) && String(status) === match) || + (/^\d[xX]{2}$/.test(match) && String(status)[0] === match[0]) + + if (isMatch) { + return schema.parse(value) + } + } + + if (defaultResponse) { + return defaultResponse.parse(value) + } + + // TODO: throw on unmatched response + return value + } catch (err) { + throw OpenAPIRuntimeError.ResponseError(err) + } + } +} diff --git a/packages/typescript-nextjs-runtime/src/zod-v4.ts b/packages/typescript-nextjs-runtime/src/zod-v4.ts new file mode 100644 index 000000000..575816ef7 --- /dev/null +++ b/packages/typescript-nextjs-runtime/src/zod-v4.ts @@ -0,0 +1,56 @@ +import type {z} from "zod/v4" +import {OpenAPIRuntimeError, type RequestInputType} from "./errors" + +export function parseRequestInput( + schema: Schema, + input: unknown, + type: RequestInputType, +): z.infer +export function parseRequestInput( + schema: undefined, + input: unknown, + type: RequestInputType, +): undefined +export function parseRequestInput( + schema: Schema | undefined, + input: unknown, + type: RequestInputType, +): z.infer | undefined { + try { + return schema?.parse(input) + } catch (err) { + throw OpenAPIRuntimeError.RequestError(err, type) + } +} + +// TODO: optional response validation +export function responseValidationFactory( + possibleResponses: [string, z.ZodTypeAny][], + defaultResponse?: z.ZodTypeAny, +) { + // Exploit the natural ordering matching the desired specificity of eg: 404 vs 4xx + possibleResponses.sort((x, y) => (x[0] < y[0] ? -1 : 1)) + + return (status: number, value: unknown) => { + try { + for (const [match, schema] of possibleResponses) { + const isMatch = + (/^\d+$/.test(match) && String(status) === match) || + (/^\d[xX]{2}$/.test(match) && String(status)[0] === match[0]) + + if (isMatch) { + return schema.parse(value) + } + } + + if (defaultResponse) { + return defaultResponse.parse(value) + } + + // TODO: throw on unmatched response + return value + } catch (err) { + throw OpenAPIRuntimeError.ResponseError(err) + } + } +} diff --git a/packages/typescript-nextjs-runtime/tsconfig.json b/packages/typescript-nextjs-runtime/tsconfig.json new file mode 100644 index 000000000..368510a53 --- /dev/null +++ b/packages/typescript-nextjs-runtime/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "references": [ + { + "path": "../typescript-common-runtime" + } + ] +} diff --git a/packages/typescript-nextjs-runtime/tsdown.config.mts b/packages/typescript-nextjs-runtime/tsdown.config.mts new file mode 100644 index 000000000..aee5751c3 --- /dev/null +++ b/packages/typescript-nextjs-runtime/tsdown.config.mts @@ -0,0 +1,34 @@ +import {defineConfig} from "tsdown" +import packageJson from "./package.json" with {type: "json"} + +export default defineConfig({ + name: packageJson.name, + entry: [ + "./src/server.ts", + "./src/errors.ts", + "./src/joi.ts", + "./src/zod-v4.ts", + "./src/zod-v3.ts", + ], + + target: "esnext", + dts: true, + sourcemap: true, + failOnWarn: true, + logLevel: "warn", + publint: true, + attw: {profile: "node16"}, + + deps: { + onlyBundle: ["@nahkies/typescript-common-runtime"], + }, + + format: { + esm: { + outDir: "./dist/esm", + }, + cjs: { + outDir: "./dist/cjs", + }, + }, +}) diff --git a/packages/typescript-nextjs-runtime/vitest.config.mts b/packages/typescript-nextjs-runtime/vitest.config.mts new file mode 100644 index 000000000..2420afb2d --- /dev/null +++ b/packages/typescript-nextjs-runtime/vitest.config.mts @@ -0,0 +1,10 @@ +import {defineConfig} from "vitest/config" +import {baseTestConfig} from "../../vitest.base" +import pkg from "./package.json" with {type: "json"} + +export default defineConfig({ + test: { + ...baseTestConfig.test, + name: pkg.name, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fec1fc3c..c01519fbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,8 +64,8 @@ catalogs: specifier: ^1.17.6 version: 1.17.6 '@koa/router': - specifier: ^15.6.0 - version: 15.6.0 + specifier: ^15.7.0 + version: 15.7.0 '@lerna-lite/cli': specifier: ^5.4.0 version: 5.4.0 @@ -217,7 +217,7 @@ importers: dependencies: '@koa/router': specifier: 'catalog:' - version: 15.6.0(koa@3.2.1) + version: 15.7.0(koa@3.2.1) '@nahkies/typescript-axios-runtime': specifier: workspace:* version: link:../packages/typescript-axios-runtime @@ -283,10 +283,10 @@ importers: specifier: 'catalog:' version: 22.0.5(@angular/common@22.0.5(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2))(@angular/platform-browser@22.0.5(@angular/common@22.0.5(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.0.5(@angular/compiler@22.0.5)(rxjs@7.8.2)))(rxjs@7.8.2) rxjs: - specifier: ~7.8.0 + specifier: ~7.8.2 version: 7.8.2 tslib: - specifier: ^2.3.0 + specifier: ^2.8.1 version: 2.8.1 devDependencies: '@angular/build': @@ -378,7 +378,7 @@ importers: dependencies: '@koa/router': specifier: 'catalog:' - version: 15.6.0(koa@3.2.1) + version: 15.7.0(koa@3.2.1) '@nahkies/typescript-koa-runtime': specifier: workspace:* version: link:../../packages/typescript-koa-runtime @@ -402,6 +402,43 @@ importers: specifier: catalog:typescript7 version: 7.0.2 + integration-tests/typescript-nextjs: + dependencies: + '@nahkies/typescript-fetch-runtime': + specifier: workspace:* + version: link:../../packages/typescript-fetch-runtime + '@nahkies/typescript-nextjs-runtime': + specifier: workspace:* + version: link:../../packages/typescript-nextjs-runtime + joi: + specifier: 'catalog:' + version: 18.2.3 + next: + specifier: 'catalog:' + version: 16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.99.0) + react: + specifier: 'catalog:' + version: 19.2.7 + react-dom: + specifier: 'catalog:' + version: 19.2.7(react@19.2.7) + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^22.20.0 + version: 22.20.0 + '@types/react': + specifier: 'catalog:' + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + typescript: + specifier: catalog:typescript6 + version: 6.0.3 + packages/documentation: dependencies: '@hookform/resolvers': @@ -504,6 +541,9 @@ importers: prettier: specifier: 'catalog:' version: 3.9.4 + ts-morph: + specifier: ^26.0.0 + version: 26.0.0 tslib: specifier: ^2.8.1 version: 2.8.1 @@ -536,19 +576,19 @@ importers: specifier: ^4.17.24 version: 4.17.24 '@typespec/compiler': - specifier: ^1.12.0 + specifier: ^1.13.0 version: 1.13.0(@types/node@22.20.0) '@typespec/events': specifier: 0.83.0 version: 0.83.0(@typespec/compiler@1.13.0(@types/node@22.20.0)) '@typespec/http': - specifier: ^1.12.0 + specifier: ^1.13.0 version: 1.13.0(@typespec/compiler@1.13.0(@types/node@22.20.0))(@typespec/streams@0.83.0(@typespec/compiler@1.13.0(@types/node@22.20.0))) '@typespec/openapi': - specifier: ^1.12.0 + specifier: ^1.13.0 version: 1.13.0(@typespec/compiler@1.13.0(@types/node@22.20.0))(@typespec/http@1.13.0(@typespec/compiler@1.13.0(@types/node@22.20.0))(@typespec/streams@0.83.0(@typespec/compiler@1.13.0(@types/node@22.20.0)))) '@typespec/openapi3': - specifier: ^1.12.0 + specifier: ^1.13.0 version: 1.13.0(d40defdf02a20bbce34f949d2f323956) '@typespec/rest': specifier: 0.83.0 @@ -704,7 +744,7 @@ importers: version: 5.0.0 '@koa/router': specifier: 'catalog:' - version: 15.6.0(koa@3.2.1) + version: 15.7.0(koa@3.2.1) '@nahkies/typescript-common-runtime': specifier: workspace:^ version: link:../typescript-common-runtime @@ -736,6 +776,31 @@ importers: specifier: ^4.4.3 version: 4.4.3 + packages/typescript-nextjs-runtime: + dependencies: + '@nahkies/typescript-common-runtime': + specifier: workspace:^ + version: link:../typescript-common-runtime + next: + specifier: 'catalog:' + version: 16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.99.0) + devDependencies: + joi: + specifier: 'catalog:' + version: 18.2.3 + tsdown: + specifier: 'catalog:' + version: 0.22.5(@arethetypeswrong/core@0.18.4)(publint@0.3.21)(tsx@4.23.0)(typescript@7.0.2) + typescript: + specifier: catalog:typescript7 + version: 7.0.2 + vitest: + specifier: 'catalog:' + version: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.3.5(@types/node@22.20.0)(sass@1.99.0)(tsx@4.23.0)(yaml@2.9.0)) + zod: + specifier: ^4.4.3 + version: 4.4.3 + packages: '@algolia/abtesting@1.18.0': @@ -1674,8 +1739,8 @@ packages: '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - '@iconify/utils@3.1.3': - resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@iconify/utils@3.1.4': + resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} @@ -2042,8 +2107,8 @@ packages: resolution: {integrity: sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==} engines: {node: '>= 14.0.0'} - '@koa/router@15.6.0': - resolution: {integrity: sha512-iEOXlvGIBqSNkGXrg0XtMARAOm5zA24oedXxiTGEkrD4JgwVjfRDddCQvW1s4WEcwDYvyecRbf8BikXsuEEj8w==} + '@koa/router@15.7.0': + resolution: {integrity: sha512-WaAlk4TOl/O0rhTpOR0l052gz03syPMmI6Pe2gd7v3ubjfv5UcSGcnb0Y/J5NNC/ln+5FiUqPJTc/a15I+XqAA==} engines: {node: '>= 20'} peerDependencies: koa: ^2.0.0 || ^3.0.0 @@ -2141,8 +2206,8 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} - '@mermaid-js/parser@1.1.1': - resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} + '@mermaid-js/parser@1.2.0': + resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} @@ -3089,16 +3154,16 @@ packages: cpu: [x64] os: [win32] - '@scalar/helpers@0.8.2': - resolution: {integrity: sha512-qNbqUjSB3S4Gr4A0oANcm5G1Ip+EqBxICYKhe9YzmnaBpbmW6shxqpiivApTvvuDf+uIhR3uMwWyVQbYcGLsxA==} + '@scalar/helpers@0.9.0': + resolution: {integrity: sha512-M34CLRCttqC1bXthI/QSzQj0s5C6nrU2PFWf/vOT3RpycbiGDGQbqR+5RfFzpOIQvRqbHfNdcRbeiZBw+vCbkQ==} engines: {node: '>=22'} - '@scalar/json-magic@0.12.16': - resolution: {integrity: sha512-w8cDbZhHCzmIblWx92IVWoAXsbI4Fz3m++jiBANTSO1hgphF6UqEPQiCt3wnMPaxaanjMQxjS/iBk1UGXR2EGA==} + '@scalar/json-magic@0.12.17': + resolution: {integrity: sha512-Vw2nrUDIjhvMP6vxFtkiiFlabJ6SyTtfn1BsOxgnr1hIB+/rkngMguiDzl5em21VjyfFGIoADia+QWKM2hdcdA==} engines: {node: '>=22'} - '@scalar/openapi-parser@0.28.7': - resolution: {integrity: sha512-E6beEdTsJxUStxOmY1knQvSNJq6LTiXOsRX2WTrfmU6d/kiATn6IKkAU0kXtAZkaYCGU4UCEmBFHCMmNKn0JLA==} + '@scalar/openapi-parser@0.28.8': + resolution: {integrity: sha512-BO98D3TLfKNL80UnE1sIAmI6DQRmOaH0AHnlAooTn1sQjNbRDaeHyRO53EEjo7HjFEdHeQtiRzoXmMB4jvt8AA==} engines: {node: '>=22'} '@scalar/openapi-types@0.9.1': @@ -3194,14 +3259,14 @@ packages: '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} - '@tanstack/react-virtual@3.14.3': - resolution: {integrity: sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==} + '@tanstack/react-virtual@3.14.5': + resolution: {integrity: sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/virtual-core@3.17.1': - resolution: {integrity: sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==} + '@tanstack/virtual-core@3.17.3': + resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==} '@theguild/remark-mermaid@0.3.0': resolution: {integrity: sha512-Fy1J4FSj8totuHsHFpaeWyWRaRSIvpzGTRoEfnNJc1JmLV9uV70sYE3zcT+Jj5Yw20Xq4iCsiT+3Ho49BBZcBQ==} @@ -3211,6 +3276,9 @@ packages: '@theguild/remark-npm2yarn@0.3.3': resolution: {integrity: sha512-ma6DvR03gdbvwqfKx1omqhg9May/VYGdMHvTzB4VuxkyS7KzfZ/lzrj43hmcsggpMje0x7SADA/pcMph0ejRnA==} + '@ts-morph/common@0.27.0': + resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@ts-morph/common@0.28.1': resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==} @@ -3315,8 +3383,8 @@ packages: '@types/d3-quadtree@3.0.6': resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} - '@types/d3-random@3.0.3': - resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} '@types/d3-scale-chromatic@3.1.0': resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} @@ -4005,8 +4073,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.41: - resolution: {integrity: sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==} + baseline-browser-mapping@2.10.42: + resolution: {integrity: sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==} engines: {node: '>=6.0.0'} hasBin: true @@ -4646,8 +4714,8 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es-toolkit@1.47.1: - resolution: {integrity: sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==} + es-toolkit@1.49.0: + resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -4852,8 +4920,8 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} - fs-extra@11.3.5: - resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} + fs-extra@11.3.6: + resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} engines: {node: '>=14.14'} fs-minipass@3.0.3: @@ -5544,8 +5612,8 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - mermaid@11.15.0: - resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==} + mermaid@11.16.0: + resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} mhchemparser@4.2.1: resolution: {integrity: sha512-kYmyrCirqJf3zZ9t/0wGgRZ4/ZJw//VwaRVGA75C4nhE60vtnIzhl9J9ndkX/h6hxSN7pjg/cE0VxbnNM+bnDQ==} @@ -6126,8 +6194,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.15.2: - resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} quansync@1.0.0: @@ -6136,8 +6204,8 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} raw-body@2.5.3: @@ -6615,8 +6683,8 @@ packages: resolution: {integrity: sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==} engines: {node: '>=18'} - tabbable@6.4.0: - resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} @@ -6700,6 +6768,9 @@ packages: resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} engines: {node: '>=6.10'} + ts-morph@26.0.0: + resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} + ts-morph@27.0.2: resolution: {integrity: sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==} @@ -6753,16 +6824,16 @@ packages: resolution: {integrity: sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==} engines: {node: ^20.17.0 || >=22.9.0} - twoslash-protocol@0.3.8: - resolution: {integrity: sha512-HmvAHoiEviK8LqvAQyc9/irkdvwTUiR1fHmNwH/0gq8EHxyBt4PWVPixjEXg6wJu1u6yBrILEWXGK9Kw58/8yQ==} + twoslash-protocol@0.3.9: + resolution: {integrity: sha512-9/iwp+CXOnjFMPQuPL5PkuRbZnDoNpBvtJCLs9t8kDYkL3YHujbvnHfZA1i5fApDftVEdBw+T/4F+dH5kIzpYQ==} - twoslash@0.3.8: - resolution: {integrity: sha512-OeDz0kDl8sqPUN3nr7gqcvOs70f5lZsdhKYTX3/SgB9OvdadzzoYJI/4SBXhXV1HG8E9fLc+e17itoRYTxmoig==} + twoslash@0.3.9: + resolution: {integrity: sha512-rDclk+OtzuTX+tnea7DYLCkqGQ3eP0IyfD+kzUJ7t46X/NzlaxwrhecmEBNuSCuEn3V+n1PhcjUUQQ7gUJzX5Q==} peerDependencies: typescript: ^5.5.0 || ^6.0.0 - type-fest@5.7.0: - resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} engines: {node: '>=20'} type-is@1.6.18: @@ -7911,7 +7982,7 @@ snapshots: '@floating-ui/utils': 0.2.11 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - tabbable: 6.4.0 + tabbable: 6.5.0 '@floating-ui/utils@0.2.11': {} @@ -7949,7 +8020,7 @@ snapshots: '@floating-ui/react': 0.26.28(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@react-aria/focus': 3.22.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@react-aria/interactions': 3.28.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/react-virtual': 3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-virtual': 3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) use-sync-external-store: 1.6.0(react@19.2.7) @@ -8006,7 +8077,7 @@ snapshots: '@iconify/types@2.0.0': {} - '@iconify/utils@3.1.3': + '@iconify/utils@3.1.4': dependencies: '@antfu/install-pkg': 1.1.0 '@iconify/types': 2.0.0 @@ -8318,7 +8389,7 @@ snapshots: dependencies: vary: 1.1.2 - '@koa/router@15.6.0(koa@3.2.1)': + '@koa/router@15.7.0(koa@3.2.1)': dependencies: debug: 4.4.3 http-errors: 2.0.1 @@ -8493,7 +8564,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@mermaid-js/parser@1.1.1': + '@mermaid-js/parser@1.2.0': dependencies: '@chevrotain/types': 11.1.2 @@ -9208,18 +9279,18 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true - '@scalar/helpers@0.8.2': {} + '@scalar/helpers@0.9.0': {} - '@scalar/json-magic@0.12.16': + '@scalar/json-magic@0.12.17': dependencies: - '@scalar/helpers': 0.8.2 + '@scalar/helpers': 0.9.0 pathe: 2.0.3 yaml: 2.9.0 - '@scalar/openapi-parser@0.28.7': + '@scalar/openapi-parser@0.28.8': dependencies: - '@scalar/helpers': 0.8.2 - '@scalar/json-magic': 0.12.16 + '@scalar/helpers': 0.9.0 + '@scalar/json-magic': 0.12.17 '@scalar/openapi-types': 0.9.1 '@scalar/openapi-upgrader': 0.2.9 ajv: 8.20.0 @@ -9274,7 +9345,7 @@ snapshots: dependencies: '@shikijs/core': 3.23.0 '@shikijs/types': 3.23.0 - twoslash: 0.3.8(typescript@6.0.3) + twoslash: 0.3.9(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -9345,17 +9416,17 @@ snapshots: dependencies: tslib: 2.8.1 - '@tanstack/react-virtual@3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@tanstack/react-virtual@3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@tanstack/virtual-core': 3.17.1 + '@tanstack/virtual-core': 3.17.3 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@tanstack/virtual-core@3.17.1': {} + '@tanstack/virtual-core@3.17.3': {} '@theguild/remark-mermaid@0.3.0(react@19.2.7)': dependencies: - mermaid: 11.15.0 + mermaid: 11.16.0 react: 19.2.7 unist-util-visit: 5.1.0 @@ -9364,6 +9435,12 @@ snapshots: npm-to-yarn: 3.0.1 unist-util-visit: 5.1.0 + '@ts-morph/common@0.27.0': + dependencies: + fast-glob: 3.3.3 + minimatch: 10.2.5 + path-browserify: 1.0.1 + '@ts-morph/common@0.28.1': dependencies: minimatch: 10.2.5 @@ -9477,7 +9554,7 @@ snapshots: '@types/d3-quadtree@3.0.6': {} - '@types/d3-random@3.0.3': {} + '@types/d3-random@3.0.4': {} '@types/d3-scale-chromatic@3.1.0': {} @@ -9528,7 +9605,7 @@ snapshots: '@types/d3-path': 3.1.1 '@types/d3-polygon': 3.0.2 '@types/d3-quadtree': 3.0.6 - '@types/d3-random': 3.0.3 + '@types/d3-random': 3.0.4 '@types/d3-scale': 4.0.9 '@types/d3-scale-chromatic': 3.1.0 '@types/d3-selection': 3.0.11 @@ -9779,8 +9856,8 @@ snapshots: '@typespec/openapi3@1.13.0(d40defdf02a20bbce34f949d2f323956)': dependencies: - '@scalar/json-magic': 0.12.16 - '@scalar/openapi-parser': 0.28.7 + '@scalar/json-magic': 0.12.17 + '@scalar/openapi-parser': 0.28.8 '@scalar/openapi-types': 0.9.1 '@typespec/asset-emitter': 0.79.1(@typespec/compiler@1.13.0(@types/node@22.20.0)) '@typespec/compiler': 1.13.0(@types/node@22.20.0) @@ -10079,7 +10156,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.41: {} + baseline-browser-mapping@2.10.42: {} beasties@0.4.2: dependencies: @@ -10120,7 +10197,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.15.2 + qs: 6.15.3 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -10138,7 +10215,7 @@ snapshots: browserslist@4.28.4: dependencies: - baseline-browser-mapping: 2.10.41 + baseline-browser-mapping: 2.10.42 caniuse-lite: 1.0.30001800 electron-to-chromium: 1.5.387 node-releases: 2.0.50 @@ -10247,7 +10324,7 @@ snapshots: dependencies: '@hapi/bourne': 3.0.0 inflation: 2.1.0 - qs: 6.15.2 + qs: 6.15.3 raw-body: 2.5.3 type-is: 1.6.18 @@ -10708,7 +10785,7 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 - es-toolkit@1.47.1: {} + es-toolkit@1.49.0: {} esast-util-from-estree@2.0.0: dependencies: @@ -10893,8 +10970,8 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.2 - range-parser: 1.2.1 + qs: 6.15.3 + range-parser: 1.3.0 router: 2.2.0 send: 1.2.1 serve-static: 2.2.1 @@ -10940,6 +11017,10 @@ snapshots: dependencies: walk-up-path: 4.0.0 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -11004,7 +11085,7 @@ snapshots: fresh@2.0.0: {} - fs-extra@11.3.5: + fs-extra@11.3.6: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.1 @@ -11056,7 +11137,7 @@ snapshots: email-addresses: 5.0.0 filenamify: 4.3.0 find-cache-dir: 3.3.2 - fs-extra: 11.3.5 + fs-extra: 11.3.6 globby: 11.1.0 git-up@8.1.1: @@ -11611,7 +11692,7 @@ snapshots: '@types/koa': 3.0.3 co-body: 6.2.0 formidable: 3.5.4 - type-fest: 5.7.0 + type-fest: 5.8.0 zod: 4.4.3 koa-compose@4.1.0: {} @@ -11982,11 +12063,11 @@ snapshots: merge2@1.4.1: {} - mermaid@11.15.0: + mermaid@11.16.0: dependencies: '@braintree/sanitize-url': 7.1.2 - '@iconify/utils': 3.1.3 - '@mermaid-js/parser': 1.1.1 + '@iconify/utils': 3.1.4 + '@mermaid-js/parser': 1.2.0 '@types/d3': 7.4.3 '@upsetjs/venn.js': 2.0.0 cytoscape: 3.34.0 @@ -11997,7 +12078,7 @@ snapshots: dagre-d3-es: 7.0.14 dayjs: 1.11.21 dompurify: 3.4.11 - es-toolkit: 1.47.1 + es-toolkit: 1.49.0 katex: 0.16.47 khroma: 2.1.0 marked: 16.4.2 @@ -12405,7 +12486,7 @@ snapshots: dependencies: '@next/env': 16.2.10 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.41 + baseline-browser-mapping: 2.10.42 caniuse-lite: 1.0.30001800 postcss: 8.4.31 react: 19.2.7 @@ -12822,15 +12903,16 @@ snapshots: punycode@2.3.1: {} - qs@6.15.2: + qs@6.15.3: dependencies: + es-define-property: 1.0.1 side-channel: 1.1.1 quansync@1.0.0: {} queue-microtask@1.2.3: {} - range-parser@1.2.1: {} + range-parser@1.3.0: {} raw-body@2.5.3: dependencies: @@ -13295,7 +13377,7 @@ snapshots: mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 - range-parser: 1.2.1 + range-parser: 1.3.0 statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -13535,7 +13617,7 @@ snapshots: system-architecture@0.1.0: {} - tabbable@6.4.0: {} + tabbable@6.5.0: {} tagged-tag@1.0.0: {} @@ -13559,8 +13641,8 @@ snapshots: tinyglobby@0.2.16: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinyglobby@0.2.17: dependencies: @@ -13609,6 +13691,11 @@ snapshots: ts-dedent@2.3.0: {} + ts-morph@26.0.0: + dependencies: + '@ts-morph/common': 0.27.0 + code-block-writer: 13.0.3 + ts-morph@27.0.2: dependencies: '@ts-morph/common': 0.28.1 @@ -13688,17 +13775,17 @@ snapshots: transitivePeerDependencies: - supports-color - twoslash-protocol@0.3.8: {} + twoslash-protocol@0.3.9: {} - twoslash@0.3.8(typescript@6.0.3): + twoslash@0.3.9(typescript@6.0.3): dependencies: '@typescript/vfs': 1.6.4(typescript@6.0.3) - twoslash-protocol: 0.3.8 + twoslash-protocol: 0.3.9 typescript: 6.0.3 transitivePeerDependencies: - supports-color - type-fest@5.7.0: + type-fest@5.8.0: dependencies: tagged-tag: 1.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fb88e2548..b831d0c6e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,7 +18,7 @@ catalog: '@biomejs/wasm-nodejs': 2.5.2 '@commander-js/extra-typings': ^15.0.0 '@hyperjump/json-schema': ^1.17.6 - '@koa/router': ^15.6.0 + '@koa/router': ^15.7.0 '@lerna-lite/cli': ^5.4.0 '@lerna-lite/publish': ^5.4.0 '@lerna-lite/version': ^5.4.0 diff --git a/scripts/generate.mjs b/scripts/generate.mjs index 72ee88975..c10942c10 100755 --- a/scripts/generate.mjs +++ b/scripts/generate.mjs @@ -65,10 +65,20 @@ async function runSingle(templatePath, input) { const filename = path.basename(input) const template = path.basename(templatePath) + if (template === "typescript-nextjs" && !input.endsWith("todo-lists.yaml")) { + console.warn(`skipping generation of ${templatePath} using ${template}`) + return + } + + const output = + template === "typescript-nextjs" + ? `integration-tests/${template}/src` + : `integration-tests/${template}/src/generated/${filename}` + const args = [ `--input="${input}"`, `--input-type=${inputType}`, - `--output="integration-tests/${template}/src/generated/${filename}"`, + `--output="${output}"`, `--template="${template}"`, `--schema-builder=${schemaBuilder}`, ] @@ -95,15 +105,19 @@ function runCmd(cmd) { console.info(cmd) - const child = exec(cmd, (err) => { - if (err) { - err.output = output - reject(err) - return - } - - resolve(output) - }) + const child = exec( + cmd, + {env: {...process.env, OPENAPI_INTEGRATION_TESTS: "true"}}, + (err) => { + if (err) { + err.output = output + reject(err) + return + } + + resolve(output) + }, + ) child.stdout.on("data", (it) => { output.push(it.trim()) diff --git a/tsconfig.json b/tsconfig.json index 4d2528718..d90c713b0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ { "path": "./packages/typescript-axios-runtime" }, { "path": "./packages/typescript-express-runtime" }, { "path": "./packages/typescript-fetch-runtime" }, - { "path": "./packages/typescript-koa-runtime" } + { "path": "./packages/typescript-koa-runtime" }, + { "path": "./packages/typescript-nextjs-runtime" } ] }