From cebdb2e802407b4d691d1a9c82f4bb3809248529 Mon Sep 17 00:00:00 2001 From: Victor Date: Mon, 27 Mar 2023 19:56:44 +0200 Subject: [PATCH] feat(clients): add zod (#785) * feat(clients): add zod * feat(zod): add validations variables * feat(zod): handling some format and escap regex * fix(zod): package json version --- docs/generated/endpoints.ts | 476 ------------------ packages/core/src/getters/array.ts | 4 +- packages/core/src/getters/body.ts | 4 +- packages/core/src/getters/combine.ts | 6 +- packages/core/src/getters/object.ts | 6 +- packages/core/src/getters/params.ts | 1 + .../core/src/getters/query-params.test.ts | 18 +- packages/core/src/getters/query-params.ts | 4 + packages/core/src/getters/response.ts | 1 + packages/core/src/getters/scalar.ts | 4 +- packages/core/src/types.ts | 20 +- packages/orval/package.json | 5 +- packages/orval/src/client.ts | 6 + packages/query/src/index.ts | 1 + packages/zod/README.md | 28 ++ packages/zod/package.json | 18 + packages/zod/src/index.ts | 400 +++++++++++++++ packages/zod/tsconfig.json | 4 + 18 files changed, 504 insertions(+), 502 deletions(-) delete mode 100644 docs/generated/endpoints.ts create mode 100644 packages/zod/README.md create mode 100644 packages/zod/package.json create mode 100644 packages/zod/src/index.ts create mode 100644 packages/zod/tsconfig.json diff --git a/docs/generated/endpoints.ts b/docs/generated/endpoints.ts deleted file mode 100644 index cada0854e..000000000 --- a/docs/generated/endpoints.ts +++ /dev/null @@ -1,476 +0,0 @@ -/** - * Generated by orval v6.11.0 🍺 - * Do not edit manually. - * Swagger Petstore - * OpenAPI spec version: 1.0.0 - */ -import * as axios from 'axios'; -import type { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'; -import { useQuery, useMutation } from '@tanstack/react-query'; -import type { - UseQueryOptions, - UseMutationOptions, - QueryFunction, - MutationFunction, - UseQueryResult, - QueryKey, -} from '@tanstack/react-query'; -import { rest } from 'msw'; -import { faker } from '@faker-js/faker'; -export type CreatePetsBody = { - name: string; - tag: string; -}; - -export type ListPetsParams = { limit?: string }; - -export interface Error { - code: number; - message: string; -} - -export type Pets = Pet[]; - -export type CatType = typeof CatType[keyof typeof CatType]; - -// eslint-disable-next-line @typescript-eslint/no-redeclare -export const CatType = { - cat: 'cat', -} as const; - -export interface Cat { - petsRequested?: number; - type: CatType; -} - -export type DachshundBreed = typeof DachshundBreed[keyof typeof DachshundBreed]; - -// eslint-disable-next-line @typescript-eslint/no-redeclare -export const DachshundBreed = { - Dachshund: 'Dachshund', -} as const; - -export interface Dachshund { - length: number; - breed: DachshundBreed; -} - -export type LabradoodleBreed = - typeof LabradoodleBreed[keyof typeof LabradoodleBreed]; - -// eslint-disable-next-line @typescript-eslint/no-redeclare -export const LabradoodleBreed = { - Labradoodle: 'Labradoodle', -} as const; - -export interface Labradoodle { - cuteness: number; - breed: LabradoodleBreed; -} - -export type DogType = typeof DogType[keyof typeof DogType]; - -// eslint-disable-next-line @typescript-eslint/no-redeclare -export const DogType = { - dog: 'dog', -} as const; - -export type Dog = - | (Labradoodle & { - barksPerMinute?: number; - type: DogType; - }) - | (Dachshund & { - barksPerMinute?: number; - type: DogType; - }); - -export type PetCountry = typeof PetCountry[keyof typeof PetCountry]; - -// eslint-disable-next-line @typescript-eslint/no-redeclare -export const PetCountry = { - "People's_Republic_of_China": "People's Republic of China", - Uruguay: 'Uruguay', -} as const; - -export type PetCallingCode = typeof PetCallingCode[keyof typeof PetCallingCode]; - -// eslint-disable-next-line @typescript-eslint/no-redeclare -export const PetCallingCode = { - '+33': '+33', - '+420': '+420', -} as const; - -export type Pet = - | (Dog & { - '@id'?: string; - id: number; - name: string; - tag?: string; - email?: string; - callingCode?: PetCallingCode; - country?: PetCountry; - }) - | (Cat & { - '@id'?: string; - id: number; - name: string; - tag?: string; - email?: string; - callingCode?: PetCallingCode; - country?: PetCountry; - }); - -type AwaitedInput = PromiseLike | T; - -type Awaited = O extends AwaitedInput ? T : never; - -/** - * @summary List all pets - */ -export const listPets = ( - params?: ListPetsParams, - options?: AxiosRequestConfig, -): Promise> => { - return axios.default.get(`/pets`, { - ...options, - params: { ...params, ...options?.params }, - }); -}; - -export const getListPetsQueryKey = (params?: ListPetsParams) => [ - `/pets`, - ...(params ? [params] : []), -]; - -export type ListPetsQueryResult = NonNullable< - Awaited> ->; -export type ListPetsQueryError = AxiosError; - -export const useListPets = < - TData = Awaited>, - TError = AxiosError, ->( - params?: ListPetsParams, - options?: { - query?: UseQueryOptions< - Awaited>, - TError, - TData - >; - axios?: AxiosRequestConfig; - }, -): UseQueryResult & { queryKey: QueryKey } => { - const { query: queryOptions, axios: axiosOptions } = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getListPetsQueryKey(params); - - const queryFn: QueryFunction>> = ({ - signal, - }) => listPets(params, { signal, ...axiosOptions }); - - const query = useQuery>, TError, TData>( - queryKey, - queryFn, - queryOptions, - ) as UseQueryResult & { queryKey: QueryKey }; - - query.queryKey = queryKey; - - return query; -}; - -/** - * @summary Create a pet - */ -export const createPets = ( - createPetsBody: CreatePetsBody, - options?: AxiosRequestConfig, -): Promise> => { - return axios.default.post(`/pets`, createPetsBody, options); -}; - -export type CreatePetsMutationResult = NonNullable< - Awaited> ->; -export type CreatePetsMutationBody = CreatePetsBody; -export type CreatePetsMutationError = AxiosError; - -export const useCreatePets = < - TError = AxiosError, - TContext = unknown, ->(options?: { - mutation?: UseMutationOptions< - Awaited>, - TError, - { data: CreatePetsBody }, - TContext - >; - axios?: AxiosRequestConfig; -}) => { - const { mutation: mutationOptions, axios: axiosOptions } = options ?? {}; - - const mutationFn: MutationFunction< - Awaited>, - { data: CreatePetsBody } - > = (props) => { - const { data } = props ?? {}; - - return createPets(data, axiosOptions); - }; - - return useMutation< - Awaited>, - TError, - { data: CreatePetsBody }, - TContext - >(mutationFn, mutationOptions); -}; - -/** - * @summary Info for a specific pet - */ -export const showPetById = ( - petId: string, - options?: AxiosRequestConfig, -): Promise> => { - return axios.default.get(`/pets/${petId}`, options); -}; - -export const getShowPetByIdQueryKey = (petId: string) => [`/pets/${petId}`]; - -export type ShowPetByIdQueryResult = NonNullable< - Awaited> ->; -export type ShowPetByIdQueryError = AxiosError; - -export const useShowPetById = < - TData = Awaited>, - TError = AxiosError, ->( - petId: string, - options?: { - query?: UseQueryOptions< - Awaited>, - TError, - TData - >; - axios?: AxiosRequestConfig; - }, -): UseQueryResult & { queryKey: QueryKey } => { - const { query: queryOptions, axios: axiosOptions } = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getShowPetByIdQueryKey(petId); - - const queryFn: QueryFunction>> = ({ - signal, - }) => showPetById(petId, { signal, ...axiosOptions }); - - const query = useQuery< - Awaited>, - TError, - TData - >(queryKey, queryFn, { enabled: !!petId, ...queryOptions }) as UseQueryResult< - TData, - TError - > & { queryKey: QueryKey }; - - query.queryKey = queryKey; - - return query; -}; - -export const getListPetsMock = () => - Array.from( - { length: faker.datatype.number({ min: 1, max: 10 }) }, - (_, i) => i + 1, - ).map(() => - faker.helpers.arrayElement([ - { - cuteness: faker.datatype.number({ min: undefined, max: undefined }), - breed: faker.helpers.arrayElement(['Labradoodle']), - barksPerMinute: faker.helpers.arrayElement([ - faker.datatype.number({ min: undefined, max: undefined }), - undefined, - ]), - type: faker.helpers.arrayElement(['dog']), - }, - { - length: faker.datatype.number({ min: undefined, max: undefined }), - breed: faker.helpers.arrayElement(['Dachshund']), - barksPerMinute: faker.helpers.arrayElement([ - faker.datatype.number({ min: undefined, max: undefined }), - undefined, - ]), - type: faker.helpers.arrayElement(['dog']), - '@id': faker.helpers.arrayElement([faker.random.word(), undefined]), - id: faker.datatype.number({ min: undefined, max: undefined }), - name: faker.random.word(), - tag: faker.helpers.arrayElement([faker.random.word(), undefined]), - email: faker.helpers.arrayElement([faker.internet.email(), undefined]), - callingCode: faker.helpers.arrayElement([ - faker.helpers.arrayElement(['+33', '+420', '+33']), - undefined, - ]), - country: faker.helpers.arrayElement([ - faker.helpers.arrayElement(["People's Republic of China", 'Uruguay']), - undefined, - ]), - }, - { - petsRequested: faker.helpers.arrayElement([ - faker.datatype.number({ min: undefined, max: undefined }), - undefined, - ]), - type: faker.helpers.arrayElement(['cat']), - '@id': faker.helpers.arrayElement([faker.random.word(), undefined]), - id: faker.datatype.number({ min: undefined, max: undefined }), - name: faker.random.word(), - tag: faker.helpers.arrayElement([faker.random.word(), undefined]), - email: faker.helpers.arrayElement([faker.internet.email(), undefined]), - callingCode: faker.helpers.arrayElement([ - faker.helpers.arrayElement(['+33', '+420', '+33']), - undefined, - ]), - country: faker.helpers.arrayElement([ - faker.helpers.arrayElement(["People's Republic of China", 'Uruguay']), - undefined, - ]), - }, - ]), - ); - -export const getCreatePetsMock = () => - faker.helpers.arrayElement([ - { - cuteness: faker.datatype.number({ min: undefined, max: undefined }), - breed: faker.helpers.arrayElement(['Labradoodle']), - barksPerMinute: faker.helpers.arrayElement([ - faker.datatype.number({ min: undefined, max: undefined }), - undefined, - ]), - type: faker.helpers.arrayElement(['dog']), - }, - { - length: faker.datatype.number({ min: undefined, max: undefined }), - breed: faker.helpers.arrayElement(['Dachshund']), - barksPerMinute: faker.helpers.arrayElement([ - faker.datatype.number({ min: undefined, max: undefined }), - undefined, - ]), - type: faker.helpers.arrayElement(['dog']), - '@id': faker.helpers.arrayElement([faker.random.word(), undefined]), - id: faker.datatype.number({ min: undefined, max: undefined }), - name: faker.random.word(), - tag: faker.helpers.arrayElement([faker.random.word(), undefined]), - email: faker.helpers.arrayElement([faker.internet.email(), undefined]), - callingCode: faker.helpers.arrayElement([ - faker.helpers.arrayElement(['+33', '+420', '+33']), - undefined, - ]), - country: faker.helpers.arrayElement([ - faker.helpers.arrayElement(["People's Republic of China", 'Uruguay']), - undefined, - ]), - }, - { - petsRequested: faker.helpers.arrayElement([ - faker.datatype.number({ min: undefined, max: undefined }), - undefined, - ]), - type: faker.helpers.arrayElement(['cat']), - '@id': faker.helpers.arrayElement([faker.random.word(), undefined]), - id: faker.datatype.number({ min: undefined, max: undefined }), - name: faker.random.word(), - tag: faker.helpers.arrayElement([faker.random.word(), undefined]), - email: faker.helpers.arrayElement([faker.internet.email(), undefined]), - callingCode: faker.helpers.arrayElement([ - faker.helpers.arrayElement(['+33', '+420', '+33']), - undefined, - ]), - country: faker.helpers.arrayElement([ - faker.helpers.arrayElement(["People's Republic of China", 'Uruguay']), - undefined, - ]), - }, - ]); - -export const getShowPetByIdMock = () => - faker.helpers.arrayElement([ - { - cuteness: faker.datatype.number({ min: undefined, max: undefined }), - breed: faker.helpers.arrayElement(['Labradoodle']), - barksPerMinute: faker.helpers.arrayElement([ - faker.datatype.number({ min: undefined, max: undefined }), - undefined, - ]), - type: faker.helpers.arrayElement(['dog']), - }, - { - length: faker.datatype.number({ min: undefined, max: undefined }), - breed: faker.helpers.arrayElement(['Dachshund']), - barksPerMinute: faker.helpers.arrayElement([ - faker.datatype.number({ min: undefined, max: undefined }), - undefined, - ]), - type: faker.helpers.arrayElement(['dog']), - '@id': faker.helpers.arrayElement([faker.random.word(), undefined]), - id: faker.datatype.number({ min: undefined, max: undefined }), - name: faker.random.word(), - tag: faker.helpers.arrayElement([faker.random.word(), undefined]), - email: faker.helpers.arrayElement([faker.internet.email(), undefined]), - callingCode: faker.helpers.arrayElement([ - faker.helpers.arrayElement(['+33', '+420', '+33']), - undefined, - ]), - country: faker.helpers.arrayElement([ - faker.helpers.arrayElement(["People's Republic of China", 'Uruguay']), - undefined, - ]), - }, - { - petsRequested: faker.helpers.arrayElement([ - faker.datatype.number({ min: undefined, max: undefined }), - undefined, - ]), - type: faker.helpers.arrayElement(['cat']), - '@id': faker.helpers.arrayElement([faker.random.word(), undefined]), - id: faker.datatype.number({ min: undefined, max: undefined }), - name: faker.random.word(), - tag: faker.helpers.arrayElement([faker.random.word(), undefined]), - email: faker.helpers.arrayElement([faker.internet.email(), undefined]), - callingCode: faker.helpers.arrayElement([ - faker.helpers.arrayElement(['+33', '+420', '+33']), - undefined, - ]), - country: faker.helpers.arrayElement([ - faker.helpers.arrayElement(["People's Republic of China", 'Uruguay']), - undefined, - ]), - }, - ]); - -export const getSwaggerPetstoreMSW = () => [ - rest.get('*/pets', (_req, res, ctx) => { - return res( - ctx.delay(1000), - ctx.status(200, 'Mocked status'), - ctx.json(getListPetsMock()), - ); - }), - rest.post('*/pets', (_req, res, ctx) => { - return res( - ctx.delay(1000), - ctx.status(200, 'Mocked status'), - ctx.json(getCreatePetsMock()), - ); - }), - rest.get('*/pets/:petId', (_req, res, ctx) => { - return res( - ctx.delay(1000), - ctx.status(200, 'Mocked status'), - ctx.json(getShowPetByIdMock()), - ); - }), -]; diff --git a/packages/core/src/getters/array.ts b/packages/core/src/getters/array.ts index 011cc4ea0..9059c64c7 100644 --- a/packages/core/src/getters/array.ts +++ b/packages/core/src/getters/array.ts @@ -1,5 +1,5 @@ import { SchemaObject } from 'openapi3-ts'; -import { ContextSpecs, ResolverValue } from '../types'; +import { ContextSpecs, ScalarValue } from '../types'; import { resolveObject } from '../resolvers/object'; /** @@ -15,7 +15,7 @@ export const getArray = ({ schema: SchemaObject; name?: string; context: ContextSpecs; -}): ResolverValue => { +}): ScalarValue => { if (schema.items) { const resolvedObject = resolveObject({ schema: schema.items, diff --git a/packages/core/src/getters/body.ts b/packages/core/src/getters/body.ts index cbc029a0e..56807945e 100644 --- a/packages/core/src/getters/body.ts +++ b/packages/core/src/getters/body.ts @@ -1,7 +1,6 @@ import { ReferenceObject, RequestBodyObject } from 'openapi3-ts'; import { generalJSTypesWithArray } from '../constants'; -import { ContextSpecs, OverrideOutputContentType } from '../types'; -import { GetterBody } from '../types'; +import { ContextSpecs, GetterBody, OverrideOutputContentType } from '../types'; import { camel } from '../utils'; import { getResReqTypes } from './res-req-types'; @@ -51,6 +50,7 @@ export const getBody = ({ : camel(definition); return { + originalSchema: requestBody, definition, implementation, imports, diff --git a/packages/core/src/getters/combine.ts b/packages/core/src/getters/combine.ts index b038b35ab..97cb536cf 100644 --- a/packages/core/src/getters/combine.ts +++ b/packages/core/src/getters/combine.ts @@ -5,7 +5,7 @@ import { ContextSpecs, GeneratorImport, GeneratorSchema, - ResolverValue, + ScalarValue, SchemaType, } from '../types'; import { getNumberWord, pascal } from '../utils'; @@ -30,7 +30,7 @@ const combineValues = ({ separator, }: { resolvedData: CombinedData; - resolvedValue?: ResolverValue; + resolvedValue?: ScalarValue; separator: Separator; }) => { const isAllEnums = resolvedData.isEnum.every((v) => v); @@ -68,7 +68,7 @@ export const combineSchemas = ({ separator: Separator; context: ContextSpecs; nullable: string; -}): ResolverValue => { +}): ScalarValue => { const items = schema[separator] ?? []; const resolvedData = items.reduce( diff --git a/packages/core/src/getters/object.ts b/packages/core/src/getters/object.ts index 20da587c8..4cee20935 100644 --- a/packages/core/src/getters/object.ts +++ b/packages/core/src/getters/object.ts @@ -1,6 +1,6 @@ import { ReferenceObject, SchemaObject } from 'openapi3-ts'; import { resolveObject, resolveValue } from '../resolvers'; -import { ContextSpecs, ResolverValue, SchemaType } from '../types'; +import { ContextSpecs, ScalarValue, SchemaType } from '../types'; import { isBoolean, isReference, jsDoc, pascal } from '../utils'; import { combineSchemas } from './combine'; import { getKey } from './keys'; @@ -21,7 +21,7 @@ export const getObject = ({ name?: string; context: ContextSpecs; nullable: string; -}): ResolverValue => { +}): ScalarValue => { if (isReference(item)) { const { name, specKey } = getRefInfo(item.$ref, context); return { @@ -137,7 +137,7 @@ export const getObject = ({ type: 'object' as SchemaType, isRef: false, schema: {}, - } as ResolverValue, + } as ScalarValue, ); } diff --git a/packages/core/src/getters/params.ts b/packages/core/src/getters/params.ts index ebafe1887..6d92fa85f 100644 --- a/packages/core/src/getters/params.ts +++ b/packages/core/src/getters/params.ts @@ -102,6 +102,7 @@ export const getParams = ({ default: resolvedValue.originalSchema!.default, required, imports: resolvedValue.imports, + originalSchema: resolvedValue.originalSchema, }; }); }; diff --git a/packages/core/src/getters/query-params.test.ts b/packages/core/src/getters/query-params.test.ts index 4d6f61341..17bfce8f6 100644 --- a/packages/core/src/getters/query-params.test.ts +++ b/packages/core/src/getters/query-params.test.ts @@ -139,13 +139,15 @@ describe('getQueryParams getter', () => { operationName: '', context, }); - expect(result?.schema.model.trim()).toBe([ - 'export type Params = {', - '/**', - ' * Parameter description.', - ' */', - 'queryParamWithDescription?: string;', - '};', - ].join('\n')); + expect(result?.schema.model.trim()).toBe( + [ + 'export type Params = {', + '/**', + ' * Parameter description.', + ' */', + 'queryParamWithDescription?: string;', + '};', + ].join('\n'), + ); }); }); diff --git a/packages/core/src/getters/query-params.ts b/packages/core/src/getters/query-params.ts index 242af895b..15a4f3217 100644 --- a/packages/core/src/getters/query-params.ts +++ b/packages/core/src/getters/query-params.ts @@ -15,6 +15,7 @@ type QueryParamsType = { definition: string; imports: GeneratorImport[]; schemas: GeneratorSchema[]; + originalSchema: SchemaObject; }; const getQueryParamsTypes = ( @@ -61,6 +62,7 @@ const getQueryParamsTypes = ( };`, imports: parameterImports, schemas: [], + originalSchema: resolvedeValue.originalSchema, }; } @@ -82,6 +84,7 @@ const getQueryParamsTypes = ( ...resolvedeValue.schemas, { name: enumName, model: enumValue, imports: resolvedeValue.imports }, ], + originalSchema: resolvedeValue.originalSchema, }; } @@ -93,6 +96,7 @@ const getQueryParamsTypes = ( definition, imports: resolvedeValue.imports, schemas: resolvedeValue.schemas, + originalSchema: resolvedeValue.originalSchema, }; }); }; diff --git a/packages/core/src/getters/response.ts b/packages/core/src/getters/response.ts index aeb79f73f..7d5061ea0 100644 --- a/packages/core/src/getters/response.ts +++ b/packages/core/src/getters/response.ts @@ -93,5 +93,6 @@ export const getResponse = ({ types: groupedByStatus, contentTypes, schemas, + originalSchema: responses, }; }; diff --git a/packages/core/src/getters/scalar.ts b/packages/core/src/getters/scalar.ts index 8f9359795..63dd8695d 100644 --- a/packages/core/src/getters/scalar.ts +++ b/packages/core/src/getters/scalar.ts @@ -1,5 +1,5 @@ import { SchemaObject } from 'openapi3-ts'; -import { ContextSpecs, ResolverValue } from '../types'; +import { ContextSpecs, ScalarValue } from '../types'; import { escape, isString } from '../utils'; import { getArray } from './array'; import { getObject } from './object'; @@ -18,7 +18,7 @@ export const getScalar = ({ item: SchemaObject; name?: string; context: ContextSpecs; -}): ResolverValue => { +}): ScalarValue => { const nullable = item.nullable ? ' | null' : ''; if (!item.type && item.items) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5a2253191..af853a715 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -4,6 +4,9 @@ import { OpenAPIObject, OperationObject, ParameterObject, + ReferenceObject, + RequestBodyObject, + ResponsesObject, SchemaObject, } from 'openapi3-ts'; import swagger2openapi from 'swagger2openapi'; @@ -175,7 +178,8 @@ export type OutputClient = | 'react-query' | 'svelte-query' | 'vue-query' - | 'swr'; + | 'swr' + | 'zod'; export const OutputClient = { ANGULAR: 'angular' as OutputClient, @@ -591,9 +595,12 @@ export type GetterResponse = { }; contentTypes: string[]; schemas: GeneratorSchema[]; + + originalSchema?: ResponsesObject; }; export type GetterBody = { + originalSchema: ReferenceObject | RequestBodyObject; imports: GeneratorImport[]; definition: string; implementation: string; @@ -623,6 +630,7 @@ export type GetterQueryParam = { schema: GeneratorSchema; deps: GeneratorSchema[]; isOptional: boolean; + originalSchema?: SchemaObject; }; export type GetterPropType = 'param' | 'body' | 'queryParam' | 'header'; @@ -668,22 +676,26 @@ export const SchemaType = { unknown: 'unknown', }; -export type ResolverValue = { +export type ScalarValue = { value: string; isEnum: boolean; type: SchemaType; imports: GeneratorImport[]; schemas: GeneratorSchema[]; - originalSchema?: SchemaObject; isRef: boolean; }; -export type ResReqTypesValue = ResolverValue & { +export type ResolverValue = ScalarValue & { + originalSchema: SchemaObject; +}; + +export type ResReqTypesValue = ScalarValue & { formData?: string; formUrlEncoded?: string; isRef?: boolean; key: string; contentType: string; + originalSchema?: SchemaObject; }; export type WriteSpecsBuilder = { diff --git a/packages/orval/package.json b/packages/orval/package.json index 513aa466e..06c692fdd 100644 --- a/packages/orval/package.json +++ b/packages/orval/package.json @@ -43,9 +43,9 @@ }, "scripts": { "build": "tsup ./src/bin/orval.ts ./src/index.ts --target node12 --minify --clean --dts --splitting", - "dev": "tsup ./src/bin/orval.ts ./src/index.ts --target node12 --clean --watch src --onSuccess 'yarn generate-api'", + "dev": "tsup ./src/bin/orval.ts ./src/index.ts --target node12 --clean --watch ./src --onSuccess 'yarn generate-api'", "lint": "eslint src/**/*.ts", - "generate-api": "node ./dist/bin/orval.js --config ../../samples/react-query/basic/orval.config.ts --watch" + "generate-api": "node ./dist/bin/orval.js --config ../../samples/react-query/basic/orval.config.ts" }, "devDependencies": { "@types/chalk": "^2.2.0", @@ -60,6 +60,7 @@ "@orval/msw": "6.12.1", "@orval/query": "6.12.1", "@orval/swr": "6.12.1", + "@orval/zod": "6.12.1", "ajv": "^8.11.0", "cac": "^6.7.12", "chalk": "^4.1.2", diff --git a/packages/orval/src/client.ts b/packages/orval/src/client.ts index 5280f1496..c432de1c2 100644 --- a/packages/orval/src/client.ts +++ b/packages/orval/src/client.ts @@ -20,6 +20,7 @@ import { import { generateMSW } from '@orval/msw'; import query from '@orval/query'; import swr from '@orval/swr'; +import zod from '@orval/zod'; const DEFAULT_CLIENT = OutputClient.AXIOS; @@ -31,6 +32,7 @@ export const GENERATOR_CLIENT: GeneratorClients = { 'svelte-query': query({ type: 'svelte-query' })(), 'vue-query': query({ type: 'vue-query' })(), swr: swr()(), + zod: zod()(), }; const getGeneratorClient = (outputClient: OutputClient | OutputClientFunc) => { @@ -201,6 +203,10 @@ export const generateOperations = ( const client = await generatorClient(verbOption, options, outputClient); const msw = generateMock(verbOption, options); + if (!client.implementation) { + return acc; + } + acc[verbOption.operationId] = { implementation: verbOption.doc + client.implementation, imports: client.imports, diff --git a/packages/query/src/index.ts b/packages/query/src/index.ts index 699181d4a..0f6ac4506 100644 --- a/packages/query/src/index.ts +++ b/packages/query/src/index.ts @@ -909,6 +909,7 @@ const generateQueryHook = async ( }${body.implementation ? `, ${body.implementation}` : ''}];`; const implementation = `${!queryKeyMutator ? queryKeyFn : ''} + ${queries.reduce( (acc, queryOption) => diff --git a/packages/zod/README.md b/packages/zod/README.md new file mode 100644 index 000000000..8e1596ee8 --- /dev/null +++ b/packages/zod/README.md @@ -0,0 +1,28 @@ +[![npm version](https://badge.fury.io/js/orval.svg)](https://badge.fury.io/js/orval) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![tests](https://github.com/anymaniax/orval/actions/workflows/tests.yaml/badge.svg)](https://github.com/anymaniax/orval/actions/workflows/tests.yaml) + +

+ orval - Restfull Client Generator +

+

+ Visit orval.dev for docs, guides, API and beer! +

+ +### Code Generation + +`orval` is able to generate client with appropriate type-signatures (TypeScript) from any valid OpenAPI v3 or Swagger v2 specification, either in `yaml` or `json` formats. + +`Generate`, `valid`, `cache` and `mock` in your React, Vue, Svelte and Angular applications all with your OpenAPI specification. + +### Samples + +You can find below some samples + +- [react app](https://github.com/anymaniax/orval/tree/master/samples/react-app) +- [react query](https://github.com/anymaniax/orval/tree/master/samples/react-query) +- [svelte query](https://github.com/anymaniax/orval/tree/master/samples/svelte-query) +- [vue query](https://github.com/anymaniax/orval/tree/master/samples/vue-query) +- [react app with swr](https://github.com/anymaniax/orval/tree/master/samples/react-app-with-swr) +- [nx fastify react](https://github.com/anymaniax/orval/tree/master/samples/nx-fastify-react) +- [angular app](https://github.com/anymaniax/orval/tree/master/samples/angular-app) diff --git a/packages/zod/package.json b/packages/zod/package.json new file mode 100644 index 000000000..dd0247d2c --- /dev/null +++ b/packages/zod/package.json @@ -0,0 +1,18 @@ +{ + "name": "@orval/zod", + "version": "6.12.1", + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup ./src/index.ts --target node12 --minify --clean --dts --splitting", + "dev": "tsup ./src/index.ts --target node12 --clean --watch src", + "lint": "eslint src/**/*.ts" + }, + "dependencies": { + "@orval/core": "6.12.1" + } +} diff --git a/packages/zod/src/index.ts b/packages/zod/src/index.ts new file mode 100644 index 000000000..5dfce0ceb --- /dev/null +++ b/packages/zod/src/index.ts @@ -0,0 +1,400 @@ +import { + ParameterObject, + PathItemObject, + ReferenceObject, + RequestBodyObject, + ResponseObject, + SchemaObject, +} from 'openapi3-ts'; +import { + camel, + ClientBuilder, + ClientGeneratorsBuilder, + escape, + GeneratorDependency, + GeneratorOptions, + GeneratorVerbOptions, + isString, + resolveRef, +} from '@orval/core'; + +const ZOD_DEPENDENCIES: GeneratorDependency[] = [ + { + exports: [ + { + name: '* as zod', + alias: 'zod', + default: true, + values: true, + syntheticDefaultImport: true, + }, + ], + dependency: 'zod', + }, +]; + +export const getZodDependencies = () => ZOD_DEPENDENCIES; + +const resolveZodType = (schemaTypeValue: SchemaObject['type']) => { + switch (schemaTypeValue) { + case 'integer': + return 'number'; + case 'null': + return 'mixed'; + default: + return schemaTypeValue ?? 'mixed'; + } +}; + +const generateZodValidationSchemaDefinition = ( + schema: SchemaObject | undefined, + _required: boolean | undefined, + name: string, +): { functions: [string, any][]; consts: string[] } => { + if (!schema) return { functions: [], consts: [] }; + + const consts = []; + const functions: [string, any][] = []; + const type = resolveZodType(schema?.type); + const required = + schema?.default !== undefined + ? false + : _required ?? !schema?.nullable ?? false; + const min = + schema?.minimum ?? + schema?.exclusiveMinimum ?? + schema?.minLength ?? + undefined; + const max = + schema?.maximum ?? + schema?.exclusiveMaximum ?? + schema?.maxLength ?? + undefined; + const matches = schema?.pattern ?? undefined; + + switch (type) { + case 'object': + functions.push([ + 'object', + Object.keys(schema?.properties ?? {}) + .map((key) => ({ + [key]: generateZodValidationSchemaDefinition( + schema?.properties?.[key] as any, + schema?.required?.includes(key), + camel(`${name}-${key}`), + ), + })) + .reduce((acc, curr) => ({ ...acc, ...curr }), {}), + ]); + break; + case 'array': + const items = schema?.items as SchemaObject | undefined; + functions.push([ + 'array', + generateZodValidationSchemaDefinition(items, true, camel(name)), + ]); + break; + case 'string': { + if (schema?.enum && type === 'string') { + break; + } + + functions.push([type as string, undefined]); + + if (schema?.format === 'date-time' || schema?.format === 'date') { + functions.push(['datetime', undefined]); + break; + } + + if (schema?.format === 'email') { + functions.push(['email', undefined]); + break; + } + + if (schema?.format === 'uri' || schema?.format === 'hostname') { + functions.push(['url', undefined]); + break; + } + + if (schema?.format === 'uuid') { + functions.push(['uuid', undefined]); + break; + } + + break; + } + default: + functions.push([type as string, undefined]); + break; + } + + if (min !== undefined) { + consts.push(`export const ${name}Min = ${min};`); + functions.push(['min', `${name}Min`]); + } + if (max !== undefined) { + consts.push(`export const ${name}Max = ${min};`); + functions.push(['max', `${name}Max`]); + } + if (matches) { + const isStartWithSlash = matches.startsWith('/'); + const isEndWithSlash = matches.endsWith('/'); + + const regexp = `new RegExp('${escape( + matches.slice(isStartWithSlash ? 1 : 0, isEndWithSlash ? -1 : undefined), + )}')`; + + consts.push(`export const ${name}RegExp = ${regexp};`); + functions.push(['regex', `${name}RegExp`]); + } + if (schema?.enum) { + functions.push([ + 'enum', + [ + `[${schema?.enum + .map((value) => (isString(value) ? `'${escape(value)}'` : `${value}`)) + .join(', ')}]`, + ], + ]); + } + + if (!required) { + functions.push(['optional', undefined]); + } + + return { functions, consts }; +}; + +const parseZodValidationSchemaDefinition = ( + input: Record, +): { zod: string; consts: string } => { + const parseProperty = ([fn, args = '']: [string, any]): string => { + if (fn === 'object') return ` ${parseZodValidationSchemaDefinition(args)}`; + if (fn === 'array') + return `.array(${ + Array.isArray(args) + ? `zod${args.map(parseProperty).join('')}` + : parseProperty(args) + })`; + + return `.${fn}(${args})`; + }; + + if (!Object.keys(input).length) { + return { zod: '', consts: '' }; + } + + const consts = Object.entries(input).reduce((acc, [key, schema]) => { + return acc + schema.consts.join('\n'); + }, ''); + + const zod = `zod.object({ + ${Object.entries(input) + .map( + ([key, schema]) => + `"${key}": ${ + schema.functions[0][0] !== 'object' ? 'zod' : '' + }${schema.functions.map(parseProperty).join('')}`, + ) + .join(',')} +})`; + + return { zod, consts }; +}; + +const generateZodRoute = ( + { operationName, body, verb }: GeneratorVerbOptions, + { pathRoute, context }: GeneratorOptions, +) => { + const spec = context.specs[context.specKey].paths[pathRoute] as + | PathItemObject + | undefined; + + const parameters = spec?.[verb]?.parameters; + const requestBody = spec?.[verb]?.requestBody; + const response = spec?.[verb]?.responses?.['200'] as + | ResponseObject + | ReferenceObject; + + const resolvedResponse = response + ? resolveRef(response, context).schema + : undefined; + + const resolvedResponseJsonSchema = resolvedResponse?.content?.[ + 'application/json' + ]?.schema + ? resolveRef( + resolvedResponse.content['application/json'].schema, + context, + ).schema + : undefined; + + const zodDefinitionsResponseProperties = + resolvedResponseJsonSchema?.properties ?? + ([] as (SchemaObject | ReferenceObject)[]); + + const zodDefinitionsResponse = Object.entries( + zodDefinitionsResponseProperties, + ) + .map(([key, response]) => { + const { schema } = resolveRef(response, context); + + return { + [key]: generateZodValidationSchemaDefinition( + schema, + !!resolvedResponseJsonSchema?.required?.find( + (requiredKey: string) => requiredKey === key, + ), + camel(`${operationName}-${key}`), + ), + }; + }) + .reduce((acc, curr) => ({ ...acc, ...curr }), {}); + + const resolvedRequestBody = requestBody + ? resolveRef(requestBody, context).schema + : undefined; + + const resolvedRequestBodyJsonSchema = resolvedRequestBody?.content?.[ + 'application/json' + ]?.schema + ? resolveRef( + resolvedRequestBody.content['application/json'].schema, + context, + ).schema + : undefined; + + const zodDefinitionsBodyProperties = + resolvedRequestBodyJsonSchema?.properties ?? + ([] as (SchemaObject | ReferenceObject)[]); + + const zodDefinitionsBody = Object.entries(zodDefinitionsBodyProperties) + .map(([key, body]) => { + const { schema } = resolveRef(body, context); + + return { + [key]: generateZodValidationSchemaDefinition( + schema, + !!resolvedRequestBodyJsonSchema?.required?.find( + (requiredKey: string) => requiredKey === key, + ), + camel(`${operationName}-${key}`), + ), + }; + }) + .reduce((acc, curr) => ({ ...acc, ...curr }), {}); + + const zodDefinitionsParameters = (parameters ?? []).reduce( + (acc, val) => { + const { schema: parameter } = resolveRef(val, context); + + if (!parameter.schema) { + return acc; + } + + const { schema } = resolveRef(parameter.schema, context); + + const definition = generateZodValidationSchemaDefinition( + schema, + parameter.required, + camel(`${operationName}-${parameter.name}`), + ); + + if (parameter.in === 'header') { + return { + ...acc, + headers: { ...acc.headers, [parameter.name]: definition }, + }; + } + + if (parameter.in === 'query') { + return { + ...acc, + queryParams: { ...acc.queryParams, [parameter.name]: definition }, + }; + } + + if (parameter.in === 'path') { + return { + ...acc, + params: { ...acc.params, [parameter.name]: definition }, + }; + } + + return acc; + }, + { + headers: {}, + queryParams: {}, + params: {}, + } as Record< + 'headers' | 'queryParams' | 'params', + Record + >, + ); + + const inputParams = parseZodValidationSchemaDefinition( + zodDefinitionsParameters.params, + ); + const inputQueryParams = parseZodValidationSchemaDefinition( + zodDefinitionsParameters.queryParams, + ); + const inputHeaders = parseZodValidationSchemaDefinition( + zodDefinitionsParameters.headers, + ); + const inputBody = parseZodValidationSchemaDefinition(zodDefinitionsBody); + const inputResponse = parseZodValidationSchemaDefinition( + zodDefinitionsResponse, + ); + + if ( + !inputParams.zod && + !inputQueryParams.zod && + !inputHeaders.zod && + !inputBody.zod && + !inputResponse.zod + ) { + return ''; + } + + return [ + ...(inputParams.consts ? [inputParams.consts] : []), + ...(inputParams.zod + ? [`export const ${operationName}Params = ${inputParams.zod}`] + : []), + ...(inputQueryParams.consts ? [inputQueryParams.consts] : []), + ...(inputQueryParams.zod + ? [`export const ${operationName}QueryParams = ${inputQueryParams.zod}`] + : []), + ...(inputHeaders.consts ? [inputHeaders.consts] : []), + ...(inputHeaders.zod + ? [`export const ${operationName}Header = ${inputHeaders.zod}`] + : []), + ...(inputBody.consts ? [inputBody.consts] : []), + ...(inputBody.zod + ? [`export const ${operationName}Body = ${inputBody.zod}`] + : []), + ...(inputResponse.consts ? [inputResponse.consts] : []), + ...(inputResponse.zod + ? [`export const ${operationName}Response = ${inputResponse.zod}`] + : []), + ].join('\n\n'); +}; + +export const generateZod: ClientBuilder = (verbOptions, options) => { + const routeImplementation = generateZodRoute(verbOptions, options); + + return { + implementation: routeImplementation ? `${routeImplementation}\n\n` : '', + imports: [], + }; +}; + +const zodClientBuilder: ClientGeneratorsBuilder = { + client: generateZod, + dependencies: getZodDependencies, +}; + +export const builder = () => () => zodClientBuilder; + +export default builder; diff --git a/packages/zod/tsconfig.json b/packages/zod/tsconfig.json new file mode 100644 index 000000000..9e25e6ece --- /dev/null +++ b/packages/zod/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts"] +}