diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36062a0..4852e75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,3 +28,27 @@ jobs: uses: codecov/codecov-action@v3 with: fail_ci_if_error: false + + zod-matrix: + name: "Zod ${{ matrix.zod }} compatibility" + runs-on: ubuntu-latest + strategy: + # Test against multiple Zod 4 minor versions to catch cross-realm + # regressions early — see #222. + fail-fast: false + matrix: + zod: ["4.1.13", "4.2.0", "4.4.3", "latest"] + steps: + - uses: actions/checkout@v3 + - uses: pnpm/action-setup@v2 + with: + version: 9 + - uses: actions/setup-node@v3 + with: + node-version: 20 + cache: "pnpm" + - run: pnpm i --frozen-lockfile + - name: Override zod version + run: pnpm add -D --filter next-rest-framework zod@${{ matrix.zod }} + - run: pnpm --filter next-rest-framework run build + - run: pnpm --filter next-rest-framework run test diff --git a/apps/example/package.json b/apps/example/package.json index 72ef027..7294099 100644 --- a/apps/example/package.json +++ b/apps/example/package.json @@ -16,6 +16,7 @@ "jsdom": "24.0.0", "next-rest-framework": "workspace:*", "tsx": "4.7.2", + "zod": "^4.1.13", "zod-form-data": "3.0.1" }, "devDependencies": { diff --git a/packages/next-rest-framework/package.json b/packages/next-rest-framework/package.json index a5d0012..f1dde2e 100644 --- a/packages/next-rest-framework/package.json +++ b/packages/next-rest-framework/package.json @@ -43,6 +43,15 @@ "prettier": "3.0.2", "qs": "6.14.1" }, + "peerDependencies": { + "zod": "^4.1.13", + "zod-form-data": "^3.0.1" + }, + "peerDependenciesMeta": { + "zod-form-data": { + "optional": true + } + }, "devDependencies": { "@types/formidable": "^3.4.5", "@types/jest": "29.5.4", diff --git a/packages/next-rest-framework/tests/app-router/route.test.ts b/packages/next-rest-framework/tests/app-router/route.test.ts index 003b002..dcc121d 100644 --- a/packages/next-rest-framework/tests/app-router/route.test.ts +++ b/packages/next-rest-framework/tests/app-router/route.test.ts @@ -727,4 +727,102 @@ describe('route', () => { expect(itemsSchema?.items?.properties?.id?.description).toBe('Item ID'); expect(itemsSchema?.items?.properties?.name?.description).toBe('Item name'); }); + + it('preserves Zod refinements in generated OpenAPI schema', async () => { + const { req, context } = createMockRouteRequest({ + method: ValidMethod.POST + }); + + const inputSchema = z.object({ + // string constraints + title: z.string().min(1).max(100), + slug: z.string().regex(/^[a-z-]+$/), + // string formats + email: z.email(), + url: z.url(), + uuid: z.uuid(), + isoDate: z.iso.datetime(), + // number constraints + age: z.number().min(0).max(150), + // integer + count: z.number().int(), + // multipleOf + stride: z.number().multipleOf(5), + // array constraints + tags: z.array(z.string()).min(1).max(5), + // enum / literal + role: z.enum(['admin', 'user', 'guest']), + kind: z.literal('special'), + // default + verbose: z.boolean().default(false), + // optional + note: z.string().optional() + }); + + const outputSchema = z.object({ + id: z.string().min(1) + }); + + const operations = { + createItem: routeOperation({ method: 'POST' }) + .input({ + contentType: 'application/json', + body: inputSchema + }) + .outputs([ + { + status: 201, + contentType: 'application/json', + body: outputSchema + } + ]) + .handler(() => TypedNextResponse.json({ id: '1' }, { status: 201 })) + } as const; + + await route(operations).POST(req, context); + + const { schemas } = getPathsFromRoute({ + operations, + route: '/api/items' + }); + + const req_ = schemas?.CreateItemRequestBody as any; + const p = req_?.properties; + + // string constraints + expect(p?.title?.minLength).toBe(1); + expect(p?.title?.maxLength).toBe(100); + expect(p?.slug?.pattern).toBe('^[a-z-]+$'); + + // string formats + expect(p?.email?.format).toBe('email'); + expect(p?.url?.format).toBe('uri'); + expect(p?.uuid?.format).toBe('uuid'); + expect(p?.isoDate?.format).toBe('date-time'); + + // number constraints + expect(p?.age?.minimum).toBe(0); + expect(p?.age?.maximum).toBe(150); + expect(p?.count?.type).toBe('integer'); + expect(p?.stride?.multipleOf).toBe(5); + + // array constraints + expect(p?.tags?.minItems).toBe(1); + expect(p?.tags?.maxItems).toBe(5); + + // enum / literal + expect(p?.role?.enum).toEqual(['admin', 'user', 'guest']); + expect(p?.kind?.enum).toEqual(['special']); + + // default values + expect(p?.verbose?.default).toBe(false); + + // required fields (note: 'note' is optional, 'verbose' has default) + expect(req_?.required).toContain('title'); + expect(req_?.required).not.toContain('note'); + + // response refinements + const res_ = schemas?.CreateItem201ResponseBody as any; + expect(res_?.properties?.id?.minLength).toBe(1); + }); }); diff --git a/packages/next-rest-framework/tsup.config.ts b/packages/next-rest-framework/tsup.config.ts index f98a7f2..3af70f5 100644 --- a/packages/next-rest-framework/tsup.config.ts +++ b/packages/next-rest-framework/tsup.config.ts @@ -30,6 +30,11 @@ export default defineConfig({ 'src/cli/validate.ts' ], bundle: true, + // `zod` and `zod-form-data` are externalised so that schemas constructed by + // the consumer's installed `zod` share the same realm as the `toJSONSchema` + // call inside this package. Bundling them caused cross-realm bugs where + // `_zod.bag` was unreadable across versions (see #222). + external: ['zod', 'zod-form-data'], esbuildPlugins: [uaParserDirnamePlugin()], format: ['cjs', 'esm'], platform: 'node' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cedd0e3..6093dd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,9 +72,12 @@ importers: tsx: specifier: 4.7.2 version: 4.7.2 + zod: + specifier: ^4.1.13 + version: 4.4.3 zod-form-data: specifier: 3.0.1 - version: 3.0.1(zod@4.1.13) + version: 3.0.1(zod@4.4.3) devDependencies: '@types/jsdom': specifier: ^21.1.6 @@ -185,10 +188,10 @@ importers: version: 5.2.2 zod: specifier: ^4.1.13 - version: 4.1.13 + version: 4.4.3 zod-form-data: specifier: 3.0.1 - version: 3.0.1(zod@4.1.13) + version: 3.0.1(zod@4.4.3) packages: @@ -1333,89 +1336,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1582,24 +1601,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.4.8': resolution: {integrity: sha512-DX/L8VHzrr1CfwaVjBQr3GWCqNNFgyWJbeQ10Lx/phzbQo3JNAxUok1DZ8JHRGcL6PgMRgj6HylnLNndxn4Z6A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.4.8': resolution: {integrity: sha512-9fLAAXKAL3xEIFdKdzG5rUSvSiZTLLTCc6JKq1z04DR4zY7DbAPcRvNm3K1inVhTiQCs19ZRAgUerHiVKMZZIA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.4.8': resolution: {integrity: sha512-s45V7nfb5g7dbS7JK6XZDcapicVrMMvX2uYgOHP16QuKH/JA285oy6HcxlKqwUNaFY/UC6EvQ8QZUOo19cBKSA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.4.8': resolution: {integrity: sha512-KjgeQyOAq7t/HzAJcWPGA8X+4WY03uSCZ2Ekk98S9OgCFsb6lfBE3dbUzUuEQAN2THbwYgFfxX2yFTCMm8Kehw==} @@ -1661,26 +1684,31 @@ packages: resolution: {integrity: sha512-TVYVWD/SYwWzGGnbfTkrNpdE4HON46orgMNHCivlXmlsSGQOx/OHHYiQcMIOx38/GWgwr/po2LBn7wypkWw/Mg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.9.4': resolution: {integrity: sha512-XcKvuendwizYYhFxpvQ3xVpzje2HHImzg33wL9zvxtj77HvPStbSGI9czrdbfrf8DGMcNNReH9pVZv8qejAQ5A==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.9.4': resolution: {integrity: sha512-LFHS/8Q+I9YA0yVETyjonMJ3UA+DczeBd/MqNEzsGSTdNvSJa1OJZcSH8GiXLvcizgp9AlHs2walqRcqzjOi3A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.9.4': resolution: {integrity: sha512-dIYgo+j1+yfy81i0YVU5KnQrIJZE8ERomx17ReU4GREjGtDW4X+nvkBak2xAUpyqLs4eleDSj3RrV72fQos7zw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.9.4': resolution: {integrity: sha512-RoaYxjdHQ5TPjaPrLsfKqR3pakMr3JGqZ+jZM0zP2IkDtsGa4CqYaWSfQmZVgFUCgLrTnzX+cnHS3nfl+kB6ZQ==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.9.4': resolution: {integrity: sha512-T8Q3XHV+Jjf5e49B4EAaLKV74BbX7/qYBRQ8Wop/+TyyU0k+vSjiLVSHNWdVd1goMjZcbhDmYZUYW5RFqkBNHQ==} @@ -6677,6 +6705,9 @@ packages: zod@4.1.13: resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@1.0.5: resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==} @@ -14646,11 +14677,13 @@ snapshots: yocto-queue@0.1.0: {} - zod-form-data@3.0.1(zod@4.1.13): + zod-form-data@3.0.1(zod@4.4.3): dependencies: '@rvf/set-get': 7.0.1 - zod: 4.1.13 + zod: 4.4.3 zod@4.1.13: {} + zod@4.4.3: {} + zwitch@1.0.5: {}