diff --git a/README.md b/README.md index b768fa85..4e3db0a5 100644 --- a/README.md +++ b/README.md @@ -351,7 +351,7 @@ The decision of having a `RequestHandler` interface with granular implementation in mind: `server` vs `client` | `prod` vs `test` | custom "middleware" behaviour. You can write your own implementations or just combine some of the existing ones below, according to your needs. The `RequestHandler` interface for custom implementations can be found -[here](https://github.com/inkognitro/oas-tszod-gen/blob/main/src/templates/core/core.ts#L263). +[here](https://github.com/inkognitro/oas-tszod-gen/blob/main/src/templates/core/core.ts#L343). ### AxiosRequestHandler This implementation is responsible for executing your requests through the http(s) protocol. diff --git a/src/oas3/codegen/endpoint.ts b/src/oas3/codegen/endpoint.ts index 1ab27ed8..a630b9d6 100644 --- a/src/oas3/codegen/endpoint.ts +++ b/src/oas3/codegen/endpoint.ts @@ -77,13 +77,20 @@ function findPayloadParamCode( path: OutputPath, applyRequestResult: ApplyRequestTypeDefinitionResult ): null | string { - const fields = applyRequestResult.payloadFields; - if (!fields.length) { + const requiredFields = applyRequestResult.requiredPayloadFields; + const optionalFields = applyRequestResult.optionalPayloadFields; + if (!requiredFields.length && !optionalFields.length) { return null; } + const requiredFieldsCode = requiredFields.length + ? `'${requiredFields.join("' | '")}'` + : 'never'; + const optionalFieldsCode = optionalFields.length + ? `'${optionalFields.join("' | '")}'` + : 'never'; const requestPayloadTypeName = templateRequestPayloadType.createName(path); const requestTypeName = applyRequestResult.typeDefinition.createName(path); - return `payload: ${requestPayloadTypeName}<${requestTypeName}, '${fields.join("' | '")}'>`; + return `payload: ${requestPayloadTypeName}<${requestTypeName}, ${requiredFieldsCode}, ${optionalFieldsCode}>`; } export function applyEndpointCallerFunction( diff --git a/src/oas3/codegen/request.ts b/src/oas3/codegen/request.ts index 9e0435e1..73e00bda 100644 --- a/src/oas3/codegen/request.ts +++ b/src/oas3/codegen/request.ts @@ -130,13 +130,18 @@ function applyNullableRequestBodyByContentTypeMap( }; } -function applyNullableRequestLocationParameters( +type ApplyRequestLocationParametersResult = { + hasAtLeastOneRequiredParameter: boolean; + codeOutput: CodeGenerationOutput; +}; + +function applyNullableRequestLocationParametersResult( codeGenerator: CodeGenerator, requestParameters: Parameter[], path: OutputPath, ctx: Context, parameterLocation: ConcreteParameterLocation -): null | CodeGenerationOutput { +): null | ApplyRequestLocationParametersResult { const objectSchema = findObjectSchemaFromLocationParameters( codeGenerator, requestParameters, @@ -145,7 +150,11 @@ function applyNullableRequestLocationParameters( if (!objectSchema) { return null; } - return applyObjectSchema(codeGenerator, objectSchema, path, ctx); + const codeOutput = applyObjectSchema(codeGenerator, objectSchema, path, ctx); + return { + codeOutput, + hasAtLeastOneRequiredParameter: !!objectSchema.required?.length, + }; } export type RequestPayloadField = @@ -157,7 +166,8 @@ export type RequestPayloadField = | 'body'; export type ApplyRequestTypeDefinitionResult = { - payloadFields: RequestPayloadField[]; + requiredPayloadFields: RequestPayloadField[]; + optionalPayloadFields: RequestPayloadField[]; typeDefinition: DefinitionOutput; }; @@ -167,34 +177,35 @@ export function applyRequestTypeDefinition( path: OutputPath, ctx: Context ): ApplyRequestTypeDefinitionResult { - const pathParamsCodeOutput = applyNullableRequestLocationParameters( + const pathParamsCodeOutput = applyNullableRequestLocationParametersResult( codeGenerator, schema.parameters ?? [], [...path, 'pathParams'], ctx, 'path' - ); - const queryParamsCodeOutput = applyNullableRequestLocationParameters( + )?.codeOutput; + const queryParamsResult = applyNullableRequestLocationParametersResult( codeGenerator, schema.parameters ?? [], [...path, 'queryParams'], ctx, 'query' ); - const headersCodeOutput = applyNullableRequestLocationParameters( + const queryParamsCodeOutput = queryParamsResult?.codeOutput; + const headersCodeOutput = applyNullableRequestLocationParametersResult( codeGenerator, schema.parameters ?? [], [...path, 'headers'], ctx, 'header' - ); - const cookiesCodeOutput = applyNullableRequestLocationParameters( + )?.codeOutput; + const cookiesCodeOutput = applyNullableRequestLocationParametersResult( codeGenerator, schema.parameters ?? [], [...path, 'cookies'], ctx, 'cookie' - ); + )?.codeOutput; let bodyCodeOutput: CodeGenerationOutput | null = null; if (schema.requestBody?.content) { bodyCodeOutput = applyNullableRequestBodyByContentTypeMap( @@ -280,25 +291,32 @@ export function applyRequestTypeDefinition( }, }; codeGenerator.addOutput(typeDefinition, ctx); - const includedFields: RequestPayloadField[] = []; + const requiredFields: RequestPayloadField[] = []; + const optionalFields: RequestPayloadField[] = []; if (pathParamsCodeOutput) { - includedFields.push('pathParams'); + requiredFields.push('pathParams'); } - if (queryParamsCodeOutput) { - includedFields.push('queryParams'); + if ( + queryParamsCodeOutput && + queryParamsResult?.hasAtLeastOneRequiredParameter + ) { + requiredFields.push('queryParams'); + } else if (queryParamsCodeOutput) { + optionalFields.push('queryParams'); } if (headersCodeOutput) { - includedFields.push('headers'); + optionalFields.push('headers'); } if (cookiesCodeOutput) { - includedFields.push('cookies'); + optionalFields.push('cookies'); } if (bodyCodeOutput) { - includedFields.push('contentType'); - includedFields.push('body'); + requiredFields.push('contentType'); + requiredFields.push('body'); } return { typeDefinition, - payloadFields: includedFields, + requiredPayloadFields: requiredFields, + optionalPayloadFields: optionalFields, }; } diff --git a/src/oas3/codegen/template.ts b/src/oas3/codegen/template.ts index a8a5a378..652be7d3 100644 --- a/src/oas3/codegen/template.ts +++ b/src/oas3/codegen/template.ts @@ -44,6 +44,27 @@ const templateCreateRequestUrlFunction: TemplateDefinitionOutput = { getRequiredOutputPaths: () => [templatePathParamsType.path], }; +const templateRequiredAndPartialType: TemplateDefinitionOutput = { + type: OutputType.TEMPLATE_DEFINITION, + definitionType: 'type', + path: ['core', 'core', 'requiredAndPartial'], + createName: () => { + return 'RequiredAndPartial'; + }, + createGenericsDeclarationCode: () => { + const codeParts = [ + 'T extends object = any,', + 'RFields extends keyof T = any,', + 'OFields extends keyof T = any,', + ]; + return codeParts.join('\n'); + }, + createCode: () => { + return 'Required> & Partial>'; + }, + getRequiredOutputPaths: () => [], +}; + const templateRequestPayloadTypePath = ['core', 'core', 'requestPayload']; export const templateRequestPayloadType: TemplateDefinitionOutput = { type: OutputType.TEMPLATE_DEFINITION, @@ -57,24 +78,27 @@ export const templateRequestPayloadType: TemplateDefinitionOutput = { `TRequest extends ${templateRequestType.createName( templateRequestPayloadTypePath )} = any,`, - 'TFields extends "cookies" | "headers" | "pathParams" | "queryParams" | "contentType" | "body" = any,', + "RFields extends 'pathParams' | 'queryParams' | 'contentType' | 'body' = any,", + "OFields extends 'cookies' | 'headers' | 'queryParams' = any,", ]; return codeParts.join('\n'); }, createCode: () => { const fieldCodeParts = [ - 'requestId?: string; // always optional', - 'headers?: TRequest["headers"]; // always optional', - 'cookies?: TRequest["cookies"]; // always optional', + 'requestId: string;', + 'headers: TRequest["headers"];', + 'cookies: TRequest["cookies"];', 'pathParams: TRequest["pathParams"];', 'queryParams: TRequest["queryParams"];', 'contentType: TRequest["contentType"];', 'body: TRequest["body"];', ]; - return `Pick<{${fieldCodeParts.join('\n')}}, "requestId" | TFields>`; + return `${templateRequiredAndPartialType.createName( + templateRequestPayloadTypePath + )}<{${fieldCodeParts.join('\n')}}, RFields, "requestId" | OFields>`; }, getRequiredOutputPaths: () => { - return [templateRequestType.path]; + return [templateRequestType.path, templateRequiredAndPartialType.path]; }, }; @@ -931,6 +955,7 @@ export const templateDefinitionOutputs: TemplateDefinitionOutput[] = [ templateRequestBodyDataType, templateRequestType, templateRequestUnionType, + templateRequiredAndPartialType, templateRequestPayloadType, templateRequestFromPayloadType, templateCreateRequestIdFunction, diff --git a/src/templates/core/core.ts b/src/templates/core/core.ts index 54bdf98e..c79d24ca 100644 --- a/src/templates/core/core.ts +++ b/src/templates/core/core.ts @@ -1,43 +1,5 @@ import {ZodSchema} from 'zod'; -export type RequestHeaders = { - [headerName: string]: string | number; -}; - -export type ResponseHeaders = { - [headerName: string]: string; -}; - -export type PathParams = { - [paramName: string]: number | string; -}; - -export type QueryParams = { - [paramName: string]: - | null - | undefined - | QueryParams - | QueryParams[] - | string - | number - | boolean; -}; - -export type RequestCookies = { - [cookieName: string]: string; -}; - -export type RequestBody = - | Blob - | FormData - | FormDataObject - | PlainObject - | string; - -export type FormDataObject = { - [key: string]: undefined | string | number | boolean | Blob; -}; - export type PlainObject = | null | (null | boolean | number | string | PlainObject)[] @@ -79,6 +41,10 @@ export function isPlainObject( return true; } +export type FormDataObject = { + [key: string]: undefined | string | number | boolean | Blob; +}; + export function findMatchingSchemaContentType( actualStatus: number, actualContentType: string, @@ -110,16 +76,156 @@ export function findMatchingSchemaContentType( }, null); } -export type RequestCreationSettings = { - headers?: RequestHeaders; - cookies?: RequestCookies; - pathParams?: PathParams; - queryParams?: QueryParams; - contentType?: string; - body?: RequestBody; +export type EndpointSecuritySchema = { + name: string; + scopes: string[]; +}; + +export type ResponseSchema = { + bodyByContentType: Record< + string, + { + zodSchema: ZodSchema; // only defined by the generator when "withZod: true" + } + >; + headersZodSchema?: ZodSchema; // only defined by the generator when "withZod: true" +}; + +export type EndpointSchema = { + path: string; + method: string; + supportedSecuritySchemas: EndpointSecuritySchema[]; + bodyByContentType: Record< + string, + { + zodSchema: ZodSchema; // only defined by the generator when "withZod: true" + } + >; + headersZodSchema?: ZodSchema; // only defined by the generator when "withZod: true" + cookiesZodSchema?: ZodSchema; // only defined by the generator when "withZod: true" + pathParamsZodSchema?: ZodSchema; // only defined by the generator when "withZod: true" + queryParamsZodSchema?: ZodSchema; // only defined by the generator when "withZod: true" + responseByStatus: Partial>; +}; + +export type PathParams = { + [paramName: string]: number | string; +}; + +export type QueryParams = { + [paramName: string]: + | undefined + | null + | QueryParams + | QueryParams[] + | string + | string[] + | number + | number[] + | boolean + | boolean[]; +}; + +export type RequestHeaders = { + [headerName: string]: string | number; +}; + +export type RequestCookies = { + [cookieName: string]: string | number; +}; + +export type RequestBody = + | Blob + | FormData + | FormDataObject + | PlainObject + | string; + +export type RequestBodyData< + TContentType extends string = any, + TBody extends RequestBody = any, +> = {contentType: TContentType; body: TBody}; + +export type Request< + Ct extends string | undefined = any, + TBody extends RequestBody | undefined = any, + TPathParams extends PathParams | undefined = any, + TQueryParams extends QueryParams | undefined = any, + THeaders extends RequestHeaders | undefined = any, + TCookies extends RequestCookies | undefined = any, +> = { endpointSchema: EndpointSchema; + id: string; + url: string; + headers: THeaders; + cookies: TCookies; + pathParams: TPathParams; + queryParams: TQueryParams; + // According to given OAS3 specs; used as default for the "content-type" request header + contentType: Ct; + body: TBody; }; +export type RequestUnion< + TBodyData extends RequestBodyData, + TPathParams extends PathParams | undefined = any, + TQueryParams extends QueryParams | undefined = any, + THeaders extends RequestHeaders | undefined = any, + TCookies extends RequestCookies | undefined = any, +> = TBodyData extends any + ? Request< + TBodyData['contentType'], + TBodyData['body'], + TPathParams, + TQueryParams, + THeaders, + TCookies + > + : never; + +export type RequiredAndPartial< + T extends object = any, + RFields extends keyof T = any, + OFields extends keyof T = any, +> = Required> & Partial>; + +export type RequestPayload< + TRequest extends Request = any, + RFields extends 'pathParams' | 'queryParams' | 'contentType' | 'body' = any, + OFields extends 'cookies' | 'headers' | 'queryParams' = any, +> = RequiredAndPartial< + { + requestId: string; + headers: TRequest['headers']; + cookies: TRequest['cookies']; + pathParams: TRequest['pathParams']; + queryParams: TRequest['queryParams']; + contentType: TRequest['contentType']; + body: TRequest['body']; + }, + RFields, + 'requestId' | OFields +>; + +export type RequestFromPayload = Request< + TPayload['pathParams'], + TPayload['queryParams'], + TPayload['headers'], + TPayload['cookies'], + TPayload['contentType'], + TPayload['body'] +>; + +export function createRequestId(): string { + const chars = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'.split(''); + let requestId = ''; + for (let i = 32; i > 0; i--) { + requestId += chars[Math.floor(Math.random() * chars.length)]; + } + return requestId; +} + export function createRequestUrl( endpointPath: string, params: PathParams @@ -148,67 +254,27 @@ export function createRequestUrl( return url; } -export type EndpointSecuritySchema = { - name: string; - scopes: string[]; -}; - -export type EndpointSchema = { - path: string; - method: string; - supportedSecuritySchemas: EndpointSecuritySchema[]; - bodyByContentType: Record< - string, - { - zodSchema: ZodSchema; // only defined by the generator when "withZod: true" - } - >; - headersZodSchema?: ZodSchema; // only defined by the generator when "withZod: true" - cookiesZodSchema?: ZodSchema; // only defined by the generator when "withZod: true" - pathParamsZodSchema?: ZodSchema; // only defined by the generator when "withZod: true" - queryParamsZodSchema?: ZodSchema; // only defined by the generator when "withZod: true" - responseByStatus: Partial>; -}; - -export function createRequest(settings: RequestCreationSettings): Request { - const chars = - 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'.split(''); - let requestId = ''; - for (let i = 32; i > 0; i--) { - requestId += chars[Math.floor(Math.random() * chars.length)]; - } +export function createRequest( + endpointSchema: EndpointSchema, + payload?: RequestPayload +): RequestFromPayload { + const p = payload ?? {}; return { - ...settings, - id: requestId, - url: createRequestUrl( - settings.endpointSchema.path, - settings.pathParams ?? {} - ), - contentType: settings.contentType ?? null, + ...payload, + id: p.requestId ?? createRequestId(), + url: createRequestUrl(endpointSchema.path, p.pathParams ?? {}), + contentType: p.contentType, + body: p.body, + pathParams: p.pathParams, + queryParams: p.queryParams, + headers: p.headers, + cookies: p.cookies, + endpointSchema: endpointSchema, }; } -export type Request = { - id: string; - url: string; - headers?: RequestHeaders; - cookies?: RequestCookies; - pathParams?: PathParams; - queryParams?: QueryParams; - // according to oas3 specs; used as default for the "content-type" request header - contentType: string | null; - body?: RequestBody; - endpointSchema: EndpointSchema; -}; - -export type ResponseSchema = { - bodyByContentType: Record< - string, - { - zodSchema: ZodSchema; // only defined by the generator when "withZod: true" - } - >; - headersZodSchema?: ZodSchema; // only defined by the generator when "withZod: true" +export type ResponseHeaders = { + [headerName: string]: string; }; export type ResponseSetCookies = { @@ -230,13 +296,13 @@ export type ResponseBodyData< body: B; }; -export type Response< +export interface Response< S extends number = any, Ct extends string | null = any, B extends ResponseBody = any, H extends ResponseHeaders = any, C extends ResponseSetCookies = any, -> = { +> { status: S; headers: H; cookies: C; @@ -244,7 +310,7 @@ export type Response< contentType: Ct; revealBody: () => Promise; revealBodyAsArrayBuffer: () => Promise; -}; +} export type ResponseUnion< S extends number = any,