Skip to content
2 changes: 1 addition & 1 deletion apps/example-todo-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"uuid": "^8.3.2",
"zod": "^3.17.3"
"zod": "^4.3.5"
},
"devDependencies": {
"@types/node": "18.0.1",
Expand Down
2 changes: 1 addition & 1 deletion apps/example-todo-app/pages/api/todo/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const route_spec = checkRouteSpec({
queryParams,
jsonResponse: z.object({
ok: z.boolean(),
todo: ZT.todo,
todo: ZT.todo.optional(),
error: z
.object({
type: z.string(),
Expand Down
2 changes: 1 addition & 1 deletion apps/example-todo-app/pages/api/todo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const route_spec = checkRouteSpec({
queryParams,
jsonResponse: z.object({
ok: z.boolean(),
todo: ZT.todo,
todo: ZT.todo.optional(),
error: z
.object({
type: z.string(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ test("GET /todo/array-query-brackets (comma-separated array values)", async (t)
.catch((r) => r)

t.is(status, 400)
t.is(error.message, `Expected array, received string for "ids"`)
// Zod 4 prefixes with "Invalid input: " so we check for the core message
t.true(error.message.includes('expected array, received string for "ids"'))
})

test("GET /todo/array-query-brackets (bracket array values)", async (t) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ test("GET /todo/array-query-repeat (comma-separated array values)", async (t) =>
.catch((r) => r)

t.is(status, 400)
t.is(error.message, `Expected array, received string for "ids"`)
// Zod 4 prefixes with "Invalid input: " so we check for the core message
t.true(error.message.includes('expected array, received string for "ids"'))
})

test("GET /todo/array-query-repeat (bracket array values)", async (t) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/example-todo-app/tests/api/todo/get-boolean.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import axiosAssert from "tests/fixtures/axios-assert"
import getTestServer from "tests/fixtures/get-test-server"
import { v4 as uuidv4 } from "uuid"

test.failing("GET /todo/get", async (t) => {
test("GET /todo/get", async (t) => {
const { axios } = await getTestServer(t)

axios.defaults.headers.common.Authorization = `Bearer auth_token`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,28 +130,27 @@ test("generateOpenAPI includes GET even when POST is defined", async (t) => {
const methods = Object.keys(openapiJson.paths["/api/todo/list"])
t.is(2, methods.length)

// GET includes parameters
t.deepEqual(
{
name: "ids",
in: "query",
required: true,
schema: {
items: {
format: "uuid",
type: "string",
},
type: "array",
// GET includes parameters (using t.like for flexibility with Zod 4's extra properties like pattern)
t.like(openapiJson.paths["/api/todo/list"].get.parameters[0], {
name: "ids",
in: "query",
required: true,
schema: {
items: {
format: "uuid",
type: "string",
},
type: "array",
},
openapiJson.paths["/api/todo/list"].get.parameters[0]
)
})

t.falsy(openapiJson.paths["/api/todo/list"].get.requestBody)

// POST route has request body

t.deepEqual(
// POST route has request body (using t.like for flexibility with Zod 4's extra properties)
t.like(
openapiJson.paths["/api/todo/list"].post.requestBody.content[
"application/json"
].schema,
{
properties: {
ids: {
Expand All @@ -164,10 +163,7 @@ test("generateOpenAPI includes GET even when POST is defined", async (t) => {
},
required: ["ids"],
type: "object",
},
openapiJson.paths["/api/todo/list"].post.requestBody.content[
"application/json"
].schema
}
)

t.falsy(openapiJson.paths["/api/todo/list"].post.parameters)
Expand Down
6 changes: 2 additions & 4 deletions packages/nextlove/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,17 @@
"react": ">=18",
"react-dom": ">=18",
"typescript": "^5.0.2",
"zod": "^3.0.0"
"zod": "^3.0.0 || ^4.0.0"
},
"dependencies": {
"@types/js-yaml": "^4.0.9",
"dedent": "^1.5.1",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"openapi3-ts": "^4.4.0",
"ts-deepmerge": "^6.0.3",
"ts-morph": "^21.0.1"
},
"devDependencies": {
"@anatine/zod-openapi": "^2.0.1",
"@types/lodash": "^4.14.182",
"@types/node": "18.0.0",
"@types/prettier": "^2.7.1",
Expand All @@ -91,6 +89,6 @@
"tsup": "^5.6.3",
"turbo": "^1.3.1",
"type-fest": "^3.1.0",
"zod": "^3.21.4"
"zod": "^4.3.5"
}
}
20 changes: 9 additions & 11 deletions packages/nextlove/src/generators/generate-openapi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { parseFrontMatter, testFrontMatter } from "../lib/front-matter"
import dedent from "dedent"
import { prefixObjectKeysWithX } from "../utils/prefix-object-keys-with-x"
import { dashifyObjectKeys } from "../utils/dashify-object-keys"
import { getTypeName } from "../../lib/zod-compat"

function replaceFirstCharToLowercase(str: string) {
if (str.length === 0) {
Expand Down Expand Up @@ -191,9 +192,9 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) {
body_to_generate_schema = routeSpec.jsonBody ?? routeSpec.commonParams

if (routeSpec.jsonBody && routeSpec.commonParams) {
body_to_generate_schema = routeSpec.jsonBody.merge(
routeSpec.commonParams
)
body_to_generate_schema = (
routeSpec.jsonBody as z.ZodObject<any>
).merge(routeSpec.commonParams as z.ZodObject<any>)
}
} else {
body_to_generate_schema = routeSpec.jsonBody
Expand All @@ -207,9 +208,9 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) {
routeSpec.queryParams ?? routeSpec.commonParams

if (routeSpec.queryParams && routeSpec.commonParams) {
query_to_generate_schema = routeSpec.queryParams.merge(
routeSpec.commonParams
)
query_to_generate_schema = (
routeSpec.queryParams as z.ZodObject<any>
).merge(routeSpec.commonParams as z.ZodObject<any>)
}
}

Expand Down Expand Up @@ -271,11 +272,8 @@ export async function generateOpenAPI(opts: GenerateOpenAPIOpts) {
const { addOkStatus = true } = setupParams

if (jsonResponse) {
if (
!jsonResponse._def ||
!jsonResponse._def.typeName ||
jsonResponse._def.typeName !== "ZodObject"
) {
const jsonResponseTypeName = getTypeName(jsonResponse)
if (jsonResponseTypeName !== "ZodObject") {
console.warn(
chalk.yellow(
`Skipping route ${routePath} because the response is not a ZodObject.`
Expand Down
45 changes: 32 additions & 13 deletions packages/nextlove/src/generators/generate-route-types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import * as fs from "node:fs/promises"
import { parseRoutesInPackage } from "../lib/parse-routes-in-package"
import { zodToTs, printNode } from "../lib/zod-to-ts"
import prettier from "prettier"
import { z, ZodEffects, ZodOptional } from "zod"
import { z, ZodTypeAny } from "zod"
import {
getTypeName,
getEffectsSchema,
getInnerType,
getShape,
} from "../../lib/zod-compat"

interface GenerateRouteTypesOpts {
packageDir: string
Expand Down Expand Up @@ -35,24 +41,37 @@ export const generateRouteTypes = async (opts: GenerateRouteTypesOpts) => {
const routeDefs: string[] = []
for (const [_, { route, routeSpec, setupParams }] of filteredRoutes) {
const maxDuration = routeSpec.maxDuration ?? setupParams.maxDuration
const queryKeys = Object.keys(routeSpec.queryParams?.shape ?? {})
const queryKeys = Object.keys(
routeSpec.queryParams ? getShape(routeSpec.queryParams) ?? {} : {}
)
const pathParameters = queryKeys.filter((key) => route.includes(`[${key}]`))

// queryParams might be a ZodEffects or ZodOptional in some cases
let queryParams = routeSpec.queryParams
while (
queryParams &&
("sourceType" in queryParams || "unwrap" in queryParams)
) {
if ("sourceType" in queryParams) {
queryParams = (queryParams as unknown as ZodEffects<any>).sourceType()
} else if ("unwrap" in queryParams) {
queryParams = (queryParams as unknown as ZodOptional<any>).unwrap()
let queryParams: ZodTypeAny | undefined = routeSpec.queryParams
while (queryParams) {
const typeName = getTypeName(queryParams)
if (typeName === "ZodEffects") {
const inner = getEffectsSchema(queryParams)
if (inner) {
queryParams = inner
continue
}
} else if (typeName === "ZodOptional") {
const inner = getInnerType(queryParams)
if (inner) {
queryParams = inner
continue
}
}
break
}

if (queryParams && "omit" in queryParams) {
queryParams = queryParams.omit(
if (
queryParams &&
"omit" in queryParams &&
typeof (queryParams as any).omit === "function"
) {
queryParams = (queryParams as any).omit(
Object.fromEntries(pathParameters.map((param) => [param, true]))
)
}
Expand Down
Loading