diff --git a/.changepacks/changepack_log_bTLoWLJTloIs9pkFN38NE.json b/.changepacks/changepack_log_bTLoWLJTloIs9pkFN38NE.json new file mode 100644 index 0000000..82f3c77 --- /dev/null +++ b/.changepacks/changepack_log_bTLoWLJTloIs9pkFN38NE.json @@ -0,0 +1 @@ +{"changes":{"packages/webpack-plugin/package.json":"Patch","packages/next-plugin/package.json":"Patch","packages/utils/package.json":"Patch","packages/vite-plugin/package.json":"Patch","packages/zod/package.json":"Patch","packages/fetch/package.json":"Patch","packages/core/package.json":"Patch","packages/rsbuild-plugin/package.json":"Patch","packages/generator/package.json":"Patch","packages/react-query/package.json":"Patch"},"note":"implement zod","date":"2026-01-04T11:21:33.541821Z"} \ No newline at end of file diff --git a/.changepacks/changepack_log_pD6lylXWV9brQzAlQutNu.json b/.changepacks/changepack_log_pD6lylXWV9brQzAlQutNu.json new file mode 100644 index 0000000..0c38361 --- /dev/null +++ b/.changepacks/changepack_log_pD6lylXWV9brQzAlQutNu.json @@ -0,0 +1 @@ +{"changes":{"packages/zod/package.json":"Minor"},"note":"Implement zod","date":"2026-01-04T11:21:57.546730400Z"} \ No newline at end of file diff --git a/bun.lock b/bun.lock index 86b3bac..d8fa40a 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "@devup-api/fetch": "workspace:*", "@devup-api/next-plugin": "workspace:*", "@devup-api/react-query": "workspace:*", + "@devup-api/zod": "workspace:*", "@devup-ui/react": "^1", "next": "^16.1.0", "react": "^19.2.3", @@ -100,7 +101,7 @@ "name": "@devup-api/fetch", "version": "0.1.13", "dependencies": { - "@devup-api/core": "workspace:*", + "@devup-api/core": "workspace:^", }, "devDependencies": { "@types/node": "^25.0", @@ -143,7 +144,7 @@ "name": "@devup-api/react-query", "version": "0.1.5", "dependencies": { - "@devup-api/fetch": "workspace:*", + "@devup-api/fetch": "workspace:^", "@tanstack/react-query": ">=5.90", }, "devDependencies": { @@ -194,6 +195,7 @@ }, "devDependencies": { "@types/node": "^25.0", + "openapi-types": "^12.1", "typescript": "^5.9", }, "peerDependencies": { @@ -218,6 +220,22 @@ "@devup-api/core": "*", }, }, + "packages/zod": { + "name": "@devup-api/zod", + "version": "0.1.13", + "dependencies": { + "@devup-api/fetch": "workspace:^", + "zod": ">=4", + }, + "devDependencies": { + "@types/node": "^25.0", + "typescript": "^5.9", + "zod": "^4.3.5", + }, + "peerDependencies": { + "zod": "^3.0.0", + }, + }, }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], @@ -298,6 +316,8 @@ "@devup-api/webpack-plugin": ["@devup-api/webpack-plugin@workspace:packages/webpack-plugin"], + "@devup-api/zod": ["@devup-api/zod@workspace:packages/zod"], + "@devup-ui/next-plugin": ["@devup-ui/next-plugin@1.0.59", "", { "dependencies": { "@devup-ui/wasm": "1.0.51", "@devup-ui/webpack-plugin": "1.0.49", "glob": "^13.0", "next": "^16.0" } }, "sha512-u44tjZZROuWkny/b5ZRvmiNSRvpgvrs+91npGejTx3w7dp+9y17B3GajrNkEytglT5c/45ItAaOR3fwRYwWnbA=="], "@devup-ui/react": ["@devup-ui/react@1.0.29", "", { "dependencies": { "csstype-extra": "latest", "react": "^19.2" } }, "sha512-RGmnTOMsksV/IOiqU9yCQZW+hQ/WAgo4qVyh+gsbzZQc4tM3fDUKctOF72Wm2Be7n2sn+JVHhocEaAWNkPeIig=="], @@ -858,12 +878,16 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@devup-api/zod/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@devup-ui/next-plugin/next": ["next@16.0.10", "", { "dependencies": { "@next/env": "16.0.10", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.10", "@next/swc-darwin-x64": "16.0.10", "@next/swc-linux-arm64-gnu": "16.0.10", "@next/swc-linux-arm64-musl": "16.0.10", "@next/swc-linux-x64-gnu": "16.0.10", "@next/swc-linux-x64-musl": "16.0.10", "@next/swc-win32-arm64-msvc": "16.0.10", "@next/swc-win32-x64-msvc": "16.0.10", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA=="], "@devup-ui/webpack-plugin/@devup-ui/wasm": ["@devup-ui/wasm@1.0.47", "", {}, "sha512-RPktfdg53bK5BqAyhfs9hA5vzAiH0D63w60S+ACaoIPXpqQaQp2Lh9pl3Mi6E+8KA0Div/hoQCLfYxuAefodrg=="], diff --git a/examples/next/app/page.tsx b/examples/next/app/page.tsx index 35a63e6..dc52183 100644 --- a/examples/next/app/page.tsx +++ b/examples/next/app/page.tsx @@ -2,6 +2,7 @@ import { createApi, type DevupObject } from '@devup-api/fetch' import { createQueryClient } from '@devup-api/react-query' +import { schemas } from '@devup-api/zod' import { Box, Text } from '@devup-ui/react' import { useEffect } from 'react' @@ -15,6 +16,13 @@ const api2 = createApi({ const queryClient = createQueryClient(api) +// Example usage of Zod schemas (will be populated after build) +const schema = schemas['openapi.json'].request.CreateUserRequest +const _a = schema.parse({ + name: 'John Doe', + email: 'foo@bar.com', +}) + export default function Home() { const { data, isLoading, error } = queryClient.useQuery('GET', 'getUsers', { // params: { id: 1 }, diff --git a/examples/next/package.json b/examples/next/package.json index 8204d4a..19d7ab2 100644 --- a/examples/next/package.json +++ b/examples/next/package.json @@ -15,6 +15,7 @@ "@devup-api/next-plugin": "workspace:*", "@devup-api/fetch": "workspace:*", "@devup-api/react-query": "workspace:*", + "@devup-api/zod": "workspace:*", "@devup-ui/react": "^1" }, "devDependencies": { diff --git a/package.json b/package.json index 9ac504e..d6dd515 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "lint": "biome check", "lint:fix": "biome check --write", "prepare": "husky", - "build": "bun run -F @devup-api/core build && bun run -F @devup-api/utils build && bun run -F @devup-api/generator build && bun run -F @devup-api/fetch build && bun run -F @devup-api/webpack-plugin build && bun run -F @devup-api/vite-plugin build && bun run -F @devup-api/next-plugin build && bun run -F @devup-api/rsbuild-plugin build && bun run -F @devup-api/react-query build", - "publish": "bun publish --cwd packages/core && bun publish --cwd packages/utils && bun publish --cwd packages/generator && bun publish --cwd packages/fetch && bun publish --cwd packages/webpack-plugin && bun publish --cwd packages/vite-plugin && bun publish --cwd packages/next-plugin && bun publish --cwd packages/rsbuild-plugin && bun publish --cwd packages/react-query" + "build": "bun run -F @devup-api/core build && bun run -F @devup-api/utils build && bun run -F @devup-api/generator build && bun run -F @devup-api/fetch build && bun run -F @devup-api/zod build && bun run -F @devup-api/webpack-plugin build && bun run -F @devup-api/vite-plugin build && bun run -F @devup-api/next-plugin build && bun run -F @devup-api/rsbuild-plugin build && bun run -F @devup-api/react-query build", + "publish": "bun publish --cwd packages/core && bun publish --cwd packages/utils && bun publish --cwd packages/generator && bun publish --cwd packages/fetch && bun publish --cwd packages/zod && bun publish --cwd packages/webpack-plugin && bun publish --cwd packages/vite-plugin && bun publish --cwd packages/next-plugin && bun publish --cwd packages/rsbuild-plugin && bun publish --cwd packages/react-query" }, "workspaces": [ "packages/*", diff --git a/packages/generator/src/__tests__/generate-zod.test.ts b/packages/generator/src/__tests__/generate-zod.test.ts new file mode 100644 index 0000000..db186f8 --- /dev/null +++ b/packages/generator/src/__tests__/generate-zod.test.ts @@ -0,0 +1,2925 @@ +import { describe, expect, test } from 'bun:test' +import type { OpenAPIV3_1 } from 'openapi-types' +import { + generateZodSchemas, + generateZodTypeDeclarations, +} from '../generate-zod' + +// ============================================================================= +// Helper +// ============================================================================= + +const createDocument = ( + doc: Partial = {}, +): OpenAPIV3_1.Document => + ({ + openapi: '3.1.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: {}, + ...doc, + }) as OpenAPIV3_1.Document + +// ============================================================================= +// generateZodSchemas - Basic functionality +// ============================================================================= + +describe('generateZodSchemas', () => { + test('generates import statement', () => { + const result = generateZodSchemas({ 'openapi.json': createDocument() }) + expect(result).toContain('import { z } from "zod"') + }) + + test('generates schema exports', () => { + const result = generateZodSchemas({ 'openapi.json': createDocument() }) + expect(result).toContain('export const schemas') + expect(result).toContain('export const requestSchemas') + expect(result).toContain('export const responseSchemas') + expect(result).toContain('export const errorSchemas') + }) + + test('generates response schema from component', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/users': { + get: { + operationId: 'getUsers', + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/User' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + User: { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + }, + required: ['id', 'name'], + }, + }, + }, + }), + }) + + expect(result).toContain('z.object') + expect(result).toContain('z.number().int()') + expect(result).toContain('z.string()') + }) + + test('generates request schema from component', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/users': { + post: { + operationId: 'createUser', + requestBody: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/CreateUserRequest' }, + }, + }, + }, + responses: { '201': { description: 'Created' } }, + }, + }, + }, + components: { + schemas: { + CreateUserRequest: { + type: 'object', + properties: { + name: { type: 'string' }, + email: { type: 'string', format: 'email' }, + }, + required: ['name', 'email'], + }, + }, + }, + }), + }) + + expect(result).toContain('requestSchemas') + expect(result).toContain('z.string()') + expect(result).toContain('.email()') + }) + + test('generates error schema from component', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/users': { + get: { + operationId: 'getUsers', + responses: { + '200': { description: 'Success' }, + '400': { + description: 'Bad Request', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ApiError' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + ApiError: { + type: 'object', + properties: { + message: { type: 'string' }, + code: { type: 'integer' }, + }, + required: ['message'], + }, + }, + }, + }), + }) + + expect(result).toContain('errorSchemas') + expect(result).toContain('ApiError') + }) +}) + +// ============================================================================= +// Primitive types conversion +// ============================================================================= + +describe('generateZodSchemas - primitive types', () => { + test('converts string type', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'string' }, + }, + }, + }), + }) + + expect(result).toContain('z.string()') + }) + + test('converts number type', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'number' }, + }, + }, + }), + }) + + expect(result).toContain('z.number()') + }) + + test('converts integer type', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'integer' }, + }, + }, + }), + }) + + expect(result).toContain('z.number().int()') + }) + + test('converts boolean type', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'boolean' }, + }, + }, + }), + }) + + expect(result).toContain('z.boolean()') + }) +}) + +// ============================================================================= +// Complex types conversion +// ============================================================================= + +describe('generateZodSchemas - complex types', () => { + test('converts array type', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + }), + }) + + expect(result).toContain('z.array(z.string())') + }) + + test('converts enum type', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + type: 'string', + enum: ['active', 'inactive', 'pending'], + }, + }, + }, + }), + }) + + expect(result).toContain('z.enum') + expect(result).toContain('"active"') + expect(result).toContain('"inactive"') + expect(result).toContain('"pending"') + }) + + test('converts object with optional properties', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + responses: { '200': { description: 'Success' } }, + }, + }, + }, + components: { + schemas: { + Test: { + type: 'object', + properties: { + required_field: { type: 'string' }, + optional_field: { type: 'string' }, + }, + required: ['required_field'], + }, + }, + }, + }), + }) + + expect(result).toContain('required_field: z.string()') + expect(result).toContain('optional_field: z.string().optional()') + }) +}) + +// ============================================================================= +// String format validation +// ============================================================================= + +describe('generateZodSchemas - string formats', () => { + test('adds email validation', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'string', format: 'email' }, + }, + }, + }), + }) + + expect(result).toContain('z.string().email()') + }) + + test('adds url validation', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'string', format: 'uri' }, + }, + }, + }), + }) + + expect(result).toContain('z.string().url()') + }) + + test('adds uuid validation', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'string', format: 'uuid' }, + }, + }, + }), + }) + + expect(result).toContain('z.string().uuid()') + }) +}) + +// ============================================================================= +// Number validation +// ============================================================================= + +describe('generateZodSchemas - number validation', () => { + test('adds min/max validation', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'integer', minimum: 0, maximum: 100 }, + }, + }, + }), + }) + + expect(result).toContain('.min(0)') + expect(result).toContain('.max(100)') + }) +}) + +// ============================================================================= +// Multi-server support +// ============================================================================= + +describe('generateZodSchemas - multi-server', () => { + test('generates schemas for multiple servers', () => { + const result = generateZodSchemas({ + 'main-api.json': createDocument({ + paths: { + '/users': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/User' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + User: { type: 'object', properties: { id: { type: 'integer' } } }, + }, + }, + }), + 'admin-api.json': createDocument({ + paths: { + '/admin': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Admin' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Admin: { type: 'object', properties: { role: { type: 'string' } } }, + }, + }, + }), + }) + + expect(result).toContain('main_api_json') + expect(result).toContain('admin_api_json') + expect(result).toContain('User') + expect(result).toContain('Admin') + }) +}) + +// ============================================================================= +// generateZodTypeDeclarations +// ============================================================================= + +describe('generateZodTypeDeclarations', () => { + test('generates module augmentation', () => { + const result = generateZodTypeDeclarations({ + 'openapi.json': createDocument({ + paths: { + '/users': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/User' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + User: { type: 'object', properties: { id: { type: 'integer' } } }, + }, + }, + }), + }) + + expect(result).toContain('import "@devup-api/zod"') + expect(result).toContain('declare module "@devup-api/zod"') + expect(result).toContain('interface DevupZodResponseSchemas') + // Should contain specific Zod types instead of generic z.ZodType + expect(result).toContain('z.ZodObject<') + }) + + test('generates request schema types', () => { + const result = generateZodTypeDeclarations({ + 'openapi.json': createDocument({ + paths: { + '/users': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/CreateUser' }, + }, + }, + }, + responses: { '200': { description: 'Success' } }, + }, + }, + }, + components: { + schemas: { + CreateUser: { + type: 'object', + properties: { name: { type: 'string' } }, + }, + }, + }, + }), + }) + + expect(result).toContain('interface DevupZodRequestSchemas') + expect(result).toContain('CreateUser') + }) + + test('generates error schema types', () => { + const result = generateZodTypeDeclarations({ + 'openapi.json': createDocument({ + paths: { + '/users': { + get: { + responses: { + '200': { description: 'Success' }, + '400': { + description: 'Error', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ApiError' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + ApiError: { + type: 'object', + properties: { message: { type: 'string' } }, + }, + }, + }, + }), + }) + + expect(result).toContain('interface DevupZodErrorSchemas') + expect(result).toContain('ApiError') + }) +}) + +// ============================================================================= +// Nullable and Union Types +// ============================================================================= + +describe('generateZodSchemas - nullable and union types', () => { + test('handles nullable string (OpenAPI 3.0 style)', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'string', nullable: true }, + }, + }, + }), + }) + + expect(result).toContain('.nullable()') + }) + + test('handles nullable (OpenAPI 3.1 style with type array)', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + type: 'object', + properties: { + nullableField: { + type: ['string', 'null'] as unknown as 'string', + }, + }, + }, + }, + }, + }), + }) + + // The type array check is in isNullable() and should trigger .nullable() + expect(result).toContain('.nullable()') + }) + + test('handles allOf composition', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + allOf: [ + { type: 'object', properties: { a: { type: 'string' } } }, + { type: 'object', properties: { b: { type: 'number' } } }, + ], + }, + }, + }, + }), + }) + + expect(result).toContain('z.intersection') + }) + + test('handles oneOf union', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + oneOf: [{ type: 'string' }, { type: 'number' }], + }, + }, + }, + }), + }) + + expect(result).toContain('z.union') + }) + + test('handles anyOf union', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + anyOf: [{ type: 'string' }, { type: 'boolean' }], + }, + }, + }, + }), + }) + + expect(result).toContain('z.union') + }) + + test('handles single enum value as literal', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'string', enum: ['only_value'] }, + }, + }, + }), + }) + + expect(result).toContain('z.literal') + }) +}) + +// ============================================================================= +// More String Formats +// ============================================================================= + +describe('generateZodSchemas - more string formats', () => { + test('adds datetime validation', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'string', format: 'date-time' }, + }, + }, + }), + }) + + expect(result).toContain('.datetime()') + }) + + test('adds url validation for format url', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'string', format: 'url' }, + }, + }, + }), + }) + + expect(result).toContain('.url()') + }) + + test('adds minLength and maxLength validation', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'string', minLength: 1, maxLength: 100 }, + }, + }, + }), + }) + + expect(result).toContain('.min(1)') + expect(result).toContain('.max(100)') + }) + + test('adds regex pattern validation', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'string', pattern: '^[a-z]+$' }, + }, + }, + }), + }) + + expect(result).toContain('.regex(') + }) +}) + +// ============================================================================= +// More Number Validations +// ============================================================================= + +describe('generateZodSchemas - more number validations', () => { + test('adds exclusive minimum/maximum validation', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + type: 'number', + exclusiveMinimum: 0, + exclusiveMaximum: 100, + }, + }, + }, + }), + }) + + expect(result).toContain('.gt(0)') + expect(result).toContain('.lt(100)') + }) +}) + +// ============================================================================= +// Array Validations +// ============================================================================= + +describe('generateZodSchemas - array validations', () => { + test('adds min/max items validation', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + type: 'array', + items: { type: 'string' }, + minItems: 1, + maxItems: 10, + }, + }, + }, + }), + }) + + expect(result).toContain('.min(1)') + expect(result).toContain('.max(10)') + }) + + test('handles array without items', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'array' }, + }, + }, + }), + }) + + expect(result).toContain('z.array(z.unknown())') + }) +}) + +// ============================================================================= +// Object Validations +// ============================================================================= + +describe('generateZodSchemas - object validations', () => { + test('handles additionalProperties true', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + type: 'object', + properties: { id: { type: 'string' } }, + additionalProperties: true, + }, + }, + }, + }), + }) + + expect(result).toContain('.passthrough()') + }) + + test('handles additionalProperties with schema', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + type: 'object', + properties: { id: { type: 'string' } }, + additionalProperties: { type: 'string' }, + }, + }, + }, + }), + }) + + expect(result).toContain('.passthrough()') + }) + + test('handles empty object', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'object' }, + }, + }, + }), + }) + + expect(result).toContain('z.object({})') + }) + + test('handles object with default values', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + responses: { '200': { description: 'Success' } }, + }, + }, + }, + components: { + schemas: { + Test: { + type: 'object', + properties: { + name: { type: 'string', default: 'John' }, + }, + }, + }, + }, + }), + }) + + // With default value and requestDefaultNonNullable=false, should still be optional + expect(result).toContain('name: z.string().optional()') + }) +}) + +// ============================================================================= +// Ref Handling +// ============================================================================= + +describe('generateZodSchemas - ref handling', () => { + test('handles $ref in schema', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Wrapper' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Inner: { type: 'string' }, + Wrapper: { + type: 'object', + properties: { + inner: { $ref: '#/components/schemas/Inner' }, + }, + }, + }, + }, + }), + }) + + expect(result).toContain('z.lazy') + }) + + test('handles unresolved $ref', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + type: 'object', + properties: { + // External ref that can't be resolved + external: { $ref: 'external.json#/schemas/Foo' }, + }, + }, + }, + }, + }), + }) + + expect(result).toContain('z.unknown()') + }) + + test('handles request body with $ref', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + post: { + requestBody: { + $ref: '#/components/requestBodies/CreateTest', + }, + responses: { '200': { description: 'Success' } }, + }, + }, + }, + components: { + requestBodies: { + CreateTest: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + schemas: { + Test: { type: 'object', properties: { name: { type: 'string' } } }, + }, + }, + }), + }) + + expect(result).toContain('requestSchemas') + }) +}) + +// ============================================================================= +// Path Methods +// ============================================================================= + +describe('generateZodSchemas - path methods', () => { + test('handles PUT method', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + put: { + requestBody: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + responses: { '200': { description: 'Success' } }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'object', properties: { id: { type: 'string' } } }, + }, + }, + }), + }) + + expect(result).toContain('requestSchemas') + }) + + test('handles DELETE method', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + delete: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'boolean' }, + }, + }, + }), + }) + + expect(result).toContain('z.boolean()') + }) + + test('handles PATCH method', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + patch: { + requestBody: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + responses: { '200': { description: 'Success' } }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'object', properties: { name: { type: 'string' } } }, + }, + }, + }), + }) + + expect(result).toContain('requestSchemas') + }) +}) + +// ============================================================================= +// Error Status Codes +// ============================================================================= + +describe('generateZodSchemas - error status codes', () => { + test('handles default error response', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { description: 'Success' }, + default: { + description: 'Error', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Error: { type: 'object', properties: { msg: { type: 'string' } } }, + }, + }, + }), + }) + + expect(result).toContain('errorSchemas') + }) + + test('handles 5xx error responses', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { description: 'Success' }, + '500': { + description: 'Server Error', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ServerError' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + ServerError: { + type: 'object', + properties: { error: { type: 'string' } }, + }, + }, + }, + }), + }) + + expect(result).toContain('errorSchemas') + expect(result).toContain('ServerError') + }) +}) + +// ============================================================================= +// Edge Cases +// ============================================================================= + +describe('generateZodSchemas - edge cases', () => { + test('handles empty paths', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ paths: {} }), + }) + + expect(result).toContain('import { z } from "zod"') + expect(result).toContain('export const schemas') + }) + + test('handles null pathItem', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': null as never, + }, + }), + }) + + expect(result).toContain('export const schemas') + }) + + test('handles missing components', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { '200': { description: 'Success' } }, + }, + }, + }, + }), + }) + + expect(result).toContain('export const schemas') + }) + + test('handles allOf with single schema', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + allOf: [{ type: 'string' }], + }, + }, + }, + }), + }) + + expect(result).toContain('z.string()') + expect(result).not.toContain('z.intersection') + }) + + test('handles oneOf with single schema', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + oneOf: [{ type: 'number' }], + }, + }, + }, + }), + }) + + expect(result).toContain('z.number()') + expect(result).not.toContain('z.union') + }) + + test('handles empty allOf', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { allOf: [] }, + }, + }, + }), + }) + + expect(result).toContain('z.unknown()') + }) + + test('handles empty oneOf', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { oneOf: [] }, + }, + }, + }), + }) + + expect(result).toContain('z.unknown()') + }) + + test('handles unknown type', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: {} as OpenAPIV3_1.SchemaObject, + }, + }, + }), + }) + + expect(result).toContain('z.unknown()') + }) + + test('normalizes server name with ./ prefix', () => { + const result = generateZodSchemas({ + './openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'string' }, + }, + }, + }), + }) + + expect(result).toContain('openapi_json') + expect(result).not.toContain('./openapi.json') + }) +}) + +// ============================================================================= +// generateZodTypeDeclarations - Additional Coverage +// ============================================================================= + +describe('generateZodTypeDeclarations - type generation', () => { + test('generates ZodArray type', () => { + const result = generateZodTypeDeclarations({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'array', items: { type: 'string' } }, + }, + }, + }), + }) + + expect(result).toContain('z.ZodArray') + }) + + test('generates ZodNullable type', () => { + const result = generateZodTypeDeclarations({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'string', nullable: true }, + }, + }, + }), + }) + + expect(result).toContain('z.ZodNullable') + }) + + test('generates ZodUnion type', () => { + const result = generateZodTypeDeclarations({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { oneOf: [{ type: 'string' }, { type: 'number' }] }, + }, + }, + }), + }) + + expect(result).toContain('z.ZodUnion<[z.ZodString, z.ZodNumber]>') + }) + + test('generates ZodIntersection type', () => { + const result = generateZodTypeDeclarations({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + allOf: [ + { type: 'object', properties: { a: { type: 'string' } } }, + { type: 'object', properties: { b: { type: 'number' } } }, + ], + }, + }, + }, + }), + }) + + expect(result).toContain('z.ZodIntersection<') + }) + + test('generates ZodEnum type', () => { + const result = generateZodTypeDeclarations({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'string', enum: ['a', 'b'] }, + }, + }, + }), + }) + + expect(result).toContain('z.ZodEnum<') + }) + + test('generates ZodLiteral type for single enum', () => { + const result = generateZodTypeDeclarations({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'string', enum: ['only'] }, + }, + }, + }), + }) + + expect(result).toContain('z.ZodLiteral<"only">') + }) + + test('generates ZodBoolean type', () => { + const result = generateZodTypeDeclarations({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'boolean' }, + }, + }, + }), + }) + + expect(result).toContain('z.ZodBoolean') + }) + + test('generates ZodOptional type for optional properties', () => { + const result = generateZodTypeDeclarations({ + 'openapi.json': createDocument({ + paths: { + '/test': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + responses: { '200': { description: 'Success' } }, + }, + }, + }, + components: { + schemas: { + Test: { + type: 'object', + properties: { + optional_field: { type: 'string' }, + }, + }, + }, + }, + }), + }) + + expect(result).toContain('z.ZodOptional') + }) + + test('generates empty object type', () => { + const result = generateZodTypeDeclarations({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'object' }, + }, + }, + }), + }) + + expect(result).toContain('z.ZodObject>') + }) + + test('generates ZodUnknown for unknown schema', () => { + const result = generateZodTypeDeclarations({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: {} as OpenAPIV3_1.SchemaObject, + }, + }, + }), + }) + + expect(result).toContain('z.ZodUnknown') + }) + + test('generates ZodLazy for $ref', () => { + const result = generateZodTypeDeclarations({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Wrapper' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Inner: { type: 'string' }, + Wrapper: { + type: 'object', + properties: { + inner: { $ref: '#/components/schemas/Inner' }, + }, + }, + }, + }, + }), + }) + + expect(result).toContain('z.ZodLazy') + }) + + test('handles array without items in type declaration', () => { + const result = generateZodTypeDeclarations({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { type: 'array' }, + }, + }, + }), + }) + + expect(result).toContain('z.ZodArray') + }) + + test('handles nullable type array in type declaration (OpenAPI 3.1)', () => { + const result = generateZodTypeDeclarations({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + type: ['number', 'null'] as unknown as 'number', + }, + }, + }, + }), + }) + + expect(result).toContain('z.ZodNullable') + }) + + test('handles $ref that resolves to nested $ref in type declaration', () => { + const result = generateZodTypeDeclarations({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + // Outer schema that isn't in components (external ref scenario) + Test: { type: 'string' }, + }, + }, + }), + }) + + expect(result).toContain('z.ZodString') + }) +}) + +// ============================================================================= +// Schema Collection Edge Cases +// ============================================================================= + +describe('generateZodSchemas - schema collection edge cases', () => { + test('collects schemas from allOf in response', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + allOf: [{ $ref: '#/components/schemas/Base' }], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Base: { type: 'object', properties: { id: { type: 'string' } } }, + }, + }, + }), + }) + + expect(result).toContain('responseSchemas') + expect(result).toContain('Base') + }) + + test('collects schemas from anyOf in response', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + anyOf: [{ $ref: '#/components/schemas/TypeA' }], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + TypeA: { type: 'string' }, + }, + }, + }), + }) + + expect(result).toContain('responseSchemas') + expect(result).toContain('TypeA') + }) + + test('collects schemas from oneOf in response', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + oneOf: [{ $ref: '#/components/schemas/TypeB' }], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + TypeB: { type: 'number' }, + }, + }, + }), + }) + + expect(result).toContain('responseSchemas') + expect(result).toContain('TypeB') + }) + + test('collects schemas from properties in response', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + nested: { $ref: '#/components/schemas/Nested' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Nested: { type: 'boolean' }, + }, + }, + }), + }) + + expect(result).toContain('responseSchemas') + expect(result).toContain('Nested') + }) + + test('collects schemas from array items in response', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/Item' }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Item: { type: 'string' }, + }, + }, + }), + }) + + expect(result).toContain('responseSchemas') + expect(result).toContain('Item') + }) + + test('handles response with $ref to response object', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + $ref: '#/components/responses/SuccessResponse', + } as unknown as OpenAPIV3_1.ResponseObject, + }, + }, + }, + }, + components: { + responses: { + SuccessResponse: { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Data' }, + }, + }, + }, + }, + schemas: { + Data: { type: 'object', properties: { id: { type: 'integer' } } }, + }, + }, + }), + }) + + // Response $ref to response object - extractSchemaNameFromRef returns null + // for non-component-schema refs, so Data won't be collected + expect(result).toContain('export const schemas') + }) + + test('handles error response with $ref', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { description: 'Success' }, + '500': { + $ref: '#/components/responses/ServerError', + } as unknown as OpenAPIV3_1.ResponseObject, + }, + }, + }, + }, + components: { + responses: { + ServerError: { + description: 'Server Error', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ErrorBody' }, + }, + }, + }, + }, + schemas: { + ErrorBody: { + type: 'object', + properties: { msg: { type: 'string' } }, + }, + }, + }, + }), + }) + + expect(result).toContain('export const schemas') + }) + + test('handles allOf in error response', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { description: 'Success' }, + '400': { + description: 'Bad Request', + content: { + 'application/json': { + schema: { + allOf: [{ $ref: '#/components/schemas/ErrorBase' }], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + ErrorBase: { + type: 'object', + properties: { code: { type: 'integer' } }, + }, + }, + }, + }), + }) + + expect(result).toContain('errorSchemas') + expect(result).toContain('ErrorBase') + }) + + test('handles requestBody $ref to requestBodies with schema $ref', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + post: { + requestBody: { + $ref: '#/components/requestBodies/TestRequest', + }, + responses: { '200': { description: 'Success' } }, + }, + }, + }, + components: { + requestBodies: { + TestRequest: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/RequestPayload' }, + }, + }, + }, + }, + schemas: { + RequestPayload: { + type: 'object', + properties: { data: { type: 'string' } }, + }, + }, + }, + }), + }) + + // When requestBody has $ref, extractSchemaNameFromRef is called + // but it only extracts from #/components/schemas/ path + expect(result).toContain('export const schemas') + }) +}) + +// ============================================================================= +// Ref Resolution Edge Cases +// ============================================================================= + +describe('generateZodSchemas - ref resolution edge cases', () => { + test('resolves $ref with nested object path', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Deep' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Deep: { + type: 'object', + properties: { + // This ref points to a component schema + child: { $ref: '#/components/schemas/Child' }, + }, + }, + Child: { type: 'string' }, + }, + }, + }), + }) + + expect(result).toContain('z.lazy') + expect(result).toContain('_Child') + }) + + test('handles $ref that resolves to another schema object (not in schemaRefs)', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + // This ref points to a path that exists but isn't a component schema + schema: { $ref: '#/paths/~1test/get/responses/200' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: {}, + }, + }), + }) + + // Should fall through to z.unknown() when ref can't be properly resolved + expect(result).toContain('export const schemas') + }) + + test('handles property with default value via $ref', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + responses: { '200': { description: 'Success' } }, + }, + }, + }, + components: { + schemas: { + DefaultValue: { + type: 'string', + default: 'hello', + }, + Test: { + type: 'object', + properties: { + withDefault: { $ref: '#/components/schemas/DefaultValue' }, + }, + }, + }, + }, + }), + }) + + expect(result).toContain('requestSchemas') + }) + + test('handles requestBody $ref directly to schema', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + post: { + // This is an unusual but valid pattern - requestBody $ref to schema + requestBody: { + $ref: '#/components/schemas/DirectRequestSchema', + } as unknown as OpenAPIV3_1.RequestBodyObject, + responses: { '200': { description: 'Success' } }, + }, + }, + }, + components: { + schemas: { + DirectRequestSchema: { + type: 'object', + properties: { data: { type: 'string' } }, + }, + }, + }, + }), + }) + + expect(result).toContain('requestSchemas') + expect(result).toContain('DirectRequestSchema') + }) + + test('handles success response $ref directly to schema', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + // Direct $ref to schema (unusual but for coverage) + $ref: '#/components/schemas/DirectResponseSchema', + } as unknown as OpenAPIV3_1.ResponseObject, + }, + }, + }, + }, + components: { + schemas: { + DirectResponseSchema: { + type: 'object', + properties: { result: { type: 'boolean' } }, + }, + }, + }, + }), + }) + + expect(result).toContain('responseSchemas') + expect(result).toContain('DirectResponseSchema') + }) + + test('handles error response $ref directly to schema', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { description: 'Success' }, + '400': { + // Direct $ref to schema for error + $ref: '#/components/schemas/DirectErrorSchema', + } as unknown as OpenAPIV3_1.ResponseObject, + }, + }, + }, + }, + components: { + schemas: { + DirectErrorSchema: { + type: 'object', + properties: { error: { type: 'string' } }, + }, + }, + }, + }), + }) + + expect(result).toContain('errorSchemas') + expect(result).toContain('DirectErrorSchema') + }) +}) + +// ============================================================================= +// resolveSchemaRef Edge Cases +// ============================================================================= + +describe('generateZodSchemas - resolveSchemaRef edge cases', () => { + test('handles $ref with invalid path segment', () => { + const result = generateZodSchemas({ + 'openapi.json': createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + type: 'object', + properties: { + // This ref points to a path that doesn't exist + invalid: { $ref: '#/components/nonexistent/path' }, + }, + }, + }, + }, + }), + }) + + // Should return z.unknown() for unresolvable refs + expect(result).toContain('z.unknown()') + }) + + test('handles $ref that resolves to nested $ref object', () => { + const doc = createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + type: 'object', + properties: { + // This is a special edge case - pointing to something that has $ref + nested: { $ref: '#/info' }, + }, + }, + }, + }, + }) + + // Manually add $ref to info to test the nested $ref case + ;(doc.info as unknown as { $ref: string }).$ref = '#/somewhere/else' + + const result = generateZodSchemas({ 'openapi.json': doc }) + + // When resolved object has $ref, resolveSchemaRef returns null + expect(result).toContain('z.unknown()') + }) + + test('handles non-component $ref that resolves to valid schema', () => { + const doc = createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + type: 'object', + properties: { + // Points to a custom location that has a valid schema + custom: { $ref: '#/x-custom-schemas/MySchema' }, + }, + }, + }, + }, + }) + + // Add custom schema location (OpenAPI allows x- extensions) + ;(doc as unknown as Record)['x-custom-schemas'] = { + MySchema: { type: 'string' }, + } + + const result = generateZodSchemas({ 'openapi.json': doc }) + + // Should resolve and convert the schema + expect(result).toContain('z.string()') + }) +}) + +// ============================================================================= +// schemaToZodType $ref resolution +// ============================================================================= + +describe('generateZodTypeDeclarations - $ref resolution', () => { + test('handles non-component $ref that resolves to valid schema', () => { + const doc = createDocument({ + paths: { + '/test': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Test' }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Test: { + type: 'object', + properties: { + // Points to a custom location + custom: { $ref: '#/x-types/CustomType' }, + }, + }, + }, + }, + }) + + // Add custom type location + ;(doc as unknown as Record)['x-types'] = { + CustomType: { type: 'number' }, + } + + const result = generateZodTypeDeclarations({ 'openapi.json': doc }) + + // Should resolve and use ZodNumber type + expect(result).toContain('z.ZodNumber') + }) +}) diff --git a/packages/generator/src/__tests__/index.test.ts b/packages/generator/src/__tests__/index.test.ts index 369b8ec..f4539fd 100644 --- a/packages/generator/src/__tests__/index.test.ts +++ b/packages/generator/src/__tests__/index.test.ts @@ -5,5 +5,7 @@ test('index.ts exports', () => { expect({ ...indexModule }).toEqual({ createUrlMap: expect.any(Function), generateInterface: expect.any(Function), + generateZodSchemas: expect.any(Function), + generateZodTypeDeclarations: expect.any(Function), }) }) diff --git a/packages/generator/src/generate-zod.ts b/packages/generator/src/generate-zod.ts new file mode 100644 index 0000000..84435b9 --- /dev/null +++ b/packages/generator/src/generate-zod.ts @@ -0,0 +1,894 @@ +import type { DevupApiTypeGeneratorOptions } from '@devup-api/core' +import type { OpenAPIV3_1 } from 'openapi-types' +import { wrapInterfaceKeyGuard } from './wrap-interface-key-guard' + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Normalize server name by removing ./ prefix + */ +function normalizeServerName(serverName: string): string { + return serverName.replace(/^\.\//, '') +} + +/** + * Resolve $ref reference in OpenAPI schema + */ +function resolveSchemaRef< + T extends OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ParameterObject, +>(ref: string, document: OpenAPIV3_1.Document): T | null { + if (!ref.startsWith('#/')) { + return null + } + + const parts = ref.slice(2).split('/') + let current: unknown = document + + for (const part of parts) { + if (current && typeof current === 'object' && part in current) { + current = (current as Record)[part] + } else { + return null + } + } + + if (current && typeof current === 'object' && !('$ref' in current)) { + return current as T + } + + return null +} + +/** + * Extract schema name from $ref + */ +function extractSchemaNameFromRef(ref: string): string | null { + if (ref.startsWith('#/components/schemas/')) { + return ref.replace('#/components/schemas/', '') + } + return null +} + +/** + * Check if status code is an error response + */ +function isErrorStatusCode(statusCode: string): boolean { + if (statusCode === 'default') return true + const code = parseInt(statusCode, 10) + return code >= 400 && code < 600 +} + +// ============================================================================= +// OpenAPI to Zod Conversion +// ============================================================================= + +/** + * Convert OpenAPI schema to Zod schema code string + */ +function schemaToZod( + schema: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject, + document: OpenAPIV3_1.Document, + schemaRefs: Map, + options?: { defaultNonNullable?: boolean }, +): string { + const defaultNonNullable = options?.defaultNonNullable ?? false + + // Handle $ref + if ('$ref' in schema) { + const schemaName = extractSchemaNameFromRef(schema.$ref) + if (schemaName && schemaRefs.has(schemaName)) { + // Return lazy reference for circular dependencies + return `z.lazy(() => ${schemaRefs.get(schemaName)})` + } + const resolved = resolveSchemaRef( + schema.$ref, + document, + ) + if (resolved) { + return schemaToZod(resolved, document, schemaRefs, options) + } + return 'z.unknown()' + } + + const schemaObj = schema as OpenAPIV3_1.SchemaObject + + // Handle nullable (OpenAPI 3.0 uses 'nullable', OpenAPI 3.1 uses type array with 'null') + const isNullable = (): boolean => { + // Check for OpenAPI 3.0 style nullable + if ('nullable' in schemaObj && schemaObj.nullable === true) { + return true + } + // Check for OpenAPI 3.1 style nullable (type: ["string", "null"]) + if (Array.isArray(schemaObj.type) && schemaObj.type.includes('null')) { + return true + } + return false + } + + const wrapNullable = (zodStr: string): string => { + if (isNullable()) { + return `${zodStr}.nullable()` + } + return zodStr + } + + // Helper to get the primary type from OpenAPI 3.1 type array + const getPrimaryType = (): string | undefined => { + if (Array.isArray(schemaObj.type)) { + // Filter out 'null' to get the primary type + const nonNullTypes = schemaObj.type.filter((t) => t !== 'null') + return nonNullTypes[0] + } + return schemaObj.type + } + + const primaryType = getPrimaryType() + + // Handle allOf (intersection) + if (schemaObj.allOf) { + const schemas = schemaObj.allOf.map((s) => + schemaToZod(s, document, schemaRefs, options), + ) + if (schemas.length === 0) return 'z.unknown()' + if (schemas.length === 1) return wrapNullable(schemas[0] as string) + return wrapNullable(`z.intersection(${schemas.join(', ')})`) + } + + // Handle oneOf/anyOf (union) + if (schemaObj.oneOf || schemaObj.anyOf) { + const schemas = (schemaObj.oneOf || schemaObj.anyOf || []).map((s) => + schemaToZod(s, document, schemaRefs, options), + ) + if (schemas.length === 0) return 'z.unknown()' + if (schemas.length === 1) return wrapNullable(schemas[0] as string) + return wrapNullable(`z.union([${schemas.join(', ')}])`) + } + + // Handle enum + if (schemaObj.enum) { + const enumValues = schemaObj.enum.map((v) => JSON.stringify(v)) + if (enumValues.length === 1) { + return wrapNullable(`z.literal(${enumValues[0]})`) + } + return wrapNullable(`z.enum([${enumValues.join(', ')}])`) + } + + // Handle primitive types + if (primaryType === 'string') { + let zodStr = 'z.string()' + if (schemaObj.minLength !== undefined) { + zodStr += `.min(${schemaObj.minLength})` + } + if (schemaObj.maxLength !== undefined) { + zodStr += `.max(${schemaObj.maxLength})` + } + if (schemaObj.pattern) { + zodStr += `.regex(/${schemaObj.pattern}/)` + } + if (schemaObj.format === 'email') { + zodStr += '.email()' + } + if (schemaObj.format === 'uri' || schemaObj.format === 'url') { + zodStr += '.url()' + } + if (schemaObj.format === 'uuid') { + zodStr += '.uuid()' + } + if (schemaObj.format === 'date-time') { + zodStr += '.datetime()' + } + return wrapNullable(zodStr) + } + + if (primaryType === 'number' || primaryType === 'integer') { + let zodStr = primaryType === 'integer' ? 'z.number().int()' : 'z.number()' + if (schemaObj.minimum !== undefined) { + zodStr += `.min(${schemaObj.minimum})` + } + if (schemaObj.maximum !== undefined) { + zodStr += `.max(${schemaObj.maximum})` + } + if (schemaObj.exclusiveMinimum !== undefined) { + zodStr += `.gt(${schemaObj.exclusiveMinimum})` + } + if (schemaObj.exclusiveMaximum !== undefined) { + zodStr += `.lt(${schemaObj.exclusiveMaximum})` + } + return wrapNullable(zodStr) + } + + if (primaryType === 'boolean') { + return wrapNullable('z.boolean()') + } + + // Handle array + if (primaryType === 'array') { + if ('items' in schemaObj && schemaObj.items) { + const itemSchema = schemaToZod( + schemaObj.items, + document, + schemaRefs, + options, + ) + let zodStr = `z.array(${itemSchema})` + if (schemaObj.minItems !== undefined) { + zodStr += `.min(${schemaObj.minItems})` + } + if (schemaObj.maxItems !== undefined) { + zodStr += `.max(${schemaObj.maxItems})` + } + return wrapNullable(zodStr) + } + return wrapNullable('z.array(z.unknown())') + } + + // Handle object + if (primaryType === 'object' || schemaObj.properties) { + const required = new Set(schemaObj.required || []) + const properties: string[] = [] + + if (schemaObj.properties) { + for (const [key, value] of Object.entries(schemaObj.properties)) { + const propSchema = schemaToZod(value, document, schemaRefs, options) + const isRequired = required.has(key) + + // Check for default value + let hasDefault = false + if ('$ref' in value) { + const resolved = resolveSchemaRef( + value.$ref, + document, + ) + if (resolved) { + hasDefault = resolved.default !== undefined + } + } else { + hasDefault = (value as OpenAPIV3_1.SchemaObject).default !== undefined + } + + let propStr = propSchema + if (!isRequired && !(defaultNonNullable && hasDefault)) { + propStr += '.optional()' + } + + properties.push(`${wrapInterfaceKeyGuard(key)}: ${propStr}`) + } + } + + let zodStr = + properties.length > 0 + ? `z.object({\n ${properties.join(',\n ')}\n })` + : 'z.object({})' + + // Handle additionalProperties + if (schemaObj.additionalProperties === true) { + zodStr += '.passthrough()' + } else if ( + typeof schemaObj.additionalProperties === 'object' && + schemaObj.additionalProperties !== null + ) { + // For typed additional properties, we can't perfectly represent this in Zod + // We use passthrough() as an approximation + zodStr += '.passthrough()' + } + + return wrapNullable(zodStr) + } + + return 'z.unknown()' +} + +// ============================================================================= +// OpenAPI to Zod Type Conversion (for TypeScript type declarations) +// ============================================================================= + +/** + * Convert OpenAPI schema to Zod TypeScript type string + * Unlike schemaToZod which generates runtime code like z.object({...}), + * this generates TypeScript types like z.ZodObject<{...}> + */ +function schemaToZodType( + schema: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject, + document: OpenAPIV3_1.Document, + options?: { defaultNonNullable?: boolean }, +): string { + const defaultNonNullable = options?.defaultNonNullable ?? false + + // Handle $ref + if ('$ref' in schema) { + const schemaName = extractSchemaNameFromRef(schema.$ref) + if (schemaName) { + // Return a lazy type reference + return `z.ZodLazy` + } + const resolved = resolveSchemaRef( + schema.$ref, + document, + ) + if (resolved) { + return schemaToZodType(resolved, document, options) + } + return 'z.ZodUnknown' + } + + const schemaObj = schema as OpenAPIV3_1.SchemaObject + + // Handle nullable + const isNullable = (): boolean => { + if ('nullable' in schemaObj && schemaObj.nullable === true) { + return true + } + if (Array.isArray(schemaObj.type) && schemaObj.type.includes('null')) { + return true + } + return false + } + + const wrapNullable = (zodType: string): string => { + if (isNullable()) { + return `z.ZodNullable<${zodType}>` + } + return zodType + } + + // Helper to get the primary type from OpenAPI 3.1 type array + const getPrimaryType = (): string | undefined => { + if (Array.isArray(schemaObj.type)) { + // Filter out 'null' to get the primary type + const nonNullTypes = schemaObj.type.filter((t) => t !== 'null') + return nonNullTypes[0] + } + return schemaObj.type + } + + const primaryType = getPrimaryType() + + // Handle allOf (intersection) + if (schemaObj.allOf) { + const types = schemaObj.allOf.map((s) => + schemaToZodType(s, document, options), + ) + if (types.length === 0) return 'z.ZodUnknown' + if (types.length === 1) return wrapNullable(types[0] as string) + // Zod intersection only takes 2 args, so we need to nest + let result = types[0] as string + for (let i = 1; i < types.length; i++) { + result = `z.ZodIntersection<${result}, ${types[i]}>` + } + return wrapNullable(result) + } + + // Handle oneOf/anyOf (union) + if (schemaObj.oneOf || schemaObj.anyOf) { + const types = (schemaObj.oneOf || schemaObj.anyOf || []).map((s) => + schemaToZodType(s, document, options), + ) + if (types.length === 0) return 'z.ZodUnknown' + if (types.length === 1) return wrapNullable(types[0] as string) + return wrapNullable(`z.ZodUnion<[${types.join(', ')}]>`) + } + + // Handle enum + if (schemaObj.enum) { + const enumValues = schemaObj.enum.map((v) => JSON.stringify(v)) + if (enumValues.length === 1) { + return wrapNullable(`z.ZodLiteral<${enumValues[0]}>`) + } + return wrapNullable(`z.ZodEnum<[${enumValues.join(', ')}]>`) + } + + // Handle primitive types + if (primaryType === 'string') { + return wrapNullable('z.ZodString') + } + + if (primaryType === 'number' || primaryType === 'integer') { + return wrapNullable('z.ZodNumber') + } + + if (primaryType === 'boolean') { + return wrapNullable('z.ZodBoolean') + } + + // Handle array + if (primaryType === 'array') { + if ('items' in schemaObj && schemaObj.items) { + const itemType = schemaToZodType(schemaObj.items, document, options) + return wrapNullable(`z.ZodArray<${itemType}>`) + } + return wrapNullable('z.ZodArray') + } + + // Handle object + if (primaryType === 'object' || schemaObj.properties) { + const required = new Set(schemaObj.required || []) + const properties: string[] = [] + + if (schemaObj.properties) { + for (const [key, value] of Object.entries(schemaObj.properties)) { + const propType = schemaToZodType(value, document, options) + const isRequired = required.has(key) + + // Check for default value + let hasDefault = false + if ('$ref' in value) { + const resolved = resolveSchemaRef( + value.$ref, + document, + ) + if (resolved) { + hasDefault = resolved.default !== undefined + } + } else { + hasDefault = (value as OpenAPIV3_1.SchemaObject).default !== undefined + } + + let finalType = propType + if (!isRequired && !(defaultNonNullable && hasDefault)) { + finalType = `z.ZodOptional<${propType}>` + } + + properties.push(`${wrapInterfaceKeyGuard(key)}: ${finalType}`) + } + } + + const objectType = + properties.length > 0 + ? `z.ZodObject<{ ${properties.join('; ')} }>` + : 'z.ZodObject>' + + return wrapNullable(objectType) + } + + return 'z.ZodUnknown' +} + +// ============================================================================= +// Schema Collection +// ============================================================================= + +interface SchemaInfo { + code: string // Runtime Zod code + type: string // TypeScript Zod type +} + +interface CollectedSchemas { + requestSchemas: Record + responseSchemas: Record + errorSchemas: Record +} + +/** + * Collect schema names used in request, response, and error positions + */ +function collectSchemaUsage(schema: OpenAPIV3_1.Document): { + requestSchemaNames: Set + responseSchemaNames: Set + errorSchemaNames: Set +} { + const requestSchemaNames = new Set() + const responseSchemaNames = new Set() + const errorSchemaNames = new Set() + + const collectSchemaNames = ( + schemaObj: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject, + targetSet: Set, + ): void => { + if ('$ref' in schemaObj) { + const schemaName = extractSchemaNameFromRef(schemaObj.$ref) + if (schemaName) { + targetSet.add(schemaName) + } + return + } + + const s = schemaObj as OpenAPIV3_1.SchemaObject + + if (s.allOf) + s.allOf.forEach((sub) => { + collectSchemaNames(sub, targetSet) + }) + if (s.anyOf) + s.anyOf.forEach((sub) => { + collectSchemaNames(sub, targetSet) + }) + if (s.oneOf) + s.oneOf.forEach((sub) => { + collectSchemaNames(sub, targetSet) + }) + if (s.properties) { + Object.values(s.properties).forEach((prop) => { + collectSchemaNames(prop, targetSet) + }) + } + if (s.type === 'array' && 'items' in s && s.items) { + collectSchemaNames(s.items, targetSet) + } + } + + if (schema.paths) { + for (const pathItem of Object.values(schema.paths)) { + if (!pathItem) continue + + const methods = ['get', 'post', 'put', 'delete', 'patch'] as const + for (const method of methods) { + const operation = pathItem[method] + if (!operation) continue + + // Collect request body schemas + if (operation.requestBody) { + if ('$ref' in operation.requestBody) { + const schemaName = extractSchemaNameFromRef( + operation.requestBody.$ref, + ) + if (schemaName) { + requestSchemaNames.add(schemaName) + } + } else { + const content = operation.requestBody.content + const jsonContent = content?.['application/json'] + if (jsonContent?.schema) { + collectSchemaNames(jsonContent.schema, requestSchemaNames) + } + } + } + + // Collect response and error schemas + if (operation.responses) { + for (const [statusCode, response] of Object.entries( + operation.responses, + )) { + const isError = isErrorStatusCode(statusCode) + if ('$ref' in response) { + const schemaName = extractSchemaNameFromRef(response.$ref) + if (schemaName) { + if (isError) { + errorSchemaNames.add(schemaName) + } else { + responseSchemaNames.add(schemaName) + } + } + } else if ('content' in response) { + const content = response.content + const jsonContent = content?.['application/json'] + if (jsonContent?.schema) { + if (isError) { + collectSchemaNames(jsonContent.schema, errorSchemaNames) + } else { + collectSchemaNames(jsonContent.schema, responseSchemaNames) + } + } + } + } + } + } + } + } + + return { requestSchemaNames, responseSchemaNames, errorSchemaNames } +} + +/** + * Generate Zod schemas for a single OpenAPI document + */ +function generateSchemasForDocument( + schema: OpenAPIV3_1.Document, + _serverName: string, + options?: DevupApiTypeGeneratorOptions, +): CollectedSchemas { + const { requestSchemaNames, responseSchemaNames, errorSchemaNames } = + collectSchemaUsage(schema) + + const requestSchemas: Record = {} + const responseSchemas: Record = {} + const errorSchemas: Record = {} + + // Create a map of schema references for lazy loading + const schemaRefs = new Map() + if (schema.components?.schemas) { + for (const schemaName of Object.keys(schema.components.schemas)) { + schemaRefs.set(schemaName, `_${schemaName}`) + } + } + + if (schema.components?.schemas) { + for (const [schemaName, schemaObj] of Object.entries( + schema.components.schemas, + )) { + if (!schemaObj) continue + + const requestDefaultNonNullable = + options?.requestDefaultNonNullable ?? false + const responseDefaultNonNullable = + options?.responseDefaultNonNullable ?? true + + const isRequest = requestSchemaNames.has(schemaName) + const isResponse = responseSchemaNames.has(schemaName) + const isError = errorSchemaNames.has(schemaName) + + const schemaRef = schemaObj as + | OpenAPIV3_1.SchemaObject + | OpenAPIV3_1.ReferenceObject + + if (isRequest) { + requestSchemas[schemaName] = { + code: schemaToZod(schemaRef, schema, schemaRefs, { + defaultNonNullable: requestDefaultNonNullable, + }), + type: schemaToZodType(schemaRef, schema, { + defaultNonNullable: requestDefaultNonNullable, + }), + } + } + + if (isResponse) { + responseSchemas[schemaName] = { + code: schemaToZod(schemaRef, schema, schemaRefs, { + defaultNonNullable: responseDefaultNonNullable, + }), + type: schemaToZodType(schemaRef, schema, { + defaultNonNullable: responseDefaultNonNullable, + }), + } + } + + if (isError) { + errorSchemas[schemaName] = { + code: schemaToZod(schemaRef, schema, schemaRefs, { + defaultNonNullable: responseDefaultNonNullable, + }), + type: schemaToZodType(schemaRef, schema, { + defaultNonNullable: responseDefaultNonNullable, + }), + } + } + } + } + + return { requestSchemas, responseSchemas, errorSchemas } +} + +// ============================================================================= +// Main Generator Function +// ============================================================================= + +/** + * Generate Zod schema code from OpenAPI documents + * + * @param schemas - Map of server names to OpenAPI documents + * @param options - Generator options + * @returns Generated JavaScript/TypeScript code string + */ +export function generateZodSchemas( + schemas: Record, + options?: DevupApiTypeGeneratorOptions, +): string { + const serverSchemas: Record = {} + + for (const [originalServerName, schema] of Object.entries(schemas)) { + const normalizedServerName = normalizeServerName(originalServerName) + serverSchemas[normalizedServerName] = generateSchemasForDocument( + schema, + normalizedServerName, + options, + ) + } + + // Generate the output code + const lines: string[] = ['import { z } from "zod";', ''] + + // Generate schema definitions for each server + for (const [serverName, collected] of Object.entries(serverSchemas)) { + const safeServerName = serverName.replace(/[^a-zA-Z0-9]/g, '_') + + // Request schemas + if (Object.keys(collected.requestSchemas).length > 0) { + lines.push(`// Request schemas for ${serverName}`) + for (const [name, schemaInfo] of Object.entries( + collected.requestSchemas, + )) { + lines.push( + `const ${safeServerName}_request_${name} = ${schemaInfo.code};`, + ) + } + lines.push('') + } + + // Response schemas + if (Object.keys(collected.responseSchemas).length > 0) { + lines.push(`// Response schemas for ${serverName}`) + for (const [name, schemaInfo] of Object.entries( + collected.responseSchemas, + )) { + lines.push( + `const ${safeServerName}_response_${name} = ${schemaInfo.code};`, + ) + } + lines.push('') + } + + // Error schemas + if (Object.keys(collected.errorSchemas).length > 0) { + lines.push(`// Error schemas for ${serverName}`) + for (const [name, schemaInfo] of Object.entries(collected.errorSchemas)) { + lines.push( + `const ${safeServerName}_error_${name} = ${schemaInfo.code};`, + ) + } + lines.push('') + } + } + + // Generate exports + lines.push('// Exported schemas') + + // Build schemas object for each server + for (const [serverName, collected] of Object.entries(serverSchemas)) { + const safeServerName = serverName.replace(/[^a-zA-Z0-9]/g, '_') + + // Request schemas object + const requestEntries = Object.keys(collected.requestSchemas) + .map( + (name) => + ` ${wrapInterfaceKeyGuard(name)}: ${safeServerName}_request_${name}`, + ) + .join(',\n') + lines.push(`export const ${safeServerName}_requestSchemas = {`) + lines.push(requestEntries || '') + lines.push('};') + lines.push('') + + // Response schemas object + const responseEntries = Object.keys(collected.responseSchemas) + .map( + (name) => + ` ${wrapInterfaceKeyGuard(name)}: ${safeServerName}_response_${name}`, + ) + .join(',\n') + lines.push(`export const ${safeServerName}_responseSchemas = {`) + lines.push(responseEntries || '') + lines.push('};') + lines.push('') + + // Error schemas object + const errorEntries = Object.keys(collected.errorSchemas) + .map( + (name) => + ` ${wrapInterfaceKeyGuard(name)}: ${safeServerName}_error_${name}`, + ) + .join(',\n') + lines.push(`export const ${safeServerName}_errorSchemas = {`) + lines.push(errorEntries || '') + lines.push('};') + lines.push('') + } + + // Generate combined schemas export + const serverNames = Object.keys(serverSchemas) + if (serverNames.length === 1) { + // Single server - export directly + const safeServerName = (serverNames[0] as string).replace( + /[^a-zA-Z0-9]/g, + '_', + ) + lines.push('export const schemas = {') + lines.push(` request: ${safeServerName}_requestSchemas,`) + lines.push(` response: ${safeServerName}_responseSchemas,`) + lines.push(` error: ${safeServerName}_errorSchemas,`) + lines.push('};') + lines.push('') + lines.push( + `export const requestSchemas = ${safeServerName}_requestSchemas;`, + ) + lines.push( + `export const responseSchemas = ${safeServerName}_responseSchemas;`, + ) + lines.push(`export const errorSchemas = ${safeServerName}_errorSchemas;`) + } else { + // Multiple servers - export as nested object + lines.push('export const schemas = {') + for (const serverName of serverNames) { + const safeServerName = serverName.replace(/[^a-zA-Z0-9]/g, '_') + lines.push(` ${wrapInterfaceKeyGuard(serverName)}: {`) + lines.push(` request: ${safeServerName}_requestSchemas,`) + lines.push(` response: ${safeServerName}_responseSchemas,`) + lines.push(` error: ${safeServerName}_errorSchemas,`) + lines.push(' },') + } + lines.push('};') + + // Also export flat versions for single-server convenience + if (serverNames.length > 0) { + const defaultServer = serverNames[0] as string + const safeDefaultServer = defaultServer.replace(/[^a-zA-Z0-9]/g, '_') + lines.push('') + lines.push('// Default server exports (first server)') + lines.push( + `export const requestSchemas = ${safeDefaultServer}_requestSchemas;`, + ) + lines.push( + `export const responseSchemas = ${safeDefaultServer}_responseSchemas;`, + ) + lines.push( + `export const errorSchemas = ${safeDefaultServer}_errorSchemas;`, + ) + } + } + + return lines.join('\n') +} + +/** + * Generate Zod schema type declarations for module augmentation + */ +export function generateZodTypeDeclarations( + schemas: Record, + options?: DevupApiTypeGeneratorOptions, +): string { + const serverSchemas: Record = {} + + for (const [originalServerName, schema] of Object.entries(schemas)) { + const normalizedServerName = normalizeServerName(originalServerName) + serverSchemas[normalizedServerName] = generateSchemasForDocument( + schema, + normalizedServerName, + options, + ) + } + + const lines: string[] = [ + 'import "@devup-api/zod";', + 'import type { z } from "zod";', + '', + 'declare module "@devup-api/zod" {', + ] + + // Generate interface declarations for each server + for (const [serverName, collected] of Object.entries(serverSchemas)) { + // Request schemas interface + if (Object.keys(collected.requestSchemas).length > 0) { + lines.push(` interface DevupZodRequestSchemas {`) + lines.push(` ${wrapInterfaceKeyGuard(serverName)}: {`) + for (const [name, schemaInfo] of Object.entries( + collected.requestSchemas, + )) { + lines.push(` ${wrapInterfaceKeyGuard(name)}: ${schemaInfo.type};`) + } + lines.push(' };') + lines.push(' }') + lines.push('') + } + + // Response schemas interface + if (Object.keys(collected.responseSchemas).length > 0) { + lines.push(` interface DevupZodResponseSchemas {`) + lines.push(` ${wrapInterfaceKeyGuard(serverName)}: {`) + for (const [name, schemaInfo] of Object.entries( + collected.responseSchemas, + )) { + lines.push(` ${wrapInterfaceKeyGuard(name)}: ${schemaInfo.type};`) + } + lines.push(' };') + lines.push(' }') + lines.push('') + } + + // Error schemas interface + if (Object.keys(collected.errorSchemas).length > 0) { + lines.push(` interface DevupZodErrorSchemas {`) + lines.push(` ${wrapInterfaceKeyGuard(serverName)}: {`) + for (const [name, schemaInfo] of Object.entries(collected.errorSchemas)) { + lines.push(` ${wrapInterfaceKeyGuard(name)}: ${schemaInfo.type};`) + } + lines.push(' };') + lines.push(' }') + lines.push('') + } + } + + lines.push('}') + + return lines.join('\n') +} diff --git a/packages/generator/src/index.ts b/packages/generator/src/index.ts index 34ca7ce..304d2a0 100644 --- a/packages/generator/src/index.ts +++ b/packages/generator/src/index.ts @@ -1,2 +1,3 @@ export * from './create-url-map' export * from './generate-interface' +export * from './generate-zod' diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 3fc5b9b..0fe59ac 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -1,6 +1,11 @@ -import { join } from 'node:path' +import { join, resolve } from 'node:path' import type { DevupApiOptions } from '@devup-api/core' -import { createUrlMap, generateInterface } from '@devup-api/generator' +import { + createUrlMap, + generateInterface, + generateZodSchemas, + generateZodTypeDeclarations, +} from '@devup-api/generator' import { createTmpDir, normalizeOpenapiFiles, @@ -21,10 +26,24 @@ export function devupApi( const openapiFiles = normalizeOpenapiFiles(options?.openapiFiles) const schemas = readOpenapis(openapiFiles) + // Generate API interface file writeInterface( join(tempDir, 'api.d.ts'), generateInterface(schemas, options), ) + + // Generate Zod schemas file + writeInterface( + join(tempDir, 'zod-schemas.js'), + generateZodSchemas(schemas, options), + ) + + // Generate Zod type declarations + writeInterface( + join(tempDir, 'zod.d.ts'), + generateZodTypeDeclarations(schemas, options), + ) + // Create urlMap and set environment variable const urlMap = createUrlMap(schemas, options) config.env ??= {} @@ -33,6 +52,18 @@ export function devupApi( DEVUP_API_URL_MAP: JSON.stringify(urlMap), }) } + + // Add alias for @devup-api/zod in turbopack mode + const zodSchemasPath = resolve(tempDir, 'zod-schemas.js') + config.experimental ??= {} + // biome-ignore lint/suspicious/noExplicitAny: turbo config types may not be available in all Next.js versions + const experimental = config.experimental as any + experimental.turbo ??= {} + experimental.turbo.resolveAlias ??= {} + Object.assign(experimental.turbo.resolveAlias, { + '@devup-api/zod': zodSchemasPath, + }) + return config } diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index 488dbb0..54658e8 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -1,5 +1,5 @@ import { beforeEach, expect, mock, spyOn, test } from 'bun:test' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import type { DevupApiOptions } from '@devup-api/core' import * as generator from '@devup-api/generator' import * as utils from '@devup-api/utils' @@ -10,6 +10,8 @@ let mockReadOpenapiAsync: ReturnType let mockWriteInterfaceAsync: ReturnType let mockCreateUrlMap: ReturnType let mockGenerateInterface: ReturnType +let mockGenerateZodSchemas: ReturnType +let mockGenerateZodTypeDeclarations: ReturnType const mockSchema = { openapi: '3.1.0', @@ -46,6 +48,8 @@ const mockUrlMap = { } const mockInterfaceContent = 'export interface Test {}' +const mockZodSchemasContent = 'export const schemas = {}' +const mockZodTypeDeclarationsContent = 'declare module "@devup-api/zod" {}' const createMockBuild = () => { const modifyRsbuildConfigMock = mock( @@ -76,11 +80,21 @@ beforeEach(() => { mockGenerateInterface = spyOn(generator, 'generateInterface').mockReturnValue( mockInterfaceContent, ) + mockGenerateZodSchemas = spyOn( + generator, + 'generateZodSchemas', + ).mockReturnValue(mockZodSchemasContent) + mockGenerateZodTypeDeclarations = spyOn( + generator, + 'generateZodTypeDeclarations', + ).mockReturnValue(mockZodTypeDeclarationsContent) mockCreateTmpDirAsync.mockClear() mockReadOpenapiAsync.mockClear() mockWriteInterfaceAsync.mockClear() mockCreateUrlMap.mockClear() mockGenerateInterface.mockClear() + mockGenerateZodSchemas.mockClear() + mockGenerateZodTypeDeclarations.mockClear() }) test('devupApiRsbuildPlugin returns plugin with correct name', () => { @@ -113,15 +127,30 @@ test.each([ expect(mockCreateTmpDirAsync).toHaveBeenCalledWith(options?.tempDir) expect(mockReadOpenapiAsync).toHaveBeenCalledWith(expectedFiles) expect(mockGenerateInterface).toHaveBeenCalledWith(mockSchema, options) + expect(mockGenerateZodSchemas).toHaveBeenCalledWith(mockSchema, options) + expect(mockGenerateZodTypeDeclarations).toHaveBeenCalledWith( + mockSchema, + options, + ) + // 3 files written: api.d.ts, zod-schemas.js, zod.d.ts + expect(mockWriteInterfaceAsync).toHaveBeenCalledTimes(3) expect(mockWriteInterfaceAsync).toHaveBeenCalledWith( join('df', 'api.d.ts'), mockInterfaceContent, ) + expect(mockWriteInterfaceAsync).toHaveBeenCalledWith( + join('df', 'zod-schemas.js'), + mockZodSchemasContent, + ) + expect(mockWriteInterfaceAsync).toHaveBeenCalledWith( + join('df', 'zod.d.ts'), + mockZodTypeDeclarationsContent, + ) expect(mockCreateUrlMap).toHaveBeenCalledWith(mockSchema, options) expect(build.modifyRsbuildConfig).toHaveBeenCalled() }) -test('devupApiRsbuildPlugin setup hook modifies config with urlMap', async () => { +test('devupApiRsbuildPlugin setup hook modifies config with urlMap and alias', async () => { const plugin = devupApiRsbuildPlugin() const build = createMockBuild() await plugin.setup?.(build as never) @@ -134,6 +163,11 @@ test('devupApiRsbuildPlugin setup hook modifies config with urlMap', async () => const result = configModifier(config) expect(result).toEqual({ + resolve: { + alias: { + '@devup-api/zod': resolve('df', 'zod-schemas.js'), + }, + }, source: { define: { 'process.env.DEVUP_API_URL_MAP': JSON.stringify( @@ -155,6 +189,11 @@ test('devupApiRsbuildPlugin setup hook handles config without source', async () const result = configModifier(config) expect(result).toEqual({ + resolve: { + alias: { + '@devup-api/zod': resolve('df', 'zod-schemas.js'), + }, + }, source: { define: { 'process.env.DEVUP_API_URL_MAP': JSON.stringify( @@ -178,6 +217,11 @@ test('devupApiRsbuildPlugin setup hook handles config without define', async () const result = configModifier(config) expect(result).toEqual({ + resolve: { + alias: { + '@devup-api/zod': resolve('df', 'zod-schemas.js'), + }, + }, source: { define: { 'process.env.DEVUP_API_URL_MAP': JSON.stringify( @@ -202,6 +246,11 @@ test('devupApiRsbuildPlugin setup hook does not add urlMap when urlMap is null', const result = configModifier(config) expect(result).toEqual({ + resolve: { + alias: { + '@devup-api/zod': resolve('df', 'zod-schemas.js'), + }, + }, source: { define: {}, }, @@ -222,6 +271,11 @@ test('devupApiRsbuildPlugin setup hook does not add urlMap when urlMap is undefi const result = configModifier(config) expect(result).toEqual({ + resolve: { + alias: { + '@devup-api/zod': resolve('df', 'zod-schemas.js'), + }, + }, source: { define: {}, }, @@ -242,6 +296,11 @@ test('devupApiRsbuildPlugin setup hook does not add urlMap when urlMap is empty const result = configModifier(config) expect(result).toEqual({ + resolve: { + alias: { + '@devup-api/zod': resolve('df', 'zod-schemas.js'), + }, + }, source: { define: {}, }, diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 3b770e9..3d9780a 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -1,6 +1,11 @@ -import { join } from 'node:path' +import { join, resolve } from 'node:path' import type { DevupApiOptions } from '@devup-api/core' -import { createUrlMap, generateInterface } from '@devup-api/generator' +import { + createUrlMap, + generateInterface, + generateZodSchemas, + generateZodTypeDeclarations, +} from '@devup-api/generator' import { createTmpDirAsync, normalizeOpenapiFiles, @@ -25,16 +30,39 @@ export function devupApiRsbuildPlugin( generateInterface(schemas, options), ) + // Generate Zod schemas file + await writeInterfaceAsync( + join(tempDir, 'zod-schemas.js'), + generateZodSchemas(schemas, options), + ) + + // Generate Zod type declarations + await writeInterfaceAsync( + join(tempDir, 'zod.d.ts'), + generateZodTypeDeclarations(schemas, options), + ) + // Create urlMap and set environment variable const urlMap = createUrlMap(schemas, options) + // Get absolute path for Zod schemas + const zodSchemasPath = resolve(tempDir, 'zod-schemas.js') + build.modifyRsbuildConfig((config) => { config.source ??= {} + config.resolve ??= {} config.source.define ??= {} + config.resolve.alias ??= {} + + // Set URL map environment variable if (urlMap && Object.keys(urlMap).length > 0) { config.source.define['process.env.DEVUP_API_URL_MAP'] = JSON.stringify(JSON.stringify(urlMap)) } + // Add alias for @devup-api/zod + ;(config.resolve.alias as Record)['@devup-api/zod'] = + zodSchemasPath + return config }) }, diff --git a/packages/vite-plugin/package.json b/packages/vite-plugin/package.json index 6adc878..f44739f 100644 --- a/packages/vite-plugin/package.json +++ b/packages/vite-plugin/package.json @@ -30,6 +30,7 @@ }, "devDependencies": { "@types/node": "^25.0", + "openapi-types": "^12.1", "typescript": "^5.9" } } diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 21d930c..66e82f2 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -10,6 +10,8 @@ let mockReadOpenapiAsync: ReturnType let mockWriteInterfaceAsync: ReturnType let mockCreateUrlMap: ReturnType let mockGenerateInterface: ReturnType +let mockGenerateZodSchemas: ReturnType +let mockGenerateZodTypeDeclarations: ReturnType const mockSchema = { openapi: '3.1.0', @@ -47,6 +49,9 @@ const mockUrlMap = { const mockInterfaceContent = 'export interface Test {}' +const mockZodSchemasContent = 'export const schemas = {}' +const mockZodTypeDeclarationsContent = 'declare module "@devup-api/zod" {}' + beforeEach(() => { mockCreateTmpDirAsync = spyOn(utils, 'createTmpDirAsync').mockResolvedValue( 'df', @@ -64,6 +69,14 @@ beforeEach(() => { mockGenerateInterface = spyOn(generator, 'generateInterface').mockReturnValue( mockInterfaceContent, ) + mockGenerateZodSchemas = spyOn( + generator, + 'generateZodSchemas', + ).mockReturnValue(mockZodSchemasContent) + mockGenerateZodTypeDeclarations = spyOn( + generator, + 'generateZodTypeDeclarations', + ).mockReturnValue(mockZodTypeDeclarationsContent) }) test('devupApi returns plugin with correct name', () => { @@ -183,3 +196,70 @@ test('devupApi plugin has both config and configResolved hooks', () => { expect(typeof plugin.config).toBe('function') expect(typeof plugin.configResolved).toBe('function') }) + +test('devupApi resolveId returns resolved virtual module for @devup-api/zod', () => { + const plugin = devupApi() + const resolveId = plugin.resolveId as (id: string) => string | null + expect(resolveId).toBeDefined() + + const result = resolveId('@devup-api/zod') + expect(result).toBe('\0@devup-api/zod') +}) + +test('devupApi resolveId returns null for other modules', () => { + const plugin = devupApi() + const resolveId = plugin.resolveId as (id: string) => string | null + + expect(resolveId('other-module')).toBeNull() + expect(resolveId('@devup-api/fetch')).toBeNull() + expect(resolveId('zod')).toBeNull() +}) + +test('devupApi load returns zod schemas code for virtual module', async () => { + const plugin = devupApi() + const load = plugin.load as (id: string) => Promise + + const result = await load('\0@devup-api/zod') + expect(result).toBe(mockZodSchemasContent) + expect(mockGenerateZodSchemas).toHaveBeenCalled() +}) + +test('devupApi load returns null for other modules', async () => { + const plugin = devupApi() + const load = plugin.load as (id: string) => Promise + + expect(await load('other-module')).toBeNull() + expect(await load('@devup-api/zod')).toBeNull() // Not the resolved virtual module +}) + +test('devupApi load caches zod schemas code', async () => { + // Clear mocks for this specific test + mockGenerateZodSchemas.mockClear() + + const plugin = devupApi() + const load = plugin.load as (id: string) => Promise + + // First call + await load('\0@devup-api/zod') + expect(mockGenerateZodSchemas).toHaveBeenCalledTimes(1) + + // Second call should use cached value + await load('\0@devup-api/zod') + expect(mockGenerateZodSchemas).toHaveBeenCalledTimes(1) +}) + +test('devupApi configResolved writes zod type declarations', async () => { + const plugin = devupApi() + await ( + plugin as unknown as { configResolved?: () => Promise } + ).configResolved?.() + + expect(mockGenerateZodTypeDeclarations).toHaveBeenCalledWith( + mockSchema, + undefined, + ) + expect(mockWriteInterfaceAsync).toHaveBeenCalledWith( + join('df', 'zod.d.ts'), + mockZodTypeDeclarationsContent, + ) +}) diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index e6658d8..1a9a86e 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -1,30 +1,81 @@ import { join } from 'node:path' import type { DevupApiOptions } from '@devup-api/core' -import { createUrlMap, generateInterface } from '@devup-api/generator' +import { + createUrlMap, + generateInterface, + generateZodSchemas, + generateZodTypeDeclarations, +} from '@devup-api/generator' import { createTmpDirAsync, normalizeOpenapiFiles, readOpenapiAsync, writeInterfaceAsync, } from '@devup-api/utils' +import type { OpenAPIV3_1 } from 'openapi-types' import type { Plugin } from 'vite' +const VIRTUAL_ZOD_MODULE = '@devup-api/zod' +const RESOLVED_VIRTUAL_ZOD_MODULE = `\0${VIRTUAL_ZOD_MODULE}` + export function devupApi(options?: DevupApiOptions): Plugin { + let cachedSchemas: Record | null = null + let zodSchemasCode: string | null = null + + const getSchemas = async (): Promise< + Record + > => { + if (!cachedSchemas) { + const openapiFiles = normalizeOpenapiFiles(options?.openapiFiles) + cachedSchemas = await readOpenapiAsync(openapiFiles) + } + return cachedSchemas + } + return { name: 'devup-api', - // Vite plugin implementation + + // Resolve virtual module for @devup-api/zod + resolveId(id) { + if (id === VIRTUAL_ZOD_MODULE) { + return RESOLVED_VIRTUAL_ZOD_MODULE + } + return null + }, + + // Load virtual module content + async load(id) { + if (id === RESOLVED_VIRTUAL_ZOD_MODULE) { + if (!zodSchemasCode) { + const schemas = await getSchemas() + zodSchemasCode = generateZodSchemas(schemas, options) + } + return zodSchemasCode + } + return null + }, + + // Generate type definitions async configResolved() { const tempDir = await createTmpDirAsync(options?.tempDir) - const openapiFiles = normalizeOpenapiFiles(options?.openapiFiles) - const schemas = await readOpenapiAsync(openapiFiles) + const schemas = await getSchemas() + + // Write API interface definitions await writeInterfaceAsync( join(tempDir, 'api.d.ts'), generateInterface(schemas, options), ) + + // Write Zod type declarations + await writeInterfaceAsync( + join(tempDir, 'zod.d.ts'), + generateZodTypeDeclarations(schemas, options), + ) }, + + // Inject URL map as environment variable async config() { - const openapiFiles = normalizeOpenapiFiles(options?.openapiFiles) - const schemas = await readOpenapiAsync(openapiFiles) + const schemas = await getSchemas() const urlMap = createUrlMap(schemas, options) const define: Record = {} if (urlMap && Object.keys(urlMap).length > 0) { diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 48be504..0062e5d 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -11,6 +11,8 @@ let mockReadOpenapiAsync: ReturnType let mockWriteInterfaceAsync: ReturnType let mockCreateUrlMap: ReturnType let mockGenerateInterface: ReturnType +let mockGenerateZodSchemas: ReturnType +let mockGenerateZodTypeDeclarations: ReturnType const mockSchema = { openapi: '3.1.0', @@ -77,10 +79,23 @@ const createMockCompiler = (): Compiler & { ) => { apply: (compiler: Compiler) => void } DefinePlugin.prototype.apply = mock(() => {}) + const NormalModuleReplacementPlugin = function ( + this: unknown, + _pattern: RegExp, + _newResource: string, + ) { + // Constructor + } as unknown as new ( + pattern: RegExp, + newResource: string, + ) => { apply: (compiler: Compiler) => void } + NormalModuleReplacementPlugin.prototype.apply = mock(() => {}) + const compiler = { hooks, webpack: { DefinePlugin, + NormalModuleReplacementPlugin, }, } as unknown as Compiler & { _storedCallback?: (params: unknown, cb: (error?: Error) => void) => void @@ -95,6 +110,9 @@ const createMockCompiler = (): Compiler & { return compiler } +const mockZodSchemasContent = 'export const schemas = {}' +const mockZodTypeDeclarationsContent = 'declare module "@devup-api/zod" {}' + beforeEach(() => { mockCreateTmpDirAsync = spyOn(utils, 'createTmpDirAsync').mockResolvedValue( 'df', @@ -112,11 +130,21 @@ beforeEach(() => { mockGenerateInterface = spyOn(generator, 'generateInterface').mockReturnValue( mockInterfaceContent, ) + mockGenerateZodSchemas = spyOn( + generator, + 'generateZodSchemas', + ).mockReturnValue(mockZodSchemasContent) + mockGenerateZodTypeDeclarations = spyOn( + generator, + 'generateZodTypeDeclarations', + ).mockReturnValue(mockZodTypeDeclarationsContent) mockCreateTmpDirAsync.mockClear() mockReadOpenapiAsync.mockClear() mockWriteInterfaceAsync.mockClear() mockCreateUrlMap.mockClear() mockGenerateInterface.mockClear() + mockGenerateZodSchemas.mockClear() + mockGenerateZodTypeDeclarations.mockClear() }) test('devupApiWebpackPlugin constructor initializes with default options', () => { @@ -173,6 +201,10 @@ test.each([ compiler.webpack.DefinePlugin.prototype, 'apply', ).mockImplementation(() => {}) + const normalModuleReplacementPluginApplySpy = spyOn( + compiler.webpack.NormalModuleReplacementPlugin.prototype, + 'apply', + ).mockImplementation(() => {}) plugin.apply(compiler) const callback = compiler._storedCallback @@ -184,15 +216,31 @@ test.each([ expect(mockCreateTmpDirAsync).toHaveBeenCalledWith(options?.tempDir) expect(mockReadOpenapiAsync).toHaveBeenCalledWith(expectedFiles) expect(mockGenerateInterface).toHaveBeenCalledWith(mockSchema, options || {}) + expect(mockGenerateZodSchemas).toHaveBeenCalledWith(mockSchema, options || {}) + expect(mockGenerateZodTypeDeclarations).toHaveBeenCalledWith( + mockSchema, + options || {}, + ) expect(mockWriteInterfaceAsync).toHaveBeenCalledWith( join('df', 'api.d.ts'), mockInterfaceContent, ) + expect(mockWriteInterfaceAsync).toHaveBeenCalledWith( + join('df', 'zod-schemas.js'), + mockZodSchemasContent, + ) + expect(mockWriteInterfaceAsync).toHaveBeenCalledWith( + join('df', 'zod.d.ts'), + mockZodTypeDeclarationsContent, + ) + expect(mockWriteInterfaceAsync).toHaveBeenCalledTimes(3) expect(mockCreateUrlMap).toHaveBeenCalledWith(mockSchema, options || {}) expect(definePluginApplySpy).toHaveBeenCalled() + expect(normalModuleReplacementPluginApplySpy).toHaveBeenCalled() expect(mockCallback).toHaveBeenCalled() expect(plugin.initialized).toBe(true) definePluginApplySpy.mockRestore() + normalModuleReplacementPluginApplySpy.mockRestore() }) test('devupApiWebpackPlugin beforeCompile hook does not add DefinePlugin when urlMap is null', async () => { @@ -273,7 +321,9 @@ test('devupApiWebpackPlugin beforeCompile hook only runs once when called multip expect(mockCreateTmpDirAsync).toHaveBeenCalledTimes(1) expect(mockReadOpenapiAsync).toHaveBeenCalledTimes(1) expect(mockGenerateInterface).toHaveBeenCalledTimes(1) - expect(mockWriteInterfaceAsync).toHaveBeenCalledTimes(1) + expect(mockGenerateZodSchemas).toHaveBeenCalledTimes(1) + expect(mockGenerateZodTypeDeclarations).toHaveBeenCalledTimes(1) + expect(mockWriteInterfaceAsync).toHaveBeenCalledTimes(3) expect(mockCreateUrlMap).toHaveBeenCalledTimes(1) expect(mockCallback1).toHaveBeenCalled() expect(mockCallback2).toHaveBeenCalled() diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 8d12bcb..66c4fe7 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -1,6 +1,11 @@ -import { join } from 'node:path' +import { join, resolve } from 'node:path' import type { DevupApiOptions } from '@devup-api/core' -import { createUrlMap, generateInterface } from '@devup-api/generator' +import { + createUrlMap, + generateInterface, + generateZodSchemas, + generateZodTypeDeclarations, +} from '@devup-api/generator' import { createTmpDirAsync, normalizeOpenapiFiles, @@ -41,6 +46,18 @@ export class devupApiWebpackPlugin { generateInterface(schemas, this.options), ) + // Generate Zod schemas file + await writeInterfaceAsync( + join(tempDir, 'zod-schemas.js'), + generateZodSchemas(schemas, this.options), + ) + + // Generate Zod type declarations + await writeInterfaceAsync( + join(tempDir, 'zod.d.ts'), + generateZodTypeDeclarations(schemas, this.options), + ) + // Create urlMap and set environment variable const urlMap = createUrlMap(schemas, this.options) const define: Record = {} @@ -55,6 +72,13 @@ export class devupApiWebpackPlugin { new compiler.webpack.DefinePlugin(define).apply(compiler) } + // Add alias for @devup-api/zod to resolve to the generated file + const zodSchemasPath = resolve(tempDir, 'zod-schemas.js') + new compiler.webpack.NormalModuleReplacementPlugin( + /^@devup-api\/zod$/, + zodSchemasPath, + ).apply(compiler) + callback() } catch (error) { this.initialized = false diff --git a/packages/zod/README.md b/packages/zod/README.md new file mode 100644 index 0000000..57c0c15 --- /dev/null +++ b/packages/zod/README.md @@ -0,0 +1,76 @@ +# @devup-api/zod + +Zod schema generation for devup-api. This package provides runtime validation schemas generated from your OpenAPI specification. + +## Installation + +```bash +npm install @devup-api/zod zod +``` + +## Usage + +This package works in conjunction with devup-api bundler plugins. The Zod schemas are provided as virtual files by the bundler plugins. + +### With Vite + +```ts +// vite.config.ts +import { defineConfig } from 'vite' +import devupApi from '@devup-api/vite-plugin' + +export default defineConfig({ + plugins: [devupApi()], +}) +``` + +### Using the Schemas + +```ts +import { schemas } from '@devup-api/zod' + +// Access generated Zod schemas +const userSchema = schemas.response.User +const createUserSchema = schemas.request.CreateUserRequest +const errorSchema = schemas.error.ApiError + +// Validate data +const result = userSchema.safeParse(data) +if (result.success) { + console.log('Valid user:', result.data) +} else { + console.error('Validation errors:', result.error) +} +``` + +### Type Inference + +```ts +import { schemas, type SchemaTypes } from '@devup-api/zod' +import { z } from 'zod' + +// Infer types from schemas +type User = z.infer +type CreateUserRequest = z.infer + +// Or use the pre-defined types +type User = SchemaTypes['response']['User'] +``` + +## How It Works + +1. Your bundler plugin reads the OpenAPI specification +2. Zod schemas are generated from the OpenAPI schemas +3. When you import from `@devup-api/zod`, the bundler provides the generated schemas as a virtual file +4. You get fully typed, runtime-validated schemas + +## Cold Typing vs Boild Typing + +Similar to `@devup-api/fetch`, this package uses a two-phase typing system: + +- **Cold Typing**: Before the build runs, schemas are typed as `any` to prevent type errors +- **Boild Typing**: After the build runs, full type safety is enforced + +## License + +Apache 2.0 diff --git a/packages/zod/package.json b/packages/zod/package.json new file mode 100644 index 0000000..f4b907a --- /dev/null +++ b/packages/zod/package.json @@ -0,0 +1,34 @@ +{ + "name": "@devup-api/zod", + "version": "0.0.1", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc && bun build --target node --outfile=dist/index.js src/index.ts --production --packages=external && bun build --target node --outfile=dist/index.cjs --format=cjs src/index.ts --production --packages=external" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@devup-api/fetch": "workspace:^", + "zod": ">=4" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "devDependencies": { + "@types/node": "^25.0", + "typescript": "^5.9", + "zod": "^4.3.5" + } +} diff --git a/packages/zod/src/__tests__/index.test.ts b/packages/zod/src/__tests__/index.test.ts new file mode 100644 index 0000000..ace5276 --- /dev/null +++ b/packages/zod/src/__tests__/index.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test' +import { + errorSchemas, + requestSchemas, + responseSchemas, + schemas, +} from '../index' + +describe('@devup-api/zod exports', () => { + test('exports schemas placeholder object', () => { + expect(schemas).toBeDefined() + expect(typeof schemas).toBe('object') + }) + + test('exports responseSchemas placeholder object', () => { + expect(responseSchemas).toBeDefined() + expect(typeof responseSchemas).toBe('object') + }) + + test('exports requestSchemas placeholder object', () => { + expect(requestSchemas).toBeDefined() + expect(typeof requestSchemas).toBe('object') + }) + + test('exports errorSchemas placeholder object', () => { + expect(errorSchemas).toBeDefined() + expect(typeof errorSchemas).toBe('object') + }) +}) diff --git a/packages/zod/src/index.ts b/packages/zod/src/index.ts new file mode 100644 index 0000000..7561328 --- /dev/null +++ b/packages/zod/src/index.ts @@ -0,0 +1,51 @@ +export * from '@devup-api/fetch' +export * from './schema-struct' + +import type { DevupZodAllSchemas, DevupZodSchemas } from './schema-struct' + +// ============================================================================= +// Runtime Exports (will be replaced by virtual file from bundler plugins) +// ============================================================================= + +// Note: These exports are placeholders. The actual Zod schemas are provided +// by bundler plugins as virtual files. When you import from '@devup-api/zod', +// the bundler intercepts the import and provides the generated schemas. + +/** + * All Zod schemas organized by server and category (response, request, error) + * @example + * import { schemas } from '@devup-api/zod' + * const userSchema = schemas['openapi.json'].response.User + * const result = userSchema.safeParse(data) + */ +export const schemas: DevupZodAllSchemas = {} as DevupZodAllSchemas + +/** + * Response schemas - Zod schemas for API response types (default server: openapi.json) + * @example + * import { responseSchemas } from '@devup-api/zod' + * const userSchema = responseSchemas.User + * const result = userSchema.safeParse(responseData) + */ +export const responseSchemas: DevupZodSchemas['response'] = + {} as DevupZodSchemas['response'] + +/** + * Request schemas - Zod schemas for API request body types (default server: openapi.json) + * @example + * import { requestSchemas } from '@devup-api/zod' + * const createUserSchema = requestSchemas.CreateUserRequest + * const result = createUserSchema.safeParse(requestBody) + */ +export const requestSchemas: DevupZodSchemas['request'] = + {} as DevupZodSchemas['request'] + +/** + * Error schemas - Zod schemas for API error response types (default server: openapi.json) + * @example + * import { errorSchemas } from '@devup-api/zod' + * const errorSchema = errorSchemas.ApiError + * const result = errorSchema.safeParse(errorResponse) + */ +export const errorSchemas: DevupZodSchemas['error'] = + {} as DevupZodSchemas['error'] diff --git a/packages/zod/src/schema-struct.ts b/packages/zod/src/schema-struct.ts new file mode 100644 index 0000000..a03b030 --- /dev/null +++ b/packages/zod/src/schema-struct.ts @@ -0,0 +1,122 @@ +// Import from @devup-api/fetch to get the augmented DevupApiServers +// (api.d.ts augments @devup-api/fetch, not @devup-api/core) +import type { DevupApiServers, ExtractValue } from '@devup-api/fetch' +import type { z } from 'zod' + +// ============================================================================= +// Zod Schema Structure Interfaces (augmented by generated code) +// ============================================================================= + +// biome-ignore lint/suspicious/noEmptyInterface: empty interface for augmentation +export interface DevupZodRequestSchemas {} + +// biome-ignore lint/suspicious/noEmptyInterface: empty interface for augmentation +export interface DevupZodResponseSchemas {} + +// biome-ignore lint/suspicious/noEmptyInterface: empty interface for augmentation +export interface DevupZodErrorSchemas {} + +// ============================================================================= +// Schema Access Types +// ============================================================================= + +/** + * Get Zod schemas for a specific category and server + * @example + * type UserSchema = DevupZodSchema<'response', 'openapi.json'>['User'] + */ +export type DevupZodSchema< + Category extends 'response' | 'request' | 'error' = 'response', + Server extends keyof DevupApiServers | (string & {}) = 'openapi.json', +> = ExtractValue< + { + response: ExtractValue< + DevupZodResponseSchemas, + Server, + Record + > + request: ExtractValue< + DevupZodRequestSchemas, + Server, + Record + > + error: ExtractValue> + }, + Category, + Record +> + +/** + * Access Zod schemas by category for a specific server + * This matches the runtime structure of responseSchemas, requestSchemas, errorSchemas + * @example + * const userSchema: DevupZodSchemas['response']['User'] + */ +export type DevupZodSchemas< + T extends keyof DevupApiServers | (string & {}) = 'openapi.json', +> = { + response: ExtractValue> + request: ExtractValue> + error: ExtractValue> +} + +/** + * All schemas indexed by server name + * This matches the runtime structure of the schemas export + * @example + * const userSchema = schemas['openapi.json'].response.User + */ +export type DevupZodAllSchemas = { + [K in keyof DevupApiServers | (string & {})]: DevupZodSchemas +} + +/** + * Inferred types from Zod schemas + * @example + * type User = DevupZodSchemaTypes['response']['User'] + */ +export type DevupZodSchemaTypes< + T extends keyof DevupApiServers | (string & {}) = 'openapi.json', +> = { + response: { + [K in keyof ExtractValue< + DevupZodResponseSchemas, + T, + Record + >]: z.infer< + ExtractValue>[K] + > + } + request: { + [K in keyof ExtractValue< + DevupZodRequestSchemas, + T, + Record + >]: z.infer< + ExtractValue>[K] + > + } + error: { + [K in keyof ExtractValue< + DevupZodErrorSchemas, + T, + Record + >]: z.infer< + ExtractValue>[K] + > + } +} + +// ============================================================================= +// Cold Typing Support +// ============================================================================= + +/** + * Check if Zod schemas are available (boild typing) + */ +export type IsZodCold = keyof DevupApiServers extends never ? true : false + +/** + * Cold type fallback for schemas - returns any when schemas are not generated + */ +export type ColdZodSchemas = Record> diff --git a/packages/zod/tsconfig.json b/packages/zod/tsconfig.json new file mode 100644 index 0000000..fa29f35 --- /dev/null +++ b/packages/zod/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "emitDeclarationOnly": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "src/**/__tests__/**"] +}