From 212040363afeb2c616e1b3be667827dd4269b5dd Mon Sep 17 00:00:00 2001 From: Severin Ibarluzea Date: Mon, 23 Jun 2025 10:30:27 -0700 Subject: [PATCH] Implement simplified winterspec2 route-types codegen --- src/cli2/commands/codegen/route-types.ts | 120 ++++++-------- tests/cli/codegen/route-types2/api/foo.ts | 25 +++ .../cli/codegen/route-types2/api/importer.ts | 15 ++ .../codegen/route-types2/api/many-params.ts | 35 ++++ .../route-types2/api/param-transform.ts | 21 +++ .../codegen/route-types2/api/query-params.ts | 12 ++ tests/cli/codegen/route-types2/api/union.ts | 23 +++ .../route-types2/route-type-codegen.test.ts | 156 ++++++++++++++++++ tests/cli/codegen/route-types2/tsconfig.json | 7 + .../codegen/route-types2/with-winter-spec.ts | 10 ++ 10 files changed, 353 insertions(+), 71 deletions(-) create mode 100644 tests/cli/codegen/route-types2/api/foo.ts create mode 100644 tests/cli/codegen/route-types2/api/importer.ts create mode 100644 tests/cli/codegen/route-types2/api/many-params.ts create mode 100644 tests/cli/codegen/route-types2/api/param-transform.ts create mode 100644 tests/cli/codegen/route-types2/api/query-params.ts create mode 100644 tests/cli/codegen/route-types2/api/union.ts create mode 100644 tests/cli/codegen/route-types2/route-type-codegen.test.ts create mode 100644 tests/cli/codegen/route-types2/tsconfig.json create mode 100644 tests/cli/codegen/route-types2/with-winter-spec.ts diff --git a/src/cli2/commands/codegen/route-types.ts b/src/cli2/commands/codegen/route-types.ts index a78b590..177727a 100644 --- a/src/cli2/commands/codegen/route-types.ts +++ b/src/cli2/commands/codegen/route-types.ts @@ -1,8 +1,6 @@ -import { Command } from "commander" import fs from "node:fs/promises" import { BaseCommand } from "../../base-command.js" -import { ResolvedWinterSpecConfig } from "src/config/utils.js" -import { extractRouteSpecsFromAST } from "src/lib/codegen/extract-route-specs-from-ast.js" +import { extractRouteSpecsFromAST } from "../../../lib/codegen/extract-route-specs-from-ast.js" import Debug from "debug" const debug = Debug("winterspec:CodeGenRouteTypes") @@ -17,86 +15,66 @@ export class CodeGenRouteTypes extends BaseCommand { .option("--root ", "Path to your project root") .option("--tsconfig ", "Path to your tsconfig.json") .option("--routes-directory ", "Path to your routes directory") - .option("--platform ", "The platform to bundle for") .action(async (options) => { debug("Running with config", options) const config = await this.loadConfig(options) debug("Config loaded.") - debug("Extracting route specs from AST...") - const { project, routes, renderType } = await extractRouteSpecsFromAST({ + const { routes, renderType } = await extractRouteSpecsFromAST({ tsConfigFilePath: config.tsconfigPath, routesDirectory: config.routesDirectory, }) - debug("Route specs extracted.") - project.createSourceFile( - "manifest.ts", - ` - import {z} from "zod" + const routeEntries = routes.map( + ({ + route, + httpMethods, + jsonResponseZodOutputType, + jsonBodyZodInputType, + commonParamsZodInputType, + queryParamsZodInputType, + urlEncodedFormDataZodInputType, + }) => { + const parts = [ + `route: "${route}"`, + `method: ${httpMethods.map((m) => `"${m}"`).join(" | ")}`, + ] + if (jsonResponseZodOutputType) { + parts.push( + `jsonResponse: ${renderType(jsonResponseZodOutputType)}` + ) + } + if (jsonBodyZodInputType) { + parts.push(`jsonBody: ${renderType(jsonBodyZodInputType)}`) + } + if (commonParamsZodInputType) { + parts.push( + `commonParams: ${renderType(commonParamsZodInputType)}` + ) + } + if (queryParamsZodInputType) { + parts.push(`queryParams: ${renderType(queryParamsZodInputType)}`) + } + if (urlEncodedFormDataZodInputType) { + parts.push( + `urlEncodedFormData: ${renderType( + urlEncodedFormDataZodInputType + )}` + ) + } - export type Routes = { - ${routes - .map( - ({ - route, - httpMethods, - jsonResponseZodOutputType, - jsonBodyZodInputType, - commonParamsZodInputType, - queryParamsZodInputType, - urlEncodedFormDataZodInputType, - }) => { - return ` "${route}": { - route: "${route}" - method: ${httpMethods.map((m) => `"${m}"`).join(" | ")} - ${ - jsonResponseZodOutputType - ? `jsonResponse: ${renderType(jsonResponseZodOutputType)}` - : "" - } - ${ - jsonBodyZodInputType - ? `jsonBody: ${renderType(jsonBodyZodInputType)}` - : "" - } - ${ - commonParamsZodInputType - ? `commonParams: ${renderType(commonParamsZodInputType)}` - : "" - } - ${ - queryParamsZodInputType - ? `queryParams: ${renderType(queryParamsZodInputType)}` - : "" - } - ${ - urlEncodedFormDataZodInputType - ? `urlEncodedFormData: ${renderType( - urlEncodedFormDataZodInputType - )}` - : "" - } - }` - } - ) - .join("\n")} - } - - type ExtractOrUnknown = Key extends keyof T ? T[Key] : unknown; - - export type RouteResponse = ExtractOrUnknown - export type RouteRequestBody = ExtractOrUnknown & ExtractOrUnknown - export type RouteRequestParams = ExtractOrUnknown & ExtractOrUnknown - ` + return ` "${route}": {\n ${parts.join("\n ")}\n }` + } ) - const result = project.emitToMemory({ emitOnlyDtsFiles: true }) - await fs.writeFile( - options.output, - result.getFiles().find((f) => f.filePath.includes("/manifest.d.ts"))! - .text - ) + const content = + `export type Routes = {\n${routeEntries.join("\n")}\n}\n\n` + + `type ExtractOrUnknown = Key extends keyof T ? T[Key] : unknown\n\n` + + `export type RouteResponse = ExtractOrUnknown\n` + + `export type RouteRequestBody = ExtractOrUnknown & ExtractOrUnknown\n` + + `export type RouteRequestParams = ExtractOrUnknown & ExtractOrUnknown\n` + + await fs.writeFile(options.output, content) }) } } diff --git a/tests/cli/codegen/route-types2/api/foo.ts b/tests/cli/codegen/route-types2/api/foo.ts new file mode 100644 index 0000000..4c9cda5 --- /dev/null +++ b/tests/cli/codegen/route-types2/api/foo.ts @@ -0,0 +1,25 @@ +import { z } from "zod" +import { withWinterSpec } from "../with-winter-spec.js" + +export const jsonResponse = z.object({ + foo: z.object({ + id: z.string().uuid(), + name: z.string(), + }), +}) + +export default withWinterSpec({ + auth: "none", + methods: ["GET", "POST"], + jsonBody: z.object({ + foo_id: z.string().uuid(), + }), + jsonResponse, +})((req) => { + return Response.json({ + foo: { + id: "example-id", + name: "foo", + }, + }) +}) diff --git a/tests/cli/codegen/route-types2/api/importer.ts b/tests/cli/codegen/route-types2/api/importer.ts new file mode 100644 index 0000000..f7d356f --- /dev/null +++ b/tests/cli/codegen/route-types2/api/importer.ts @@ -0,0 +1,15 @@ +import { withWinterSpec } from "../with-winter-spec.js" +import { jsonResponse } from "./foo.js" + +export default withWinterSpec({ + auth: "none", + methods: ["PUT"], + jsonResponse, +})((req) => { + return Response.json({ + foo: { + id: "example-id", + name: "foo", + }, + }) +}) diff --git a/tests/cli/codegen/route-types2/api/many-params.ts b/tests/cli/codegen/route-types2/api/many-params.ts new file mode 100644 index 0000000..22c5061 --- /dev/null +++ b/tests/cli/codegen/route-types2/api/many-params.ts @@ -0,0 +1,35 @@ +import { z } from "zod" +import { withWinterSpec } from "../with-winter-spec.js" + +const manyParams = z.object({ + this_has: z.string(), + many: z.string(), + params: z.string(), + to: z.string(), + make: z.string(), + sure: z.string(), + type_is: z.string(), + fully: z.string(), + expanded: z.string(), +}) + +export default withWinterSpec({ + auth: "none", + methods: ["GET", "POST"], + jsonBody: manyParams.extend({ + and: manyParams, + }), + jsonResponse: z.object({ + foo: z.object({ + id: z.string().uuid(), + name: z.string(), + }), + }), +})((req) => { + return Response.json({ + foo: { + id: "example-id", + name: "foo", + }, + }) +}) diff --git a/tests/cli/codegen/route-types2/api/param-transform.ts b/tests/cli/codegen/route-types2/api/param-transform.ts new file mode 100644 index 0000000..4ba9ee2 --- /dev/null +++ b/tests/cli/codegen/route-types2/api/param-transform.ts @@ -0,0 +1,21 @@ +import { z } from "zod" +import { withWinterSpec } from "../with-winter-spec.js" + +export default withWinterSpec({ + auth: "none", + methods: ["GET", "POST"], + jsonBody: z.object({ + // this should be written to route types as a string rather than a number + foo_id: z.string().transform((v) => Number(v)), + }), + jsonResponse: z.object({ + ok: z.boolean(), + }), +})((req) => { + return Response.json({ + foo: { + id: "example-id", + name: "foo", + }, + }) +}) diff --git a/tests/cli/codegen/route-types2/api/query-params.ts b/tests/cli/codegen/route-types2/api/query-params.ts new file mode 100644 index 0000000..4ac634a --- /dev/null +++ b/tests/cli/codegen/route-types2/api/query-params.ts @@ -0,0 +1,12 @@ +import { z } from "zod" +import { withWinterSpec } from "../with-winter-spec.js" + +export default withWinterSpec({ + auth: "none", + methods: ["GET"], + queryParams: z.object({ + foo_id: z.string().uuid(), + }), +})((req) => { + return Response.json({}) +}) diff --git a/tests/cli/codegen/route-types2/api/union.ts b/tests/cli/codegen/route-types2/api/union.ts new file mode 100644 index 0000000..55a44c3 --- /dev/null +++ b/tests/cli/codegen/route-types2/api/union.ts @@ -0,0 +1,23 @@ +import { z } from "zod" +import { withWinterSpec } from "../with-winter-spec.js" + +export default withWinterSpec({ + auth: "none", + methods: ["GET", "POST"], + jsonBody: z.object({ + foo_id: z.string().uuid(), + }), + jsonResponse: z.union([ + z.object({ + foo_id: z.string(), + }), + z.boolean().array(), + ]), +})((req) => { + return Response.json({ + foo: { + id: "example-id", + name: "foo", + }, + }) +}) diff --git a/tests/cli/codegen/route-types2/route-type-codegen.test.ts b/tests/cli/codegen/route-types2/route-type-codegen.test.ts new file mode 100644 index 0000000..a6c60de --- /dev/null +++ b/tests/cli/codegen/route-types2/route-type-codegen.test.ts @@ -0,0 +1,156 @@ +import test from "ava" +import { getTestCLI2 } from "tests/fixtures/get-test-cli.js" +import os from "node:os" +import path from "node:path" +import { randomUUID } from "node:crypto" +import { fileURLToPath } from "node:url" +import fs from "node:fs/promises" +import { Project } from "ts-morph" + +test("CLI codegen route-types command produces the expected route types", async (t) => { + const cli = await getTestCLI2(t) + + const testFileDirectory = path.dirname(fileURLToPath(import.meta.url)) + + const tempPath = path.join(os.tmpdir(), `${randomUUID()}.d.ts`) + const appDirectoryPath = path.join(testFileDirectory, "api") + const tsconfigPath = path.join(testFileDirectory, "tsconfig.json") + const execution = cli.executeCommand([ + "codegen-route-types", + "-o", + tempPath, + "--routes-directory", + appDirectoryPath, + "--tsconfig", + tsconfigPath, + ]) + const cliResult = await execution.waitUntilExit() + t.is(cliResult.exitCode, 0) + + // Test created file + const routesDTs = await fs.readFile(tempPath, "utf-8") + t.log("Generated file:") + t.log(routesDTs) + + const project = new Project({ + compilerOptions: { strict: true }, + }) + + project.createSourceFile("routes.ts", routesDTs) + project.createSourceFile( + "tests.ts", + ` + import { expectTypeOf } from "expect-type" + import {Routes, RouteResponse, RouteRequestBody, RouteRequestParams} from "./routes" + + type ExpectedRoutes = { + // Basic smoke test + "/foo": { + route: "/foo" + method: "GET" | "POST" + jsonResponse: { + foo: { + id: string + name: string + } + } + jsonBody: { + foo_id: string + } + } + // A route that imports part of its spec from /foo + "/importer": { + route: "/importer" + method: "PUT" + jsonResponse: { + foo: { + id: string + name: string + } + } + } + // Route that uses z.union + "/union": { + route: "/union" + method: "GET" | "POST" + jsonResponse: { + foo_id: string + } | boolean[] + jsonBody: { + foo_id: string + } + } + // Route with many parameters to make sure they're not truncated + "/many-params": { + route: "/many-params" + method: "GET" | "POST" + jsonResponse: { + foo: { + id: string + name: string + } + } + jsonBody: { + this_has: string + many: string + params: string + to: string + make: string + sure: string + type_is: string + fully: string + expanded: string + and: { + this_has: string + many: string + params: string + to: string + make: string + sure: string + type_is: string + fully: string + expanded: string + } + } + } + // Route that uses .transform() + "/param-transform": { + route: "/param-transform" + method: "GET" | "POST" + jsonResponse: { + ok: boolean + } + jsonBody: { + foo_id: string + } + } + // Query params + "/query-params": { + route: "/query-params" + method: "GET" + queryParams: { + foo_id: string + } + } + } + + expectTypeOf().toEqualTypeOf() + + expectTypeOf>().toEqualTypeOf<{ok: boolean}>() + expectTypeOf>().toEqualTypeOf<{foo_id: string}>() + expectTypeOf>().toEqualTypeOf<{foo_id: string}>() + ` + ) + + const diagnostics = project.getPreEmitDiagnostics() + + if (diagnostics.length > 0) { + t.log(project.formatDiagnosticsWithColorAndContext(diagnostics)) + + t.fail( + "Test TypeScript project using generated routes threw compile errors" + ) + } + + t.pass() +}) diff --git a/tests/cli/codegen/route-types2/tsconfig.json b/tests/cli/codegen/route-types2/tsconfig.json new file mode 100644 index 0000000..25ddc82 --- /dev/null +++ b/tests/cli/codegen/route-types2/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler" + } +} diff --git a/tests/cli/codegen/route-types2/with-winter-spec.ts b/tests/cli/codegen/route-types2/with-winter-spec.ts new file mode 100644 index 0000000..ffb0b78 --- /dev/null +++ b/tests/cli/codegen/route-types2/with-winter-spec.ts @@ -0,0 +1,10 @@ +import { createWithWinterSpec } from "../../../../src/index.js" + +export const withWinterSpec = createWithWinterSpec({ + openapi: { + apiName: "hello-world", + productionServerUrl: "https://example.com", + }, + authMiddleware: {}, + beforeAuthMiddleware: [], +})