diff --git a/packages/oats/package.json b/packages/oats/package.json index e1045230..7a96fa95 100644 --- a/packages/oats/package.json +++ b/packages/oats/package.json @@ -40,6 +40,7 @@ ], "devDependencies": { "@smartlyio/oats-axios-adapter": "^7.6.1", + "prettier": "^3.0.0", "@smartlyio/oats-fetch-adapter": "^7.6.1", "@smartlyio/oats-koa-adapter": "^7.6.1", "@smartlyio/oats-runtime": "^7.6.1", diff --git a/packages/oats/src/builder.ts b/packages/oats/src/builder.ts deleted file mode 100644 index b5108de0..00000000 --- a/packages/oats/src/builder.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as ts from 'typescript'; -import * as assert from 'assert'; - -export function buildMethod(methodStr: string): ts.MethodDeclaration { - const node = ts.createSourceFile( - 'block.ts', - `class Foo { ${methodStr} }`, - ts.ScriptTarget.Latest - ); - const classDeclaration: ts.Node = node.statements[0]; - assert(ts.isClassDeclaration(classDeclaration)); - const method = classDeclaration.members[0]; - assert(ts.isMethodDeclaration(method)); - return method; -} -export function buildBlock(block: string): ts.Block { - const node = ts.createSourceFile('block.ts', `function foo() {${block}}`, ts.ScriptTarget.Latest); - const fun: ts.Node = node.getChildAt(0).getChildAt(0) as any; - assert(ts.isFunctionDeclaration(fun)); - return fun.body!; -} diff --git a/packages/oats/src/codegen/classes.ts b/packages/oats/src/codegen/classes.ts new file mode 100644 index 00000000..3192ff92 --- /dev/null +++ b/packages/oats/src/codegen/classes.ts @@ -0,0 +1,83 @@ +/** + * Value class generation for TypeScript output. + */ + +import * as oas from 'openapi3-ts'; +import { GenerationContext } from './context'; +import { ts } from '../template'; +import { runtime, fromLib } from './helpers'; +import { generateClassMembers } from './types'; + +/** + * Generates a complete value class declaration. + */ +export function generateValueClass( + key: string, + valueIdentifier: string, + schema: oas.SchemaObject, + ctx: GenerationContext +): string { + const members = generateClassMembers( + schema.properties, + schema.required, + schema.additionalProperties, + ctx + ); + + const builtinMembers = generateClassBuiltinMembers(key, ctx); + + return ts`export class ${valueIdentifier} extends ${fromLib('valueClass', 'ValueClass')} { ${[ + ...members, + ...builtinMembers + ].join(' ')} }`; +} + +/** + * Generates the class constructor method. + */ +export function generateClassConstructor(key: string, ctx: GenerationContext): string { + const { options } = ctx; + const shapeName = options.nameMapper(key, 'shape'); + const valueName = options.nameMapper(key, 'value'); + + return ts`public constructor(value: ${shapeName}, opts?: ${fromLib( + 'make', + 'MakeOptions' + )} | InternalUnsafeConstructorOption) { super(); ${runtime}.instanceAssign(this, value, opts, build${valueName}); }`; +} + +/** + * Generates the static reflection property. + */ +export function generateReflectionProperty(key: string, ctx: GenerationContext): string { + const { options } = ctx; + const valueName = options.nameMapper(key, 'value'); + const reflectionName = options.nameMapper(key, 'reflection'); + + return ts`public static reflection: ${fromLib( + 'reflection', + 'NamedTypeDefinitionDeferred' + )}<${valueName}> = () => { return ${reflectionName}; };`; +} + +/** + * Generates the static make method. + */ +export function generateClassMakeMethod(key: string, ctx: GenerationContext): string { + const { options } = ctx; + const className = options.nameMapper(key, 'value'); + const shapeName = options.nameMapper(key, 'shape'); + + return ts`static make(value: ${shapeName}, opts?: ${runtime}.make.MakeOptions): ${runtime}.make.Make<${className}> { if (value instanceof ${className}) { return ${runtime}.make.Make.ok(value); } const make = build${className}(value, opts); if (make.isError()) { return ${runtime}.make.Make.error(make.errors); } else { return ${runtime}.make.Make.ok(new ${className}(make.success(), { unSafeSet: true })); } }`; +} + +/** + * Generates all built-in class members (constructor, reflection, make). + */ +export function generateClassBuiltinMembers(key: string, ctx: GenerationContext): string[] { + return [ + generateClassConstructor(key, ctx), + generateReflectionProperty(key, ctx), + generateClassMakeMethod(key, ctx) + ]; +} diff --git a/packages/oats/src/codegen/context.ts b/packages/oats/src/codegen/context.ts new file mode 100644 index 00000000..41f16678 --- /dev/null +++ b/packages/oats/src/codegen/context.ts @@ -0,0 +1,117 @@ +/** + * Generation context that replaces closure-based access to options and state. + * Passed explicitly to all code generation functions. + */ + +import * as oas from 'openapi3-ts'; +import * as path from 'path'; +import { NameKind, NameMapper, UnsupportedFeatureBehaviour } from '../util'; + +export interface Options { + forceGenerateTypes?: boolean; + header: string; + sourceFile: string; + targetFile: string; + resolve: Resolve; + oas: oas.OpenAPIObject; + runtimeModule: string; + emitStatusCode: (status: number) => boolean; + unsupportedFeatures?: { + security?: UnsupportedFeatureBehaviour; + }; + emitUndefinedForIndexTypes?: boolean; + unknownAdditionalPropertiesIndexSignature?: AdditionalPropertiesIndexSignature; + propertyNameMapper?: (openapiPropertyName: string) => string; + nameMapper: NameMapper; +} + +export type Resolve = ( + ref: string, + options: Options, + kind: NameKind +) => + | { importAs: string; importFrom: string; name: string; generate?: () => Promise } + | { name: string } + | undefined; + +export enum AdditionalPropertiesIndexSignature { + emit = 'emit', + omit = 'omit' +} + +export interface GenerationState { + cwd: string; + imports: Record; + actions: Array<() => Promise>; +} + +/** + * Context passed to all generation functions, providing access to options + * and state management functions. + */ +export interface GenerationContext { + readonly options: Options; + + /** + * Add an import to the generated file. + */ + addImport(importAs: string, importFile: string | undefined, action?: () => Promise): void; + + /** + * Resolve a $ref to a type name, potentially from an external module. + */ + resolveRefToTypeName(ref: string, kind: NameKind): { qualified?: string; member: string }; +} + +/** + * Creates a generation context from options and state. + */ +export function createContext( + options: Options, + state: GenerationState, + generatedFiles: Set +): GenerationContext { + return { + options, + + addImport( + importAs: string, + importFile: string | undefined, + action?: () => Promise + ): void { + if (!state.imports[importAs]) { + if (importFile) { + importFile = /^(\.|\/)/.test(importFile) ? './' + path.normalize(importFile) : importFile; + state.imports[importAs] = importFile; + if (action) { + if (generatedFiles.has(importFile)) { + return; + } + generatedFiles.add(importFile); + state.actions.push(action); + } + } + } + }, + + resolveRefToTypeName(ref: string, kind: NameKind): { qualified?: string; member: string } { + const external = options.resolve(ref, options, kind); + if (external) { + if ('importAs' in external) { + const importAs = external.importAs; + this.addImport(importAs, external.importFrom, external.generate); + return { member: external.name, qualified: importAs }; + } + return { member: external.name }; + } + if (ref[0] === '#') { + const refToTypeName = (r: string) => { + const name = r.split('/').reverse()[0]; + return name[0].toUpperCase() + name.slice(1); + }; + return { member: options.nameMapper(refToTypeName(ref), kind) }; + } + throw new Error('could not resolve typename for ' + ref); + } + }; +} diff --git a/packages/oats/src/codegen/helpers.ts b/packages/oats/src/codegen/helpers.ts new file mode 100644 index 00000000..6fedb1c2 --- /dev/null +++ b/packages/oats/src/codegen/helpers.ts @@ -0,0 +1,130 @@ +/** + * Pure utility functions for code generation with no context dependency. + */ + +import * as oas from 'openapi3-ts'; +import { NameMapper } from '../util'; +import { quoteProp, str } from '../template'; + +/** Runtime library identifier used in generated code. */ +export const runtime = 'oar'; + +/** Key used for index signatures in value classes. */ +export const valueClassIndexSignatureKey = 'instanceIndexSignatureKey'; + +/** Brand field name for value class type safety. */ +export const oatsBrandFieldName = '__oats_value_class_brand_tag'; + +/** Scalar types that get branded. */ +export const scalarTypes = ['string', 'integer', 'number', 'boolean']; + +/** + * Quotes a property name if it contains special characters. + */ +export function quotedProp(prop: string): string { + return quoteProp(prop); +} + +/** + * Generates a literal value as code string from a JavaScript value. + */ +export function generateLiteral(e: unknown): string { + if (e === true) return 'true'; + if (e === false) return 'false'; + if (e === null) return 'null'; + if (typeof e === 'string') return str(e); + if (typeof e === 'bigint') return `${e}n`; + if (typeof e === 'number') return generateNumericLiteral(e); + throw new Error(`unsupported enum value: "${e}"`); +} + +/** + * Generates a numeric literal, handling negative numbers with prefix. + */ +export function generateNumericLiteral(value: number | string): string { + const n = Number(value); + if (n < 0) { + return String(n); + } + return String(n); +} + +/** + * Creates a qualified name from the runtime library. + */ +export function fromLib(...names: string[]): string { + return `${runtime}.${names.join('.')}`; +} + +/** + * Creates a call expression to a runtime make function. + */ +export function makeCall(fun: string, args: readonly string[]): string { + return `${runtime}.make.${fun}(${args.join(', ')})`; +} + +/** + * Creates an any-typed parameter declaration. + */ +export function makeAnyProperty(name: string): string { + return `${name}: any`; +} + +/** + * Generates the brand type name for a given key. + */ +export function brandTypeName(key: string, nameMapper: NameMapper): string { + return 'BrandOf' + nameMapper(key, 'value'); +} + +/** + * Checks if a schema represents a scalar type. + */ +export function isScalar(schema: oas.SchemaObject): boolean { + if (!schema.type) return false; + if (Array.isArray(schema.type)) { + return schema.type.findIndex(t => scalarTypes.includes(t)) >= 0; + } + return scalarTypes.includes(schema.type); +} + +/** + * Post-processes generated source to add ts-ignore comments where needed. + */ +export function addIndexSignatureIgnores(src: string): string { + const result: string[] = []; + src.split('\n').forEach(line => { + const m = line.match(new RegExp('\\[\\s*' + valueClassIndexSignatureKey)); + if (m) { + if (!/\b(unknown|any)\b/.test(line)) { + result.push(' // @ts-ignore tsc does not like the branding type in index signatures'); + result.push(line); + return; + } + } + const brandMatch = line.match(new RegExp('\\s*readonly #' + oatsBrandFieldName)); + if (brandMatch) { + result.push(' // @ts-ignore tsc does not like unused privates'); + result.push(line); + return; + } + result.push(line); + }); + return result.join('\n'); +} + +/** + * Resolves module path for imports. + */ +export function resolveModule(fromModule: string, toModule: string): string { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const path = require('path'); + if (!toModule.startsWith('.')) { + return toModule; + } + const p = path.relative(path.dirname(fromModule), toModule); + if (p[0] === '.') { + return p; + } + return './' + p; +} diff --git a/packages/oats/src/codegen/index.ts b/packages/oats/src/codegen/index.ts new file mode 100644 index 00000000..5dbb70e0 --- /dev/null +++ b/packages/oats/src/codegen/index.ts @@ -0,0 +1,85 @@ +/** + * Code generation module exports. + */ + +// Context +export { + GenerationContext, + GenerationState, + Options, + Resolve, + AdditionalPropertiesIndexSignature, + createContext +} from './context'; + +// Helpers +export { + runtime, + valueClassIndexSignatureKey, + oatsBrandFieldName, + scalarTypes, + quotedProp, + generateLiteral, + generateNumericLiteral, + fromLib, + makeCall, + makeAnyProperty, + brandTypeName, + isScalar, + addIndexSignatureIgnores, + resolveModule +} from './helpers'; + +// Types +export { + generateAdditionalPropType, + generateClassMembers, + generateOatsBrandProperty, + generateObjectMembers, + generateType, + generateStringType, + scalarTypeWithBrand +} from './types'; + +// Reflection +export { + generateReflectionType, + generateAdditionalPropsReflectionType, + generateObjectReflectionType, + generateReflectionMaker, + generateNamedTypeDefinitionDeclaration, + inventIsA, + generateIsA, + generateIsAForScalar +} from './reflection'; + +// Classes +export { + generateValueClass, + generateClassConstructor, + generateReflectionProperty, + generateClassMakeMethod, + generateClassBuiltinMembers +} from './classes'; + +// Makers +export { + generateTypeShape, + generateBrand, + generateTopLevelClassBuilder, + generateTopLevelClassMaker, + generateTopLevelMaker, + generateTopLevelClass, + generateTopLevelType +} from './makers'; + +// Query types +export { + generateContentSchemaType, + generateHeadersSchemaType, + generateQueryType, + generateParameterType, + generateRequestBodyType, + generateResponseType, + generateQueryTypes +} from './query-types'; diff --git a/packages/oats/src/codegen/makers.ts b/packages/oats/src/codegen/makers.ts new file mode 100644 index 00000000..927d5740 --- /dev/null +++ b/packages/oats/src/codegen/makers.ts @@ -0,0 +1,164 @@ +/** + * Maker function and top-level type generation. + */ + +import * as oas from 'openapi3-ts'; +import { isReferenceObject, nonNullableClass } from '../util'; +import { GenerationContext } from './context'; +import { ts } from '../template'; +import { fromLib, isScalar } from './helpers'; +import { generateType, scalarTypeWithBrand } from './types'; +import { generateValueClass } from './classes'; +import { generateReflectionMaker, generateNamedTypeDefinitionDeclaration } from './reflection'; + +/** + * Generates the ShapeOf type alias. + */ +export function generateTypeShape( + key: string, + valueIdentifier: string, + ctx: GenerationContext +): string { + const { options } = ctx; + return ts`export type ${options.nameMapper(key, 'shape')} = ${fromLib( + 'ShapeOf' + )}<${valueIdentifier}>;`; +} + +/** + * Generates an empty enum for branding scalar types. + */ +export function generateBrand(key: string, ctx: GenerationContext): string { + const brandName = 'BrandOf' + ctx.options.nameMapper(key, 'value'); + return ts`enum ${brandName} {}`; +} + +/** + * Generates the class builder function. + */ +export function generateTopLevelClassBuilder( + key: string, + valueIdentifier: string, + ctx: GenerationContext +): string { + return generateTopLevelMaker(key, ctx, 'build', valueIdentifier); +} + +/** + * Generates a maker variable that references the class's make method. + */ +export function generateTopLevelClassMaker( + key: string, + valueIdentifier: string, + ctx: GenerationContext +): string { + const { options } = ctx; + const shapeName = options.nameMapper(key, 'shape'); + + return ts`export const make${valueIdentifier}: ${fromLib( + 'make', + 'Maker' + )}<${shapeName}, ${valueIdentifier}> = ${valueIdentifier}.make;`; +} + +/** + * Generates a maker function declaration. + */ +export function generateTopLevelMaker( + key: string, + ctx: GenerationContext, + name = 'make', + resultType?: string +): string { + const { options } = ctx; + const shapeName = options.nameMapper(key, 'shape'); + resultType = resultType || options.nameMapper(key, 'value'); + const makerName = name + options.nameMapper(key, 'value'); + const reflectionMaker = generateReflectionMaker(key, ctx); + + return ts`export const ${makerName}: ${fromLib( + 'make', + 'Maker' + )}<${shapeName}, ${resultType}> = ${fromLib( + 'make', + 'createMaker' + )}(function () { return ${reflectionMaker}; });`; +} + +/** + * Generates a complete class with all supporting types and makers. + */ +export function generateTopLevelClass( + key: string, + schema: oas.SchemaObject, + ctx: GenerationContext +): readonly string[] { + const { options } = ctx; + + if (schema.nullable) { + const classKey = nonNullableClass(key); + const proxy = generateTopLevelType( + key, + { + oneOf: [{ type: 'null' }, { $ref: '#/components/schemas/' + classKey }] + }, + ctx + ); + return [...generateTopLevelClass(classKey, { ...schema, nullable: false }, ctx), ...proxy]; + } + + const valueIdentifier = options.nameMapper(key, 'value'); + return [ + generateTypeShape(key, valueIdentifier, ctx), + generateValueClass(key, valueIdentifier, schema, ctx), + generateTopLevelClassBuilder(key, valueIdentifier, ctx), + generateTopLevelClassMaker(key, valueIdentifier, ctx), + generateNamedTypeDefinitionDeclaration(key, schema, ctx) + ]; +} + +/** + * Main entry point for generating a top-level type from a schema. + */ +export function generateTopLevelType( + key: string, + schema: oas.SchemaObject | oas.ReferenceObject, + ctx: GenerationContext +): readonly string[] { + const { options } = ctx; + const valueIdentifier = options.nameMapper(key, 'value'); + + if (isReferenceObject(schema)) { + const resolved = ctx.resolveRefToTypeName(schema.$ref, 'value'); + const type = resolved.qualified ? `${resolved.qualified}.${resolved.member}` : resolved.member; + + return [ + ts`export type ${valueIdentifier} = ${type};`, + generateTypeShape(key, valueIdentifier, ctx), + generateTopLevelMaker(key, ctx), + generateNamedTypeDefinitionDeclaration(key, schema, ctx) + ]; + } + + if (schema.type === 'object') { + return generateTopLevelClass(key, schema, ctx); + } + + if (isScalar(schema)) { + const baseType = generateType(schema, ctx); + return [ + generateBrand(key, ctx), + ts`export type ${valueIdentifier} = ${scalarTypeWithBrand(key, baseType, ctx)};`, + generateTypeShape(key, valueIdentifier, ctx), + generateTopLevelMaker(key, ctx), + generateNamedTypeDefinitionDeclaration(key, schema, ctx) + ]; + } + + return [ + ts`export type ${valueIdentifier} = ${generateType(schema, ctx)};`, + generateTypeShape(key, valueIdentifier, ctx), + generateTopLevelMaker(key, ctx), + generateNamedTypeDefinitionDeclaration(key, schema, ctx) + ]; +} diff --git a/packages/oats/src/codegen/query-types.ts b/packages/oats/src/codegen/query-types.ts new file mode 100644 index 00000000..4aa1b271 --- /dev/null +++ b/packages/oats/src/codegen/query-types.ts @@ -0,0 +1,337 @@ +/** + * Query, parameter, and response type generators for API endpoints. + */ + +import * as oas from 'openapi3-ts'; +import * as assert from 'assert'; +import { deref, isReferenceObject, endpointTypeName, errorTag } from '../util'; +import { resolvedStatusCodes } from '../status-codes'; +import { GenerationContext } from './context'; +import { generateTopLevelType } from './makers'; + +/** Schema representing void type (used for empty responses/requests). */ +const voidSchema: oas.SchemaObject = { type: 'void' as unknown as 'string' }; + +/** + * Generates the schema type for content types (request body, response). + */ +export function generateContentSchemaType(content: oas.ContentObject): oas.SchemaObject { + const contentTypeSchemas = Object.keys(content).map(contentType => + errorTag(`contentType '${contentType}'`, () => { + const mediaObject: oas.MediaTypeObject = content[contentType]; + const schema: oas.SchemaObject = { + type: 'object', + properties: { + contentType: { + type: 'string', + enum: [contentType] + }, + value: mediaObject.schema || assert.fail('missing schema') + }, + required: ['contentType', 'value'], + additionalProperties: false + }; + return schema; + }) + ); + + return { oneOf: contentTypeSchemas }; +} + +/** + * Generates the schema type for response headers. + */ +export function generateHeadersSchemaType(headers: oas.HeadersObject): oas.SchemaObject { + const required: string[] = []; + const properties = Object.entries(headers).reduce((memo, [headerName, headerObject]) => { + if (isReferenceObject(headerObject)) { + required.push(headerName); + return { ...memo, [headerName]: headerObject }; + } else if (headerObject.schema) { + if (headerObject.required) required.push(headerName); + return { ...memo, [headerName]: headerObject.schema }; + } + return memo; + }, {} as { [key: string]: oas.SchemaObject | oas.ReferenceObject }); + + return { + type: 'object', + properties, + required, + additionalProperties: { type: 'string' } + }; +} + +/** + * Generates query parameter types for an endpoint. + */ +export function generateQueryType( + op: string, + paramSchema: undefined | ReadonlyArray, + oasSchema: oas.OpenAPIObject, + ctx: GenerationContext +): readonly string[] { + const noQueryParams = { type: 'object' as const, additionalProperties: false }; + + if (!paramSchema) { + return generateTopLevelType(op, noQueryParams, ctx); + } + + const schema = deref(paramSchema, oasSchema); + const queryParams = schema.map(s => deref(s, oasSchema)).filter(s => s.in === 'query'); + + if (queryParams.length === 0) { + return generateTopLevelType(op, noQueryParams, ctx); + } + + if ( + queryParams.some( + param => + !!param.explode && + (isReferenceObject(param.schema) || + param.schema?.type === 'object' || + param.schema?.allOf || + param.schema?.oneOf || + param.schema?.anyOf) + ) + ) { + assert( + queryParams.length === 1, + 'only one query parameter is supported when the query parameter schema is a reference, object or compound' + ); + const param = queryParams[0]; + return generateTopLevelType(op, param.schema || {}, ctx); + } + + const jointSchema: oas.SchemaObject = { + type: 'object', + additionalProperties: false, + required: queryParams.filter(param => param.required).map(param => param.name), + properties: queryParams.reduce( + (memo: Record, param) => { + if (param.schema) { + memo[param.name] = param.schema; + } + return memo; + }, + {} + ) + }; + + return generateTopLevelType(op, jointSchema, ctx); +} + +/** + * Generates path or header parameter types for an endpoint. + */ +export function generateParameterType( + type: 'path' | 'header', + op: string, + paramSchema: undefined | ReadonlyArray, + oasSchema: oas.OpenAPIObject, + ctx: GenerationContext, + normalize = (name: string) => name +): readonly string[] { + const empty = generateTopLevelType(op, voidSchema, ctx); + + if (!paramSchema) { + return empty; + } + + const schema = deref(paramSchema, oasSchema); + const pathParams = schema.map(s => deref(s, oasSchema)).filter(s => s.in === type); + + if (pathParams.length === 0) { + return empty; + } + + const required: string[] = []; + pathParams.forEach(paramOrRef => { + const param = deref(paramOrRef, oasSchema); + if (param.required) { + required.push(normalize(param.name)); + } + }); + + const jointSchema: oas.SchemaObject = { + type: 'object', + additionalProperties: false, + required, + properties: pathParams.reduce( + (memo: Record, param) => { + if (param.schema) { + memo[normalize(param.name)] = param.schema; + } + return memo; + }, + {} + ) + }; + + return generateTopLevelType(op, jointSchema, ctx); +} + +/** + * Generates request body type for an endpoint. + */ +export function generateRequestBodyType( + op: string, + requestBody: undefined | oas.ReferenceObject | oas.RequestBodyObject, + ctx: GenerationContext +): readonly string[] { + if (requestBody == null) { + return generateTopLevelType(op, voidSchema, ctx); + } + + if (isReferenceObject(requestBody)) { + return generateTopLevelType(op, { $ref: requestBody.$ref }, ctx); + } + + // requestBody is not required by default https://swagger.io/docs/specification/describing-request-body/ + if (requestBody.required === true) { + return generateTopLevelType(op, generateContentSchemaType(requestBody.content), ctx); + } + + return generateTopLevelType( + op, + { + oneOf: [generateContentSchemaType(requestBody.content), voidSchema] + }, + ctx + ); +} + +/** + * Generates response type for an endpoint. + */ +export function generateResponseType( + op: string, + responses: oas.ResponsesObject, + ctx: GenerationContext +): readonly string[] { + const { options } = ctx; + + if (!responses) { + return assert.fail('missing responses'); + } + + const statusesByCode = resolvedStatusCodes(Object.keys(responses)); + const responseSchemas: oas.SchemaObject[] = []; + + Object.keys(responses).forEach(status => { + const response: oas.ReferenceObject | oas.ResponseObject = responses[status]; + const statuses = (statusesByCode.get(status) || []).filter(options.emitStatusCode); + + if (statuses.length > 0) { + const schema: oas.SchemaObject = { + type: 'object', + properties: { + status: { + type: 'integer', + enum: statuses + }, + value: isReferenceObject(response) + ? { $ref: response.$ref } + : generateContentSchemaType( + response.content || { + oatsNoContent: { + schema: { type: 'null' } + } + } + ), + headers: { + type: 'object', + additionalProperties: { type: 'string' } + } + }, + required: ['status', 'value', 'headers'], + additionalProperties: false + }; + + if (!isReferenceObject(response) && response.headers) { + schema.properties!.headers = generateHeadersSchemaType(response.headers); + } + + responseSchemas.push(schema); + } + }); + + if (responseSchemas.length === 0) { + return generateTopLevelType(op, voidSchema, ctx); + } + + return generateTopLevelType(op, { oneOf: responseSchemas }, ctx); +} + +/** + * Generates all query/parameter/body/response types for all endpoints. + */ +export function generateQueryTypes(ctx: GenerationContext): string[] { + const { options } = ctx; + const schema = options.oas; + const response: string[] = []; + + Object.keys(schema.paths).forEach(path => { + Object.keys(schema.paths[path]).forEach(method => { + const endpoint: oas.OperationObject = schema.paths[path][method]; + + errorTag(`in ${method.toUpperCase()} ${path} query`, () => + response.push( + ...generateQueryType( + endpointTypeName(endpoint, path, method, 'query'), + endpoint.parameters, + schema, + ctx + ) + ) + ); + + errorTag(`in ${method.toUpperCase()} ${path} header`, () => + response.push( + ...generateParameterType( + 'header', + endpointTypeName(endpoint, path, method, 'headers'), + endpoint.parameters, + schema, + ctx, + name => name.toLowerCase() + ) + ) + ); + + errorTag(`in ${method.toUpperCase()} ${path} parameters`, () => + response.push( + ...generateParameterType( + 'path', + endpointTypeName(endpoint, path, method, 'parameters'), + endpoint.parameters, + schema, + ctx + ) + ) + ); + + errorTag(`in ${method.toUpperCase()} ${path} requestBody`, () => + response.push( + ...generateRequestBodyType( + endpointTypeName(endpoint, path, method, 'requestBody'), + endpoint.requestBody, + ctx + ) + ) + ); + + errorTag(`in ${method.toUpperCase()} ${path} response`, () => + response.push( + ...generateResponseType( + endpointTypeName(endpoint, path, method, 'response'), + endpoint.responses, + ctx + ) + ) + ); + }); + }); + + return response; +} diff --git a/packages/oats/src/codegen/reflection.ts b/packages/oats/src/codegen/reflection.ts new file mode 100644 index 00000000..a3c6caa5 --- /dev/null +++ b/packages/oats/src/codegen/reflection.ts @@ -0,0 +1,231 @@ +/** + * Reflection metadata generation for runtime type information. + */ + +import * as oas from 'openapi3-ts'; +import * as assert from 'assert'; +import { isReferenceObject } from '../util'; +import { GenerationContext } from './context'; +import { ts, str } from '../template'; +import { fromLib, isScalar } from './helpers'; + +/** + * Generates reflection type metadata from a schema. + * This is recursive and handles all schema types. + * Returns an object literal expression string (not a complete statement). + */ +export function generateReflectionType( + schema: oas.SchemaObject | oas.ReferenceObject, + ctx: GenerationContext +): string { + if (isReferenceObject(schema)) { + const resolved = ctx.resolveRefToTypeName(schema.$ref, 'reflection'); + const type = resolved.qualified ? `${resolved.qualified}.${resolved.member}` : resolved.member; + return `{ type: "named", reference: () => { return ${type}; } }`; + } + + if (schema.oneOf) { + const options = schema.oneOf.map(s => generateReflectionType(s, ctx)).join(', '); + return `{ type: "union", options: [${options}] }`; + } + + if (schema.allOf) { + const options = schema.allOf.map(s => generateReflectionType(s, ctx)).join(', '); + return `{ type: "intersection", options: [${options}] }`; + } + + assert(!schema.anyOf, 'anyOf is not supported'); + + if (schema.nullable) { + return generateReflectionType( + { oneOf: [{ ...schema, nullable: false }, { type: 'null' }] }, + ctx + ); + } + + // @ts-expect-error schemas really do not have void type. but we do + if (schema.type === 'void') { + return `{ type: "void" }`; + } + + if (schema.type === 'null') { + return `{ type: "null" }`; + } + + if (schema.type === 'string') { + if (schema.format === 'binary') { + return `{ type: "binary" }`; + } + + const props = [ + 'type: "string"', + schema.enum ? `enum: [${schema.enum.map(v => str(v)).join(', ')}]` : '', + schema.format ? `format: ${str(schema.format)}` : '', + schema.pattern ? `pattern: ${str(schema.pattern)}` : '', + schema.minLength != null ? `minLength: ${schema.minLength}` : '', + schema.maxLength != null ? `maxLength: ${schema.maxLength}` : '' + ].filter(p => p !== ''); + + return `{ ${props.join(', ')} }`; + } + + if (schema.type === 'number' || schema.type === 'integer') { + const props = [ + `type: ${str(schema.type)}`, + schema.enum ? `enum: [${schema.enum.join(', ')}]` : '', + schema.minimum != null ? `minimum: ${schema.minimum}` : '', + schema.maximum != null ? `maximum: ${schema.maximum}` : '' + ].filter(p => p !== ''); + + return `{ ${props.join(', ')} }`; + } + + if (schema.type === 'boolean') { + const props = [ + 'type: "boolean"', + schema.enum ? `enum: [${schema.enum.join(', ')}]` : '' + ].filter(p => p !== ''); + + return `{ ${props.join(', ')} }`; + } + + if (schema.type === 'array') { + const items = generateReflectionType(schema.items || {}, ctx); + const props = [ + 'type: "array"', + `items: ${items}`, + schema.minItems != null ? `minItems: ${schema.minItems}` : '', + schema.maxItems != null ? `maxItems: ${schema.maxItems}` : '' + ].filter(p => p !== ''); + + return `{ ${props.join(', ')} }`; + } + + if (schema.type === 'object') { + return generateObjectReflectionType(schema, ctx); + } + + if (!schema.type) { + return `{ type: "unknown" }`; + } + + assert.fail('todo generateReflectionType', schema); + throw new Error(); +} + +/** + * Generates reflection type for additional properties. + */ +export function generateAdditionalPropsReflectionType( + props: oas.SchemaObject['additionalProperties'], + ctx: GenerationContext +): string { + if (props === false) { + return 'false'; + } + if ( + props === true || + !props || + (props && typeof props === 'object' && Object.keys(props).length === 0) + ) { + return 'true'; + } + return generateReflectionType(props, ctx); +} + +/** + * Generates reflection type for an object schema. + */ +export function generateObjectReflectionType( + schema: oas.SchemaObject, + ctx: GenerationContext +): string { + const { options } = ctx; + const additionalProps = generateAdditionalPropsReflectionType(schema.additionalProperties, ctx); + + const propertyEntries = Object.keys(schema.properties || {}).map((propertyName: string) => { + const mappedName = options.propertyNameMapper + ? options.propertyNameMapper(propertyName) + : propertyName; + const isRequired = (schema.required || []).indexOf(propertyName) >= 0; + const valueReflection = generateReflectionType( + (schema.properties as Record)[propertyName], + ctx + ); + + const networkNameProp = options.propertyNameMapper ? `, networkName: ${str(propertyName)}` : ''; + + return `${str( + mappedName + )}: { required: ${isRequired}${networkNameProp}, value: ${valueReflection} }`; + }); + + const propsStr = propertyEntries.length > 0 ? `{ ${propertyEntries.join(', ')} }` : '{}'; + + return `{ type: "object", additionalProperties: ${additionalProps}, properties: ${propsStr} }`; +} + +/** + * Creates the fromReflection call for a maker. + */ +export function generateReflectionMaker(key: string, ctx: GenerationContext): string { + const { options } = ctx; + return `oar.fromReflection(${options.nameMapper(key, 'reflection')}.definition)`; +} + +/** + * Generates the named type definition declaration. + */ +export function generateNamedTypeDefinitionDeclaration( + key: string, + schema: oas.SchemaObject | oas.ReferenceObject, + ctx: GenerationContext +): string { + const { options } = ctx; + const isA = inventIsA(key, schema, ctx); + const valueName = options.nameMapper(key, 'value'); + const shapeName = options.nameMapper(key, 'shape'); + const reflectionName = options.nameMapper(key, 'reflection'); + const definition = generateReflectionType(schema, ctx); + + return ts`export const ${reflectionName}: ${fromLib( + 'reflection', + 'NamedTypeDefinition' + )}<${valueName}, ${shapeName}> = { name: ${str( + valueName + )}, definition: ${definition}, maker: make${valueName}, isA: ${isA ?? 'null'} } as any;`; +} + +/** + * Determines the appropriate isA function for a schema. + */ +export function inventIsA( + key: string, + schema: oas.SchemaObject | oas.ReferenceObject, + ctx: GenerationContext +): string | undefined { + if (isReferenceObject(schema)) return undefined; + + if (schema.type === 'object') { + return generateIsA(ctx.options.nameMapper(key, 'value')); + } + if (isScalar(schema)) { + return generateIsAForScalar(key, ctx); + } + return undefined; +} + +/** + * Generates an instanceof-based isA function. + */ +export function generateIsA(type: string): string { + return `(value: any) => value instanceof ${type}`; +} + +/** + * Generates a maker-based isA function for scalar types. + */ +export function generateIsAForScalar(key: string, ctx: GenerationContext): string { + const { options } = ctx; + return `(value: any) => make${options.nameMapper(key, 'value')}(value).isSuccess()`; +} diff --git a/packages/oats/src/codegen/types.ts b/packages/oats/src/codegen/types.ts new file mode 100644 index 00000000..eb7defaf --- /dev/null +++ b/packages/oats/src/codegen/types.ts @@ -0,0 +1,208 @@ +/** + * Type generation functions for converting OpenAPI schemas to TypeScript types. + */ + +import * as oas from 'openapi3-ts'; +import * as assert from 'assert'; +import * as _ from 'lodash'; +import { isReferenceObject, SchemaObject, errorTag } from '../util'; +import { GenerationContext, AdditionalPropertiesIndexSignature } from './context'; +import { + quotedProp, + generateLiteral, + fromLib, + valueClassIndexSignatureKey, + oatsBrandFieldName +} from './helpers'; + +/** + * Generates the additional properties type for index signatures. + */ +export function generateAdditionalPropType( + additional: boolean | oas.SchemaObject['additionalProperties'], + ctx: GenerationContext +): string | undefined { + const { options } = ctx; + + if (additional === false) { + return; + } + if (additional === true || additional == null) { + if ( + options.unknownAdditionalPropertiesIndexSignature === AdditionalPropertiesIndexSignature.omit + ) { + return; + } + return 'unknown'; + } + if (options.emitUndefinedForIndexTypes || options.emitUndefinedForIndexTypes == null) { + return `${generateType(additional, ctx)} | undefined`; + } + return generateType(additional, ctx); +} + +/** + * Generates class members (property declarations) for a value class. + */ +export function generateClassMembers( + properties: oas.SchemaObject['properties'], + required: oas.SchemaObject['required'], + additional: oas.SchemaObject['additionalProperties'], + ctx: GenerationContext +): readonly string[] { + const { options } = ctx; + + const proptypes: string[] = _.map(properties, (value, key) => { + const propName = quotedProp(options.propertyNameMapper ? options.propertyNameMapper(key) : key); + const isRequired = required && required.indexOf(key) >= 0; + const modifier = isRequired ? '!' : '?'; + return `readonly ${propName}${modifier}: ${generateType(value, ctx)};`; + }); + + proptypes.push(generateOatsBrandProperty()); + + const additionalType = generateAdditionalPropType(additional, ctx); + if (additionalType) { + proptypes.push(`readonly [${valueClassIndexSignatureKey}: string]: ${additionalType};`); + } + return proptypes; +} + +/** + * Generates the private brand property for value classes. + */ +export function generateOatsBrandProperty(): string { + return `readonly #${oatsBrandFieldName}!: string;`; +} + +/** + * Generates object type members (property signatures) for type literals. + */ +export function generateObjectMembers( + properties: oas.SchemaObject['properties'], + required: oas.SchemaObject['required'], + additional: oas.SchemaObject['additionalProperties'], + ctx: GenerationContext, + typeMapper: (typeName: string) => string = n => n +): string[] { + const { options } = ctx; + + const proptypes: string[] = _.map(properties, (value, key) => + errorTag(`property '${key}'`, () => { + const propName = quotedProp( + options.propertyNameMapper ? options.propertyNameMapper(key) : key + ); + const isRequired = required && required.indexOf(key) >= 0; + const modifier = isRequired ? '' : '?'; + return `readonly ${propName}${modifier}: ${generateType(value, ctx, typeMapper)};`; + }) + ); + + const additionalType = generateAdditionalPropType(additional, ctx); + if (additionalType) { + proptypes.push(`readonly [key: string]: ${additionalType};`); + } + return proptypes; +} + +/** + * Generates a TypeScript type string from an OpenAPI schema. + * This is the main recursive type generator. + */ +export function generateType( + schema: SchemaObject, + ctx: GenerationContext, + typeMapper: (name: string) => string = n => n +): string { + assert(schema, 'missing schema'); + + if (isReferenceObject(schema)) { + const resolved = ctx.resolveRefToTypeName(schema.$ref, 'value'); + const type = resolved.qualified + ? `${resolved.qualified}.${typeMapper(resolved.member)}` + : typeMapper(resolved.member); + return type; + } + + if (schema.oneOf) { + return schema.oneOf.map(s => generateType(s, ctx, typeMapper)).join(' | '); + } + + if (schema.allOf) { + return schema.allOf.map(s => generateType(s, ctx, typeMapper)).join(' & '); + } + + assert(!schema.anyOf, 'anyOf is not supported'); + + if (schema.nullable) { + return `${generateType({ ...schema, nullable: false }, ctx, typeMapper)} | null`; + } + + if (schema.type === 'object') { + const members = generateObjectMembers( + schema.properties, + schema.required, + schema.additionalProperties, + ctx, + typeMapper + ); + if (members.length === 0) { + return '{}'; + } + return `{ ${members.join(' ')} }`; + } + + if (schema.enum) { + return schema.enum.map(e => generateLiteral(e)).join(' | '); + } + + if (schema.type === 'array') { + const itemType = generateType(schema.items || {}, ctx, typeMapper); + return `ReadonlyArray<${itemType}>`; + } + + if (schema.type === 'string') { + return generateStringType(schema.format); + } + + if (schema.type === 'integer' || schema.type === 'number') { + return 'number'; + } + + if (schema.type === 'boolean') { + return 'boolean'; + } + + // @ts-expect-error schemas really do not have void type. but we do + if (schema.type === 'void') { + return 'void'; + } + + if (schema.type === 'null') { + return 'null'; + } + + if (!schema.type) { + return 'unknown'; + } + + return assert.fail('unknown schema type: ' + schema.type); +} + +/** + * Generates a string type, handling binary format specially. + */ +export function generateStringType(format: string | undefined): string { + if (format === 'binary') { + return fromLib('make', 'Binary'); + } + return 'string'; +} + +/** + * Wraps a type with a brand for scalar types. + */ +export function scalarTypeWithBrand(key: string, type: string, ctx: GenerationContext): string { + const brandName = 'BrandOf' + ctx.options.nameMapper(key, 'value'); + return `${fromLib('BrandedScalar')}<${type}, ${brandName}>`; +} diff --git a/packages/oats/src/generate-server.ts b/packages/oats/src/generate-server.ts index 25d772b6..9205845f 100644 --- a/packages/oats/src/generate-server.ts +++ b/packages/oats/src/generate-server.ts @@ -1,54 +1,37 @@ import * as oas from 'openapi3-ts'; -import * as ts from 'typescript'; import * as assert from 'assert'; import * as oautil from './util'; import { server, client } from '@smartlyio/oats-runtime'; import { NameMapper, UnsupportedFeatureBehaviour } from './util'; +import { ts, quoteProp, str, join } from './template'; -function generateRuntimeImport(runtimeModule: string) { - return ts.factory.createNodeArray([ - ts.factory.createImportDeclaration( - undefined, - ts.factory.createImportClause( - false, - undefined, - ts.factory.createNamespaceImport(ts.factory.createIdentifier('oar')) - ), - ts.factory.createStringLiteral(runtimeModule) - ) - ]); +/** + * Generates an import statement for the runtime module. + */ +function generateRuntimeImport(runtimeModule: string): string { + return ts` + import * as oar from ${str(runtimeModule)}; + `; } -function generateImport(as: string, module: string) { - return ts.factory.createNodeArray([ - ts.factory.createImportDeclaration( - undefined, - ts.factory.createImportClause( - false, - undefined, - ts.factory.createNamespaceImport(ts.factory.createIdentifier(as)) - ), - ts.factory.createStringLiteral(module) - ) - ]); +/** + * Generates a namespace import statement. + */ +function generateImport(as: string, module: string): string { + return ts` + import * as ${as} from ${str(module)}; + `; } -function fromRuntime(name: string) { - return ts.factory.createQualifiedName(ts.factory.createIdentifier('oar'), name); -} - -function fromTypes(name: string) { - return ts.factory.createQualifiedName(ts.factory.createIdentifier('types'), name); -} - -const readonly = [ts.factory.createModifier(ts.SyntaxKind.ReadonlyKeyword)]; - +/** + * Generates a type reference for a server endpoint method. + */ function generateMethod( path: string, method: string, schema: S, opts: Options -) { +): string { if ( (opts?.unsupportedFeatures?.security ?? UnsupportedFeatureBehaviour.reject) === UnsupportedFeatureBehaviour.reject @@ -56,189 +39,131 @@ function generateMethod( assert(!schema.security, 'security not supported'); } - const headers = ts.factory.createTypeReferenceNode( - fromTypes( - opts.nameMapper( - oautil.endpointTypeName(schema, path, method, 'headers'), - opts.shapesAsRequests ? 'shape' : 'value' - ) - ), - [] - ); - const params = ts.factory.createTypeReferenceNode( - fromTypes( - opts.nameMapper( - oautil.endpointTypeName(schema, path, method, 'parameters'), - opts.shapesAsRequests ? 'shape' : 'value' - ) - ), - [] - ); - const query = ts.factory.createTypeReferenceNode( - fromTypes( - opts.nameMapper( - oautil.endpointTypeName(schema, path, method, 'query'), - opts.shapesAsRequests ? 'shape' : 'value' - ) - ), - [] - ); - const body = ts.factory.createTypeReferenceNode( - fromTypes( - opts.nameMapper( - oautil.endpointTypeName(schema, path, method, 'requestBody'), - opts.shapesAsRequests ? 'shape' : 'value' - ) - ), - [] - ); - const response = ts.factory.createTypeReferenceNode( - fromTypes( - opts.nameMapper( - oautil.endpointTypeName(schema, path, method, 'response'), - opts.shapesAsResponses ? 'shape' : 'value' - ) - ), - [] - ); - return ts.factory.createTypeReferenceNode(fromRuntime('server.Endpoint'), [ - headers, - params, - query, - body, - response, - ts.factory.createTypeReferenceNode('RequestContext', undefined) - ]); + const kind = opts.shapesAsRequests ? 'shape' : 'value'; + const responseKind = opts.shapesAsResponses ? 'shape' : 'value'; + + const headers = `types.${opts.nameMapper( + oautil.endpointTypeName(schema, path, method, 'headers'), + kind + )}`; + const params = `types.${opts.nameMapper( + oautil.endpointTypeName(schema, path, method, 'parameters'), + kind + )}`; + const query = `types.${opts.nameMapper( + oautil.endpointTypeName(schema, path, method, 'query'), + kind + )}`; + const body = `types.${opts.nameMapper( + oautil.endpointTypeName(schema, path, method, 'requestBody'), + kind + )}`; + const response = `types.${opts.nameMapper( + oautil.endpointTypeName(schema, path, method, 'response'), + responseKind + )}`; + + return `oar.server.Endpoint<${headers}, ${params}, ${query}, ${body}, ${response}, RequestContext>`; } -function generateEndpoint(path: string, schema: oas.PathItemObject, opts: Options) { +/** + * Generates the type literal for an endpoint path containing method handlers. + */ +function generateEndpoint(path: string, schema: oas.PathItemObject, opts: Options): string { assert(!schema.$ref, '$ref in path not supported'); assert(!schema.parameters, 'parameters in path not supported'); - const signatures: ts.PropertySignature[] = []; - server.supportedMethods.forEach( - method => - oautil.errorTag('in method ' + method.toUpperCase(), () => { - const methodHandler = schema[method]; - if (methodHandler) { - const endpoint = ts.factory.createPropertySignature( - readonly, - ts.factory.createStringLiteral(method), - ts.factory.createToken(ts.SyntaxKind.QuestionToken), - generateMethod(path, method, methodHandler, opts) - ); - signatures.push(endpoint); - } - }), - [] + + const signatures: string[] = []; + server.supportedMethods.forEach(method => + oautil.errorTag('in method ' + method.toUpperCase(), () => { + const methodHandler = schema[method]; + if (methodHandler) { + const methodType = generateMethod(path, method, methodHandler, opts); + signatures.push(`readonly ${str(method)}?: ${methodType};`); + } + }) ); - return ts.factory.createTypeLiteralNode(signatures); + + return ts`{ ${signatures.join(' ')} }`; } +/** + * Generates a type reference for a client endpoint method. + */ function generateClientMethod( opts: Options, path: string, method: string, op: oas.OperationObject -): ts.TypeNode { +): string { if ( (opts?.unsupportedFeatures?.security ?? UnsupportedFeatureBehaviour.reject) === UnsupportedFeatureBehaviour.reject ) { assert(!op.security, 'security not supported'); } - const headers = ts.factory.createTypeReferenceNode( - fromTypes( - opts.nameMapper( - oautil.endpointTypeName(op, path, method, 'headers'), - opts.shapesAsRequests ? 'shape' : 'value' - ) - ), - [] - ); - const query = ts.factory.createTypeReferenceNode( - fromTypes( - opts.nameMapper( - oautil.endpointTypeName(op, path, method, 'query'), - opts.shapesAsRequests ? 'shape' : 'value' - ) - ), - [] - ); - const body = ts.factory.createTypeReferenceNode( - fromTypes( - opts.nameMapper( - oautil.endpointTypeName(op, path, method, 'requestBody'), - opts.shapesAsRequests ? 'shape' : 'value' - ) - ), - [] - ); - const response = ts.factory.createTypeReferenceNode( - fromTypes( - opts.nameMapper( - oautil.endpointTypeName(op, path, method, 'response'), - opts.shapesAsResponses ? 'shape' : 'value' - ) - ), - [] - ); - return ts.factory.createTypeReferenceNode(fromRuntime('client.ClientEndpoint'), [ - headers, - query, - body, - response - ]); + + const kind = opts.shapesAsRequests ? 'shape' : 'value'; + const responseKind = opts.shapesAsResponses ? 'shape' : 'value'; + + const headers = `types.${opts.nameMapper( + oautil.endpointTypeName(op, path, method, 'headers'), + kind + )}`; + const query = `types.${opts.nameMapper( + oautil.endpointTypeName(op, path, method, 'query'), + kind + )}`; + const body = `types.${opts.nameMapper( + oautil.endpointTypeName(op, path, method, 'requestBody'), + kind + )}`; + const response = `types.${opts.nameMapper( + oautil.endpointTypeName(op, path, method, 'response'), + responseKind + )}`; + + return `oar.client.ClientEndpoint<${headers}, ${query}, ${body}, ${response}>`; } -function generateClientTree( - opts: Options, - tree: client.OpTree -): readonly ts.TypeElement[] { - const members = []; +/** + * Recursively generates the client tree type members. + */ +function generateClientTree(opts: Options, tree: client.OpTree): string[] { + const members: string[] = []; + if (tree.param) { - members.push( - ts.factory.createCallSignature( - undefined, - [ - ts.factory.createParameterDeclaration( - undefined, - undefined, - tree.param.name, - undefined, - ts.factory.createUnionTypeNode([ - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword) - ]) - ) - ], - generateClientSpecType(opts, tree.param.tree) - ) - ); + const innerType = generateClientSpecType(opts, tree.param.tree); + members.push(`(${tree.param.name}: string | number): ${innerType};`); } + Object.keys(tree.methods).forEach(method => { - const type: ts.TypeNode = tree.methods[method]; - members.push(ts.factory.createPropertySignature(readonly, method, undefined, type)); + const type = tree.methods[method]; + members.push(`readonly ${method}: ${type};`); }); + Object.keys(tree.parts).forEach(part => { - const pathPart = /[^a-zA-Z_0-9]/.test(part) ? ts.factory.createStringLiteral(part) : part; - members.push( - ts.factory.createPropertySignature( - readonly, - pathPart, - undefined, - generateClientSpecType(opts, tree.parts[part]) - ) - ); + const pathPart = quoteProp(part); + const innerType = generateClientSpecType(opts, tree.parts[part]); + members.push(`readonly ${pathPart}: ${innerType};`); }); + return members; } -function generateClientSpecType(opts: Options, tree: client.OpTree) { - return ts.factory.createTypeLiteralNode(generateClientTree(opts, tree)); +/** + * Generates a type literal for a client spec tree node. + */ +function generateClientSpecType(opts: Options, tree: client.OpTree): string { + const members = generateClientTree(opts, tree); + return ts`{ ${members.join(' ')} }`; } -function generateClientSpec(opts: Options) { - const tree: client.OpTree = Object.keys(opts.oas.paths).reduce((memo, path) => { +/** + * Generates the ClientSpec type alias. + */ +function generateClientSpec(opts: Options): string { + const tree: client.OpTree = Object.keys(opts.oas.paths).reduce((memo, path) => { const endpoint = opts.oas.paths[path]; return server.supportedMethods.reduce((memo, method) => { if (endpoint[method]) { @@ -251,89 +176,72 @@ function generateClientSpec(opts: Options) { } return memo; }, memo); - }, client.emptyTree()); - return ts.factory.createNodeArray([ - ts.factory.createTypeAliasDeclaration( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - 'ClientSpec', - undefined, - ts.factory.createTypeLiteralNode(generateClientTree(opts, tree)) - ) - ]); + }, client.emptyTree()); + + const members = generateClientTree(opts, tree); + return ts` + export type ClientSpec = { + ${members} + }; + `; } -function generateEndpointsType(opts: Options) { +/** + * Generates the EndpointsWithContext and Endpoints type aliases. + */ +function generateEndpointsType(opts: Options): string { const members = Object.keys(opts.oas.paths).map(path => { const endpoint: oas.PathItemObject = opts.oas.paths[path]; return oautil.errorTag('in endpoint ' + path, () => { const type = generateEndpoint(path, endpoint, opts); - return ts.factory.createPropertySignature( - readonly, - ts.factory.createStringLiteral(path), - ts.factory.createToken(ts.SyntaxKind.QuestionToken), - type - ); + return `readonly ${str(path)}?: ${type};`; }); }); - return ts.factory.createNodeArray([ - ts.factory.createTypeAliasDeclaration( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - 'EndpointsWithContext', - [ts.factory.createTypeParameterDeclaration([], 'RequestContext')], - ts.factory.createTypeLiteralNode(members) - ), - ts.factory.createTypeAliasDeclaration( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - 'Endpoints', - undefined, - ts.factory.createTypeReferenceNode('EndpointsWithContext', [ - ts.factory.createTypeReferenceNode('void', undefined) - ]) - ) - ]); + + return ts` + export type EndpointsWithContext = { + ${members} + }; + export type Endpoints = EndpointsWithContext; + `; } -function makeMaker(type: string, opts: Options): ts.Expression { - return ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('types'), - 'make' + opts.nameMapper(type, 'value') - ); + +/** + * Generates a maker property access expression string. + */ +function makeMaker(type: string, opts: Options): string { + return `types.make${opts.nameMapper(type, 'value')}`; } +/** + * Generates a handler object for a single endpoint. + */ function generateMaker( servers: string[], opts: Options, path: string, method: string, object: oas.OperationObject -): ts.Expression { +): string { if (object.servers) { servers = object.servers.map(server => server.url); } + const headers = makeMaker(oautil.endpointTypeName(object, path, method, 'headers'), opts); const params = makeMaker(oautil.endpointTypeName(object, path, method, 'parameters'), opts); const query = makeMaker(oautil.endpointTypeName(object, path, method, 'query'), opts); const body = makeMaker(oautil.endpointTypeName(object, path, method, 'requestBody'), opts); const response = makeMaker(oautil.endpointTypeName(object, path, method, 'response'), opts); - return ts.factory.createObjectLiteralExpression( - [ - ts.factory.createPropertyAssignment('path', ts.factory.createStringLiteral(path)), - ts.factory.createPropertyAssignment('method', ts.factory.createStringLiteral(method)), - ts.factory.createPropertyAssignment( - 'servers', - ts.factory.createArrayLiteralExpression( - servers.map(value => ts.factory.createStringLiteral(value)) - ) - ), - ts.factory.createPropertyAssignment('headers', headers), - ts.factory.createPropertyAssignment('query', query), - ts.factory.createPropertyAssignment('body', body), - ts.factory.createPropertyAssignment('params', params), - ts.factory.createPropertyAssignment('response', response) - ], - true - ); + const serversStr = servers.map(s => str(s)).join(', '); + + return ts`{ path: ${str(path)}, method: ${str( + method + )}, servers: [${serversStr}], headers: ${headers}, query: ${query}, body: ${body}, params: ${params}, response: ${response} }`; } +/** + * Flattens paths into an array of {path, method, object} tuples. + */ function flattenPathAndMethod(paths: oas.PathsObject) { const flattened = []; for (const path of Object.keys(paths)) { @@ -347,335 +255,113 @@ function flattenPathAndMethod(paths: oas.PathsObject) { return flattened; } -function generateHandler(opts: Options) { +/** + * Generates the endpointHandlers array declaration. + */ +function generateHandler(opts: Options): string { const schema = opts.oas; const servers = (schema?.servers ?? []).map(server => server.url); - return ts.factory.createVariableStatement( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - 'endpointHandlers', - undefined, - ts.factory.createArrayTypeNode( - ts.factory.createTypeReferenceNode( - ts.factory.createQualifiedName(ts.factory.createIdentifier('oar'), 'server.Handler'), - [] - ) - ), - ts.factory.createArrayLiteralExpression( - flattenPathAndMethod(schema.paths).map(p => - generateMaker(servers, opts, p.path, p.method, p.object) - ) - ) - ) - ], - ts.NodeFlags.Const - ) + const handlers = flattenPathAndMethod(schema.paths).map(p => + generateMaker(servers, opts, p.path, p.method, p.object) ); + + return ts` + export const endpointHandlers: oar.server.Handler[] = [${handlers.join(', ')}]; + `; } -function generateRouterJSDoc() { - return ts.factory.createJSDocComment(undefined, [ - ts.factory.createJSDocUnknownTag( - ts.factory.createIdentifier('deprecated'), - 'Use `createRouter()` instead. ' + - 'It supports "number", "integer", "boolean" and "array" types in query parameters ' + - 'and numeric types in path parameters.' - ) - ]); +/** + * Generates a JSDoc deprecation comment for the router. + */ +function generateRouterJSDoc(): string { + return ts` + /** + * @deprecated Use \`createRouter()\` instead. It supports "number", "integer", "boolean" and "array" types in query parameters and numeric types in path parameters. + */ + `; } -export function generateRouter() { - return ts.factory.createVariableStatement( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - 'router', - undefined, - ts.factory.createTypeReferenceNode( - ts.factory.createQualifiedName( - ts.factory.createIdentifier('oar'), - 'server.HandlerFactory' - ), - [ts.factory.createTypeReferenceNode('Endpoints', [])] - ), - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('oar'), - 'server.createHandlerFactory' - ), - undefined, - [ts.factory.createIdentifier('endpointHandlers')] - ) - ) - ], - ts.NodeFlags.Const - ) - ); +/** + * Generates the router variable declaration. + */ +export function generateRouter(): string { + return ts` + export const router: oar.server.HandlerFactory = oar.server.createHandlerFactory(endpointHandlers); + `; } -export function generateCreateRouter() { - return ts.factory.createFunctionDeclaration( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - undefined, - 'createRouter', - [ts.factory.createTypeParameterDeclaration([], 'TRequestContext')], - [ - ts.factory.createParameterDeclaration( - undefined, - undefined, - 'handlerOptions', - undefined, - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('oar.server.HandlerOptions') - ), - ts.factory.createObjectLiteralExpression() - ) - ], - ts.factory.createTypeReferenceNode(ts.factory.createIdentifier('oar.server.HandlerFactory'), [ - ts.factory.createTypeReferenceNode('EndpointsWithContext', [ - ts.factory.createTypeReferenceNode('TRequestContext') - ]) - ]), - ts.factory.createBlock( - [ - ts.factory.createVariableStatement( - undefined, - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - ts.factory.createObjectBindingPattern([ - ts.factory.createBindingElement( - undefined, - 'validationOptions', - ts.factory.createObjectBindingPattern([ - ts.factory.createBindingElement( - undefined, - 'query', - ts.factory.createObjectBindingPattern([ - ts.factory.createBindingElement( - undefined, - 'parseBooleanStrings', - 'queryParseBooleanStrings', - ts.factory.createTrue() - ), - ts.factory.createBindingElement( - undefined, - 'parseNumericStrings', - 'queryParseNumericStrings', - ts.factory.createTrue() - ), - ts.factory.createBindingElement( - undefined, - 'allowConvertForArrayType', - 'queryAllowConvertForArrayType', - ts.factory.createTrue() - ), - ts.factory.createBindingElement( - ts.factory.createToken(ts.SyntaxKind.DotDotDotToken), - undefined, - 'queryRest' - ) - ]), - ts.factory.createObjectLiteralExpression() - ), - ts.factory.createBindingElement( - undefined, - 'params', - ts.factory.createObjectBindingPattern([ - ts.factory.createBindingElement( - undefined, - 'parseNumericStrings', - 'paramsParseNumericStrings', - ts.factory.createTrue() - ), - ts.factory.createBindingElement( - ts.factory.createToken(ts.SyntaxKind.DotDotDotToken), - undefined, - 'paramsRest' - ) - ]), - ts.factory.createObjectLiteralExpression() - ), - ts.factory.createBindingElement( - undefined, - 'body', - ts.factory.createObjectBindingPattern([ - ts.factory.createBindingElement( - undefined, - 'unknownField', - 'bodyUnknownField', - ts.factory.createStringLiteral('fail') - ), - ts.factory.createBindingElement( - ts.factory.createToken(ts.SyntaxKind.DotDotDotToken), - undefined, - 'bodyRest' - ) - ]), - ts.factory.createObjectLiteralExpression() - ), - ts.factory.createBindingElement( - ts.factory.createToken(ts.SyntaxKind.DotDotDotToken), - undefined, - 'validationOptionsRest' - ) - ]), - ts.factory.createObjectLiteralExpression() - ), - ts.factory.createBindingElement( - ts.factory.createToken(ts.SyntaxKind.DotDotDotToken), - undefined, - 'handlerOptionsRest' - ) - ]), - undefined, - undefined, - ts.factory.createIdentifier('handlerOptions') - ) - ], - ts.NodeFlags.Const - ) - ), - ts.factory.createReturnStatement( - ts.factory.createCallExpression( - ts.factory.createIdentifier('oar.server.createHandlerFactory'), - undefined, - [ - ts.factory.createIdentifier('endpointHandlers'), - ts.factory.createObjectLiteralExpression([ - ts.factory.createSpreadAssignment( - ts.factory.createIdentifier('handlerOptionsRest') - ), - ts.factory.createPropertyAssignment( - 'validationOptions', - ts.factory.createObjectLiteralExpression([ - ts.factory.createSpreadAssignment( - ts.factory.createIdentifier('validationOptionsRest') - ), - ts.factory.createPropertyAssignment( - 'query', - ts.factory.createObjectLiteralExpression([ - ts.factory.createSpreadAssignment(ts.factory.createIdentifier('queryRest')), - ts.factory.createPropertyAssignment( - 'parseBooleanStrings', - ts.factory.createIdentifier('queryParseBooleanStrings') - ), - ts.factory.createPropertyAssignment( - 'parseNumericStrings', - ts.factory.createIdentifier('queryParseNumericStrings') - ), - ts.factory.createPropertyAssignment( - 'allowConvertForArrayType', - ts.factory.createIdentifier('queryAllowConvertForArrayType') - ) - ]) - ), - ts.factory.createPropertyAssignment( - 'params', - ts.factory.createObjectLiteralExpression([ - ts.factory.createSpreadAssignment( - ts.factory.createIdentifier('paramsRest') - ), - ts.factory.createPropertyAssignment( - 'parseNumericStrings', - ts.factory.createIdentifier('paramsParseNumericStrings') - ) - ]) - ), - ts.factory.createPropertyAssignment( - 'body', - ts.factory.createObjectLiteralExpression([ - ts.factory.createSpreadAssignment(ts.factory.createIdentifier('bodyRest')), - ts.factory.createPropertyAssignment( - 'unknownField', - ts.factory.createIdentifier('bodyUnknownField') - ) - ]) - ) - ]) - ) - ]) - ] - ) - ) - ], - true - ) - ); + +/** + * Generates the createRouter function declaration. + */ +export function generateCreateRouter(): string { + return ts` + export function createRouter( + handlerOptions: oar.server.HandlerOptions = {} + ): oar.server.HandlerFactory> { + const { + validationOptions: { + query: { + parseBooleanStrings: queryParseBooleanStrings = true, + parseNumericStrings: queryParseNumericStrings = true, + allowConvertForArrayType: queryAllowConvertForArrayType = true, + ...queryRest + } = {}, + params: { + parseNumericStrings: paramsParseNumericStrings = true, + ...paramsRest + } = {}, + body: { + unknownField: bodyUnknownField = 'fail', + ...bodyRest + } = {}, + ...validationOptionsRest + } = {}, + ...handlerOptionsRest + } = handlerOptions; + return oar.server.createHandlerFactory(endpointHandlers, { + ...handlerOptionsRest, + validationOptions: { + ...validationOptionsRest, + query: { + ...queryRest, + parseBooleanStrings: queryParseBooleanStrings, + parseNumericStrings: queryParseNumericStrings, + allowConvertForArrayType: queryAllowConvertForArrayType + }, + params: { + ...paramsRest, + parseNumericStrings: paramsParseNumericStrings + }, + body: { + ...bodyRest, + unknownField: bodyUnknownField + } + } + }); + } + `; } -export function generateClient() { - return ts.factory.createVariableStatement( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - 'client', - undefined, - ts.factory.createTypeReferenceNode( - ts.factory.createQualifiedName( - ts.factory.createIdentifier('oar'), - 'client.ClientFactory' - ), - [ts.factory.createTypeReferenceNode('ClientSpec', [])] - ), - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('oar'), - 'client.createClientFactory' - ), - undefined, - [ts.factory.createIdentifier('endpointHandlers')] - ) - ) - ], - ts.NodeFlags.Const - ) - ); +/** + * Generates the client variable declaration. + */ +export function generateClient(): string { + return ts` + export const client: oar.client.ClientFactory = oar.client.createClientFactory(endpointHandlers); + `; } -export function generateCreateClient() { - return ts.factory.createFunctionDeclaration( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - undefined, - 'createClient', - [], - [ - ts.factory.createParameterDeclaration( - undefined, - undefined, - 'handlerOptions', - undefined, - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('oar.server.HandlerOptions') - ), - ts.factory.createObjectLiteralExpression() - ) - ], - ts.factory.createTypeReferenceNode(ts.factory.createIdentifier('oar.client.ClientFactory'), [ - ts.factory.createTypeReferenceNode('ClientSpec', []) - ]), - ts.factory.createBlock( - [ - ts.factory.createReturnStatement( - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('oar'), - 'client.createClientFactory' - ), - undefined, - [ - ts.factory.createIdentifier('endpointHandlers'), - ts.factory.createIdentifier('handlerOptions') - ] - ) - ) - ], - false - ) - ); +/** + * Generates the createClient function declaration. + */ +export function generateCreateClient(): string { + return ts` + export function createClient( + handlerOptions: oar.server.HandlerOptions = {} + ): oar.client.ClientFactory { + return oar.client.createClientFactory(endpointHandlers, handlerOptions); + } + `; } interface Options { @@ -690,44 +376,25 @@ interface Options { nameMapper: NameMapper; } -export function run(opts: Options) { +/** + * Main entry point: generates the complete server/client module from an OpenAPI spec. + */ +export function run(opts: Options): string { const runtimeModule = opts.runtimePath; const typemodule = opts.typePath; - const runtime = generateRuntimeImport(runtimeModule); - const types = generateImport('types', typemodule); - const endpoints = generateEndpointsType(opts); - const clientSpec = generateClientSpec(opts); - const handler = generateHandler(opts); - const routerJSDoc = generateRouterJSDoc(); - const router = generateRouter(); - const createRouter = generateCreateRouter(); - const client = generateClient(); - const createClient = generateCreateClient(); - - const sourceFile: ts.SourceFile = ts.createSourceFile( - 'test.ts', - '', - ts.ScriptTarget.ES2015, - true, - ts.ScriptKind.TS - ); - return ts - .createPrinter() - .printList( - ts.ListFormat.MultiLine, - ts.factory.createNodeArray([ - ...runtime, - ...types, - ...endpoints, - ...clientSpec, - handler, - routerJSDoc, - router, - createRouter, - client, - createClient - ]), - sourceFile - ); + const parts = [ + generateRuntimeImport(runtimeModule), + generateImport('types', typemodule), + generateEndpointsType(opts), + generateClientSpec(opts), + generateHandler(opts), + generateRouterJSDoc(), + generateRouter(), + generateCreateRouter(), + generateClient(), + generateCreateClient() + ]; + + return join(parts, '\n'); } diff --git a/packages/oats/src/generate-types.ts b/packages/oats/src/generate-types.ts index 58b73df2..29f00062 100644 --- a/packages/oats/src/generate-types.ts +++ b/packages/oats/src/generate-types.ts @@ -1,25 +1,31 @@ -import { debuglog } from 'node:util'; +/** + * Type generation entry point for OpenAPI schemas. + */ + import * as oas from 'openapi3-ts'; -import * as ts from 'typescript'; -import * as _ from 'lodash'; -import * as assert from 'assert'; -import * as oautil from './util'; -import { isReferenceObject, NameKind, UnsupportedFeatureBehaviour } from './util'; import * as path from 'path'; -import { resolvedStatusCodes } from './status-codes'; -import { buildMethod } from './builder'; - -const info = debuglog('oats'); - -const valueClassIndexSignatureKey = 'instanceIndexSignatureKey'; -const scalarTypes = ['string', 'integer', 'number', 'boolean']; -interface ImportDefinition { - importAs: string; - importFile: string; -} - -// bit of a lie here really -const voidSchema: oas.SchemaObject = { type: 'void' as any }; +import { + errorTag, + isReferenceObject, + NameKind, + NameMapper, + UnsupportedFeatureBehaviour +} from './util'; +import { ts } from './template'; +import { + createContext, + GenerationState, + AdditionalPropertiesIndexSignature, + runtime, + addIndexSignatureIgnores, + resolveModule, + generateTopLevelType, + generateQueryTypes, + generateContentSchemaType +} from './codegen'; + +// Re-export types that are part of the public API +export { AdditionalPropertiesIndexSignature }; export type Resolve = ( ref: string, @@ -42,1462 +48,156 @@ export interface Options { unsupportedFeatures?: { security?: UnsupportedFeatureBehaviour; }; - /** if true emit union type with undefined for additionalProperties. Default is *true*. - * Note! Likely the default will be set to false later. Now like this to avoid - * breaking typechecking for existing projects. - * - * Typescript can be {@link https://www.typescriptlang.org/tsconfig#noUncheckedIndexedAccess configured } to consider - * index signature accesses to have implicit undefined type so we can let the caller decide on the level of safety they want. - * */ emitUndefinedForIndexTypes?: boolean; - /** If 'AdditionalPropertiesIndexSignature.emit' or not set emit - * `[key: string]: unknown` - * for objects with `additionalProperties: true` or no additionalProperties set */ unknownAdditionalPropertiesIndexSignature?: AdditionalPropertiesIndexSignature; - /** property name mapper for object properties - * ex. to map 'snake_case' property in network format to property 'camelCase' usable in ts code provide mapper - * > propertyNameMapper: (p) => p === 'snake_case ? 'camelCase' : p - * */ propertyNameMapper?: (openapiPropertyName: string) => string; - nameMapper: oautil.NameMapper; + nameMapper: NameMapper; } -export function deprecated(condition: any, message: string) { +export function deprecated(condition: unknown, message: string): void { if (condition) { // eslint-disable-next-line no-console console.log('deprecation warning: ' + message); } } -const oatsBrandFieldName = '__oats_value_class_brand_tag'; -const runtime = 'oar'; -const runtimeLibrary = ts.factory.createIdentifier(runtime); -const readonly = [ts.factory.createModifier(ts.SyntaxKind.ReadonlyKeyword)]; -// list of files for which we have an action to generate it already -// to prevent calling an action to generate it again and causing maybe some race conditions + +// Track files being generated to prevent race conditions const generatedFiles: Set = new Set(); -// query to figure out whether the file is being already generated function isGenerating(file: string): boolean { return generatedFiles.has(file); } -export function run(options: Options) { - options.targetFile = './' + path.normalize(options.targetFile); - if (isGenerating(options.targetFile) && !options.forceGenerateTypes) { - return; - } - generatedFiles.add(options.targetFile); - - const state = { - cwd: path.dirname(options.sourceFile), - imports: {} as Record, - actions: [] as Array<() => Promise> - }; - const builtins = generateBuiltins(); - const types = generateComponents(options); - const queryTypes = generateQueryTypes(options); - - const externals = generateExternals( - Object.entries(state.imports).map(([importAs, importFile]) => ({ - importAs, - importFile: resolveModule(options.targetFile, importFile) - })) - ); - state.actions.forEach(action => action()); - - const sourceFile: ts.SourceFile = ts.createSourceFile( - 'test.ts', - '', - ts.ScriptTarget.ES2015, - true, - ts.ScriptKind.TS - ); - const src = ts - .createPrinter() - .printList( - ts.ListFormat.MultiLine, - ts.factory.createNodeArray([...builtins, ...externals, ...types, ...queryTypes]), - sourceFile - ); - const finished = addIndexSignatureIgnores(src); - return finished; - - function generateOatsBrandProperty() { - return ts.factory.createPropertyDeclaration( - [ts.factory.createToken(ts.SyntaxKind.ReadonlyKeyword)], - ts.factory.createPrivateIdentifier(`#${oatsBrandFieldName}`), - ts.factory.createToken(ts.SyntaxKind.ExclamationToken), - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - undefined - ); - } - - function generateAdditionalPropType( - additional: boolean | oas.SchemaObject['additionalProperties'] - ) { - if (additional === false) { - return; - } - if (additional === true || additional == null) { - if ( - options.unknownAdditionalPropertiesIndexSignature === - AdditionalPropertiesIndexSignature.omit - ) { - return; - } - return ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword); - } - if (options.emitUndefinedForIndexTypes || options.emitUndefinedForIndexTypes == null) { - return ts.factory.createUnionTypeNode([ - generateType(additional), - ts.factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword) - ]); - } - return generateType(additional); - } - - function generateClassMembers( - properties: oas.SchemaObject['properties'], - required: oas.SchemaObject['required'], - additional: oas.SchemaObject['additionalProperties'] - ): readonly ts.ClassElement[] { - const proptypes = _.map(properties, (value, key) => { - return ts.factory.createPropertyDeclaration( - [ts.factory.createToken(ts.SyntaxKind.ReadonlyKeyword)], - quotedProp(options.propertyNameMapper ? options.propertyNameMapper(key) : key), - required && required.indexOf(key) >= 0 - ? ts.factory.createToken(ts.SyntaxKind.ExclamationToken) - : ts.factory.createToken(ts.SyntaxKind.QuestionToken), - generateType(value), - undefined - ); - }); - - proptypes.push(generateOatsBrandProperty()); - - const additionalType = generateAdditionalPropType(additional); - if (additionalType) { - proptypes.push( - ts.factory.createIndexSignature( - [ts.factory.createToken(ts.SyntaxKind.ReadonlyKeyword)], - [ - ts.factory.createParameterDeclaration( - undefined, - undefined, - valueClassIndexSignatureKey, - undefined, - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) - ) - ], - additionalType - ) as any - ); - } - return proptypes; - } - - function generateObjectMembers( - properties: oas.SchemaObject['properties'], - required: oas.SchemaObject['required'], - additional: oas.SchemaObject['additionalProperties'], - typeMapper: (typeName: string) => string - ): ts.TypeElement[] { - const proptypes = _.map(properties, (value, key) => - oautil.errorTag(`property '${key}'`, () => - ts.factory.createPropertySignature( - readonly, - quotedProp(options.propertyNameMapper ? options.propertyNameMapper(key) : key), - required && required.indexOf(key) >= 0 - ? undefined - : ts.factory.createToken(ts.SyntaxKind.QuestionToken), - generateType(value, typeMapper) - ) - ) - ); - const additionalType = generateAdditionalPropType(additional); - if (additionalType) { - proptypes.push( - ts.factory.createIndexSignature( - readonly, - [ - ts.factory.createParameterDeclaration( - undefined, - undefined, - 'key', - undefined, - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) - ) - ], - additionalType - ) as any - ); - } - return proptypes; - } +/** + * Generates an import declaration for external modules. + */ +function generateExternalImport(external: { importAs: string; importFile: string }): string { + return `import * as ${external.importAs} from ${JSON.stringify(external.importFile)};`; +} - function generateQueryType( - op: string, - paramSchema: undefined | ReadonlyArray, - oasSchema: oas.OpenAPIObject - ) { - const noQueryParams = { type: 'object' as const, additionalProperties: false }; - if (!paramSchema) { - return generateTopLevelType(op, noQueryParams); - } - const schema = oautil.deref(paramSchema, oasSchema); - const queryParams = schema - .map(schema => oautil.deref(schema, oasSchema)) - .filter(schema => schema.in === 'query'); - if (queryParams.length === 0) { - return generateTopLevelType(op, noQueryParams); - } - if ( - queryParams.some( - param => - !!param.explode && - (isReferenceObject(param.schema) || - param.schema?.type === 'object' || - param.schema?.allOf || - param.schema?.oneOf || - param.schema?.anyOf) - ) - ) { - assert( - queryParams.length === 1, - 'only one query parameter is supported when the query parameter schema is a reference, object or compound' - ); - const param = queryParams[0]; - return generateTopLevelType(op, param.schema || {}); - } - const jointSchema: oas.SchemaObject = { - type: 'object', - additionalProperties: false, - required: queryParams.filter(param => param.required).map(param => param.name), - properties: queryParams.reduce((memo: any, param) => { - memo[param.name] = param.schema; - return memo; - }, {}) - }; - return generateTopLevelType(op, jointSchema); - } +/** + * Generates builtin imports and types. + */ +function generateBuiltins(options: Options): string[] { + return [ + `import * as ${runtime} from ${JSON.stringify(options.runtimeModule)};`, + ts` + type InternalUnsafeConstructorOption = { + unSafeSet: true; + }; + ` + ]; +} - function generateParameterType( - type: 'path' | 'header', - op: string, - paramSchema: undefined | ReadonlyArray, - oasSchema: oas.OpenAPIObject, - normalize = (name: string) => name - ) { - const empty = generateTopLevelType(op, voidSchema); - if (!paramSchema) { - return empty; - } - const schema = oautil.deref(paramSchema, oasSchema); - const pathParams = schema - .map(schema => oautil.deref(schema, oasSchema)) - .filter(schema => schema.in === type); - if (pathParams.length === 0) { - return empty; - } - const required: string[] = []; - pathParams.map(paramOrRef => { - const param = oautil.deref(paramOrRef, oasSchema); - if (param.required) { - required.push(normalize(param.name)); - } - }); - const jointSchema: oas.SchemaObject = { - type: 'object', - additionalProperties: false, - required, - properties: pathParams.reduce((memo: any, param) => { - memo[normalize(param.name)] = param.schema; - return memo; - }, {}) - }; - return generateTopLevelType(op, jointSchema); - } +/** + * Generates component schemas from the OpenAPI spec. + */ +function generateComponentSchemas(ctx: ReturnType): string[] { + const { options } = ctx; + const schemas = options.oas.components?.schemas; - function generateContentSchemaType(content: oas.ContentObject) { - const contentTypeSchemas = Object.keys(content).map(contentType => - oautil.errorTag(`contentType '${contentType}'`, () => { - const mediaObject: oas.MediaTypeObject = content[contentType]; - const schema: oas.SchemaObject = { - type: 'object', - properties: { - contentType: { - type: 'string', - enum: [contentType] - }, - value: mediaObject.schema || assert.fail('missing schema') - }, - required: ['contentType', 'value'], - additionalProperties: false - }; - return schema; - }) - ); - return { - oneOf: contentTypeSchemas - }; + if (!schemas) { + return []; } - function generateHeadersSchemaType(headers: oas.HeadersObject): oas.SchemaObject { - const required: string[] = []; - const properties = Object.entries(headers).reduce((memo, [headerName, headerObject]) => { - if (oautil.isReferenceObject(headerObject)) { - required.push(headerName); - return { ...memo, [headerName]: headerObject }; - } else if (headerObject.schema) { - if (headerObject.required) required.push(headerName); - return { ...memo, [headerName]: headerObject.schema }; - } - return memo; - }, {} as { [key: string]: oas.SchemaObject | oas.ReferenceObject }); - return { - type: 'object', - properties, - required, - additionalProperties: { - type: 'string' - } - }; - } + const result: string[] = []; + Object.keys(schemas).forEach(key => { + const schema = schemas[key]; + const types = generateTopLevelType(key, schema, ctx); + result.push(...types); + }); - function generateRequestBodyType( - op: string, - requestBody: undefined | oas.ReferenceObject | oas.RequestBodyObject - ) { - if (requestBody == null) { - return generateTopLevelType(op, voidSchema); - } - if (oautil.isReferenceObject(requestBody)) { - return generateTopLevelType(op, { $ref: requestBody.$ref }); - } - // requestBody is not required by default https://swagger.io/docs/specification/describing-request-body/ - if (requestBody.required === true) { - return generateTopLevelType(op, generateContentSchemaType(requestBody.content)); - } - return generateTopLevelType(op, { - oneOf: [generateContentSchemaType(requestBody.content), voidSchema] - }); - } + return result; +} - function generateResponseType(opts: Options, op: string, responses: oas.ResponsesObject) { - if (!responses) { - return assert.fail('missing responses'); - } - const statusesByCode = resolvedStatusCodes(Object.keys(responses)); - const responseSchemas: oas.SchemaObject[] = []; - Object.keys(responses).map(status => { - const response: oas.ReferenceObject | oas.ResponseObject = responses[status]; - const statuses = (statusesByCode.get(status) || []).filter(opts.emitStatusCode); - if (statuses.length > 0) { - const schema: oas.SchemaObject = { - type: 'object', - properties: { - status: { - type: 'integer', - enum: statuses - }, - value: oautil.isReferenceObject(response) - ? { $ref: response.$ref } - : generateContentSchemaType( - response.content || { - oatsNoContent: { - schema: { - type: 'null' - } - } - } - ), - headers: { - type: 'object', - additionalProperties: { type: 'string' } - } - }, - required: ['status', 'value', 'headers'], - additionalProperties: false - }; - if (!oautil.isReferenceObject(response) && response.headers) { - schema.properties!.headers = generateHeadersSchemaType(response.headers); +/** + * Generates component request bodies and responses. + */ +function generateComponentRequestsAndResponses( + components: + | { [key: string]: oas.ResponseObject | oas.RequestBodyObject | oas.ReferenceObject } + | undefined, + ctx: ReturnType +): string[] { + const result: string[] = []; + + if (components) { + Object.keys(components).forEach(key => { + const component = components[key]; + let schema: oas.SchemaObject | oas.ReferenceObject; + + if (isReferenceObject(component)) { + schema = { $ref: component.$ref }; + } else { + const content = (component as oas.ResponseObject | oas.RequestBodyObject).content; + if (!content) { + throw new Error('missing content'); } - responseSchemas.push(schema); + schema = generateContentSchemaType(content); } - }); - if (responseSchemas.length === 0) { - return generateTopLevelType(op, voidSchema); - } - return generateTopLevelType(op, { - oneOf: responseSchemas - }); - } - function generateQueryTypes(opts: Options): ts.NodeArray { - const response: ts.Node[] = []; - const schema = opts.oas; - Object.keys(schema.paths).forEach(path => { - Object.keys(schema.paths[path]).forEach(method => { - const endpoint: oas.OperationObject = schema.paths[path][method]; - oautil.errorTag(`in ${method.toUpperCase()} ${path} query`, () => - response.push( - ...generateQueryType( - oautil.endpointTypeName(endpoint, path, method, 'query'), - endpoint.parameters, - schema - ) - ) - ); - oautil.errorTag(`in ${method.toUpperCase()} ${path} header`, () => - response.push( - ...generateParameterType( - 'header', - oautil.endpointTypeName(endpoint, path, method, 'headers'), - endpoint.parameters, - schema, - name => name.toLowerCase() - ) - ) - ); - oautil.errorTag(`in ${method.toUpperCase()} ${path} parameters`, () => - response.push( - ...generateParameterType( - 'path', - oautil.endpointTypeName(endpoint, path, method, 'parameters'), - endpoint.parameters, - schema - ) - ) - ); - oautil.errorTag(`in ${method.toUpperCase()} ${path} requestBody`, () => - response.push( - ...generateRequestBodyType( - oautil.endpointTypeName(endpoint, path, method, 'requestBody'), - endpoint.requestBody - ) - ) - ); - oautil.errorTag(`in ${method.toUpperCase()} ${path} response`, () => - response.push( - ...generateResponseType( - opts, - oautil.endpointTypeName(endpoint, path, method, 'response'), - endpoint.responses - ) - ) - ); - }); + result.push(...generateTopLevelType(key, schema, ctx)); }); - return ts.factory.createNodeArray(response); - } - - function generateLiteral(e: any): ts.LiteralTypeNode['literal'] { - const type = typeof e; - if (e === true) return ts.factory.createTrue(); - if (e === false) return ts.factory.createFalse(); - if (e === null) return ts.factory.createNull(); - if (type === 'string') return ts.factory.createStringLiteral(e); - if (type === 'bigint') return ts.factory.createBigIntLiteral(e); - if (type === 'number') return generateNumericLiteral(e); - throw new Error(`unsupported enum value: "${e}"`); - } - function generateNumericLiteral(value: number | string): ts.LiteralTypeNode['literal'] { - value = Number(value); - if (value < 0) { - return ts.factory.createPrefixUnaryExpression( - ts.SyntaxKind.MinusToken, - ts.factory.createNumericLiteral(Math.abs(value)) - ); - } - return ts.factory.createNumericLiteral(value); - } - - function generateType( - schema: oautil.SchemaObject, - typeMapper: (name: string) => string = n => n - ): ts.TypeNode { - assert(schema, 'missing schema'); - if (oautil.isReferenceObject(schema)) { - const resolved = resolveRefToTypeName(schema.$ref, 'value'); - const type = resolved.qualified - ? ts.factory.createQualifiedName(resolved.qualified, typeMapper(resolved.member)) - : typeMapper(resolved.member); - return ts.factory.createTypeReferenceNode(type, undefined); - } - if (schema.oneOf) { - return ts.factory.createUnionTypeNode( - schema.oneOf.map(schema => generateType(schema, typeMapper)) - ); - } - - if (schema.allOf) { - return ts.factory.createIntersectionTypeNode( - schema.allOf.map(schema => generateType(schema, typeMapper)) - ); - } - - assert(!schema.anyOf, 'anyOf is not supported'); - - if (schema.nullable) { - return ts.factory.createUnionTypeNode([ - generateType({ ...schema, nullable: false }, typeMapper), - ts.factory.createLiteralTypeNode(ts.factory.createNull()) - ]); - } - - if (schema.type === 'object') { - return ts.factory.createTypeLiteralNode( - generateObjectMembers( - schema.properties, - schema.required, - schema.additionalProperties, - typeMapper - ) - ); - } - - if (schema.enum) { - return ts.factory.createUnionTypeNode( - schema.enum.map(e => { - return ts.factory.createLiteralTypeNode(generateLiteral(e)); - }) - ); - } - - if (schema.type === 'array') { - const itemType = generateType(schema.items || {}, typeMapper); - return ts.factory.createTypeReferenceNode('ReadonlyArray', [itemType]); - } - - if (schema.type === 'string') { - return generateStringType(schema.format); - } - if (schema.type === 'integer' || schema.type === 'number') { - return ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword); - } - if (schema.type === 'boolean') { - return ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword); - } - // @ts-expect-error schemas really do not have void type. but we do - if (schema.type === 'void') { - return ts.factory.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword); - } - if (schema.type === 'null') { - return ts.factory.createLiteralTypeNode(ts.factory.createNull()); - } - if (!schema.type) { - return ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword); - } - return assert.fail('unknown schema type: ' + schema.type); - } - - function generateStringType(format: string | undefined) { - if (format === 'binary') { - return ts.factory.createTypeReferenceNode(fromLib('make', 'Binary'), []); - } - return ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); - } - - function generateReflectionProperty(key: string) { - return ts.factory.createPropertyDeclaration( - [ - ts.factory.createModifier(ts.SyntaxKind.PublicKeyword), - ts.factory.createModifier(ts.SyntaxKind.StaticKeyword) - ], - ts.factory.createIdentifier('reflection'), - undefined, - ts.factory.createTypeReferenceNode(fromLib('reflection', 'NamedTypeDefinitionDeferred'), [ - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier(options.nameMapper(key, 'value')), - [] - ) - ]), - ts.factory.createArrowFunction( - undefined, - undefined, - [], - undefined, - ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - ts.factory.createBlock( - [ - ts.factory.createReturnStatement( - ts.factory.createIdentifier(options.nameMapper(key, 'reflection')) - ) - ], - false - ) - ) - ); - } - - function generateClassConstructor(key: string) { - return ts.factory.createMethodDeclaration( - [ts.factory.createModifier(ts.SyntaxKind.PublicKeyword)], - undefined, - 'constructor', - undefined, - undefined, - [ - ts.factory.createParameterDeclaration( - undefined, - undefined, - 'value', - undefined, - ts.factory.createTypeReferenceNode(options.nameMapper(key, 'shape'), []) - ), - ts.factory.createParameterDeclaration( - undefined, - undefined, - 'opts', - ts.factory.createToken(ts.SyntaxKind.QuestionToken), - ts.factory.createUnionTypeNode([ - ts.factory.createTypeReferenceNode(fromLib('make', 'MakeOptions'), []), - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('InternalUnsafeConstructorOption'), - undefined - ) - ]) - ) - ], - undefined, - ts.factory.createBlock([ - ts.factory.createExpressionStatement( - ts.factory.createCallExpression(ts.factory.createIdentifier('super'), [], []) - ), - ts.factory.createExpressionStatement( - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('oar'), - 'instanceAssign' - ), - [], - [ - ts.factory.createThis(), - ts.factory.createIdentifier('value'), - ts.factory.createIdentifier('opts'), - ts.factory.createIdentifier('build' + options.nameMapper(key, 'value')) - ] - ) - ) - ]) - ); - } - - function generateClassMakeMethod(key: string) { - const className = options.nameMapper(key, 'value'); - const shapeName = options.nameMapper(key, 'shape'); - return buildMethod(` - static make(value: ${shapeName}, opts?: ${runtime}.make.MakeOptions): ${runtime}.make.Make<${className}> { - if (value instanceof ${className}) { - return ${runtime}.make.Make.ok(value); - } - const make = build${className}(value, opts); - if (make.isError()) { - return ${runtime}.make.Make.error(make.errors); - } else { - return ${runtime}.make.Make.ok(new ${className}(make.success(), { unSafeSet: true })); - } - } - `); - } - - function generateClassBuiltindMembers(key: string) { - return [ - generateClassConstructor(key), - generateReflectionProperty(key), - generateClassMakeMethod(key) - ]; - } - - function generateValueClass(key: string, valueIdentifier: string, schema: oas.SchemaObject) { - const members = generateClassMembers( - schema.properties, - schema.required, - schema.additionalProperties - ); - const brand = ts.factory.createExpressionWithTypeArguments( - ts.factory.createPropertyAccessExpression(runtimeLibrary, 'valueClass.ValueClass'), - [] - ); - return ts.factory.createClassDeclaration( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - valueIdentifier, - [], - [ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [brand])], - [...members, ...generateClassBuiltindMembers(key)] - ); - } - - function makeCall(fun: string, args: readonly ts.Expression[]) { - return ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression(runtimeLibrary, 'make.' + fun), - undefined, - args - ); - } - - function makeAnyProperty(name: string) { - return ts.factory.createParameterDeclaration( - undefined, - undefined, - ts.factory.createIdentifier(name), - undefined, - ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), - undefined - ); - } - - function quotedProp(prop: string) { - if (/\W/.test(prop)) { - return ts.factory.createStringLiteral(prop); - } - return ts.factory.createIdentifier(prop); } - function generateReflectionMaker(key: string): ts.Expression { - return ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier('oar'), - ts.factory.createIdentifier('fromReflection') - ), - undefined, - [ - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier(options.nameMapper(key, 'reflection')), - ts.factory.createIdentifier('definition') - ) - ] - ); - } - - function generateTopLevelClassBuilder(key: string, valueIdentifier: string) { - return generateTopLevelMaker(key, 'build', valueIdentifier); - } - - function generateReflectionType( - schema: oas.SchemaObject | oas.ReferenceObject - ): ts.ObjectLiteralExpression { - if (oautil.isReferenceObject(schema)) { - const resolved = resolveRefToTypeName(schema.$ref, 'reflection'); - const type: ts.Expression = resolved.qualified - ? ts.factory.createPropertyAccessExpression(resolved.qualified, resolved.member) - : ts.factory.createIdentifier(resolved.member); - return ts.factory.createObjectLiteralExpression( - [ - ts.factory.createPropertyAssignment('type', ts.factory.createStringLiteral('named')), - ts.factory.createPropertyAssignment( - 'reference', - ts.factory.createArrowFunction( - undefined, - undefined, - [], - undefined, - ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - ts.factory.createBlock([ts.factory.createReturnStatement(type)], false) - ) - ) - ], - true - ); - } - if (schema.oneOf) { - return ts.factory.createObjectLiteralExpression( - [ - ts.factory.createPropertyAssignment('type', ts.factory.createStringLiteral('union')), - ts.factory.createPropertyAssignment( - 'options', - ts.factory.createArrayLiteralExpression( - schema.oneOf.map(schema => generateReflectionType(schema)), - true - ) - ) - ], - true - ); - } - if (schema.allOf) { - return ts.factory.createObjectLiteralExpression( - [ - ts.factory.createPropertyAssignment( - 'type', - ts.factory.createStringLiteral('intersection') - ), - ts.factory.createPropertyAssignment( - 'options', - ts.factory.createArrayLiteralExpression( - schema.allOf.map(schema => generateReflectionType(schema)), - true - ) - ) - ], - true - ); - } - - assert(!schema.anyOf, 'anyOf is not supported'); - - if (schema.nullable) { - return generateReflectionType({ oneOf: [{ ...schema, nullable: false }, { type: 'null' }] }); - } - - // @ts-expect-error schemas really do not have void type. but we do - if (schema.type === 'void') { - return ts.factory.createObjectLiteralExpression( - [ts.factory.createPropertyAssignment('type', ts.factory.createStringLiteral('void'))], - true - ); - } - if (schema.type === 'null') { - return ts.factory.createObjectLiteralExpression( - [ts.factory.createPropertyAssignment('type', ts.factory.createStringLiteral('null'))], - true - ); - } - - if (schema.type === 'string') { - const enumValues = schema.enum - ? [ - ts.factory.createPropertyAssignment( - 'enum', - ts.factory.createArrayLiteralExpression( - schema.enum.map(value => ts.factory.createStringLiteral(value)) - ) - ) - ] - : []; - if (schema.format === 'binary') { - return ts.factory.createObjectLiteralExpression( - [ts.factory.createPropertyAssignment('type', ts.factory.createStringLiteral('binary'))], - true - ); - } - const format = schema.format - ? [ - ts.factory.createPropertyAssignment( - 'format', - ts.factory.createStringLiteral(schema.format) - ) - ] - : []; - const pattern = schema.pattern - ? [ - ts.factory.createPropertyAssignment( - 'pattern', - ts.factory.createStringLiteral(schema.pattern) - ) - ] - : []; - const minLength = - schema.minLength != null - ? [ - ts.factory.createPropertyAssignment( - 'minLength', - generateNumericLiteral(schema.minLength) - ) - ] - : []; - const maxLength = - schema.maxLength != null - ? [ - ts.factory.createPropertyAssignment( - 'maxLength', - generateNumericLiteral(schema.maxLength) - ) - ] - : []; - - return ts.factory.createObjectLiteralExpression( - [ - ts.factory.createPropertyAssignment('type', ts.factory.createStringLiteral('string')), - ...enumValues, - ...format, - ...pattern, - ...minLength, - ...maxLength - ], - true - ); - } - if (schema.type === 'number' || schema.type === 'integer') { - const enumValues = schema.enum - ? [ - ts.factory.createPropertyAssignment( - 'enum', - ts.factory.createArrayLiteralExpression( - schema.enum.map(i => generateNumericLiteral('' + i)) - ) - ) - ] - : []; - const properties = [ - ts.factory.createPropertyAssignment('type', ts.factory.createStringLiteral(schema.type)), - ...enumValues - ]; - if (schema.minimum != null) { - properties.push( - ts.factory.createPropertyAssignment( - 'minimum', - generateNumericLiteral(schema.minimum + '') - ) - ); - } - if (schema.maximum != null) { - properties.push( - ts.factory.createPropertyAssignment( - 'maximum', - generateNumericLiteral(schema.maximum + '') - ) - ); - } - return ts.factory.createObjectLiteralExpression(properties, true); - } - if (schema.type === 'boolean') { - const enumValues = schema.enum - ? [ - ts.factory.createPropertyAssignment( - 'enum', - ts.factory.createArrayLiteralExpression( - schema.enum.map(i => - i === true - ? ts.factory.createTrue() - : i === false - ? ts.factory.createFalse() - : assert.fail('unknown enum ' + i) - ) - ) - ) - ] - : []; - return ts.factory.createObjectLiteralExpression( - [ - ts.factory.createPropertyAssignment('type', ts.factory.createStringLiteral('boolean')), - ...enumValues - ], - true - ); - } - if (schema.type === 'array') { - const properties = [ - ts.factory.createPropertyAssignment('type', ts.factory.createStringLiteral('array')), - ts.factory.createPropertyAssignment('items', generateReflectionType(schema.items || {})) - ]; - if (schema.minItems != null) { - properties.push( - ts.factory.createPropertyAssignment( - 'minItems', - generateNumericLiteral(schema.minItems + '') - ) - ); - } - if (schema.maxItems != null) { - properties.push( - ts.factory.createPropertyAssignment( - 'maxItems', - generateNumericLiteral(schema.maxItems + '') - ) - ); - } - return ts.factory.createObjectLiteralExpression(properties, true); - } - if (schema.type === 'object') { - return generateObjectReflectionType(schema); - } - if (!schema.type) { - return ts.factory.createObjectLiteralExpression( - [ts.factory.createPropertyAssignment('type', ts.factory.createStringLiteral('unknown'))], - true - ); - } - assert.fail('todo generateReflectionType', schema); - throw new Error(); - } - - function generateAdditionalPropsReflectionType(props: oas.SchemaObject['additionalProperties']) { - if (props === false) { - return ts.factory.createFalse(); - } - if ( - props === true || - !props || - (props && typeof props === 'object' && Object.keys(props).length === 0) - ) { - return ts.factory.createTrue(); - } - return generateReflectionType(props); - } - - function generateObjectReflectionType(schema: oas.SchemaObject) { - const additionalProps = generateAdditionalPropsReflectionType(schema.additionalProperties); - return ts.factory.createObjectLiteralExpression( - [ - ts.factory.createPropertyAssignment('type', ts.factory.createStringLiteral('object')), - ts.factory.createPropertyAssignment('additionalProperties', additionalProps), - ts.factory.createPropertyAssignment( - 'properties', - ts.factory.createObjectLiteralExpression( - Object.keys(schema.properties || {}).map((propertyName: string) => { - return ts.factory.createPropertyAssignment( - ts.factory.createStringLiteral( - options.propertyNameMapper - ? options.propertyNameMapper(propertyName) - : propertyName - ), - ts.factory.createObjectLiteralExpression( - [ - ts.factory.createPropertyAssignment( - 'required', - (schema.required || []).indexOf(propertyName) >= 0 - ? ts.factory.createTrue() - : ts.factory.createFalse() - ), - ...(options.propertyNameMapper - ? [ - ts.factory.createPropertyAssignment( - 'networkName', - ts.factory.createStringLiteral(propertyName) - ) - ] - : []), - ts.factory.createPropertyAssignment( - 'value', - generateReflectionType((schema.properties as any)[propertyName]) - ) - ], - true - ) - ); - }), - true - ) - ) - ], - true - ); - } - - function inventIsA(key: string, schema: oas.SchemaObject | oas.ReferenceObject) { - if (oas.isReferenceObject(schema)) return; - - if (schema.type === 'object') { - return generateIsA(options.nameMapper(key, 'value')); - } - if (isScalar(schema)) { - return generateIsAForScalar(key); - } - } - - function generateNamedTypeDefinitionDeclaration( - key: string, - schema: oas.SchemaObject | oas.ReferenceObject - ) { - const isA = inventIsA(key, schema); - return ts.factory.createVariableStatement( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - options.nameMapper(key, 'reflection'), - undefined, - ts.factory.createTypeReferenceNode(fromLib('reflection', 'NamedTypeDefinition'), [ - ts.factory.createTypeReferenceNode(options.nameMapper(key, 'value'), []), - ts.factory.createTypeReferenceNode(options.nameMapper(key, 'shape'), []) - ]), - ts.factory.createAsExpression( - ts.factory.createObjectLiteralExpression( - [ - ts.factory.createPropertyAssignment( - 'name', - ts.factory.createStringLiteral(options.nameMapper(key, 'value')) - ), - ts.factory.createPropertyAssignment('definition', generateReflectionType(schema)), - ts.factory.createPropertyAssignment( - 'maker', - ts.factory.createIdentifier('make' + options.nameMapper(key, 'value')) - ), - ts.factory.createPropertyAssignment('isA', isA ?? ts.factory.createNull()) - ], - true - ), - ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword) - ) - ) - ], - ts.NodeFlags.Const - ) - ); - } - - function generateIsA(type: string) { - return ts.factory.createArrowFunction( - undefined, - undefined, - [makeAnyProperty('value')], - undefined, - ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - ts.factory.createBinaryExpression( - ts.factory.createIdentifier('value'), - ts.factory.createToken(ts.SyntaxKind.InstanceOfKeyword), - ts.factory.createIdentifier(type) - ) - ); - } - - function generateTopLevelClassMaker(key: string, valueIdentifier: string) { - const shape = options.nameMapper(key, 'shape'); - return ts.factory.createVariableStatement( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - 'make' + valueIdentifier, - undefined, - ts.factory.createTypeReferenceNode(fromLib('make', 'Maker'), [ - ts.factory.createTypeReferenceNode(shape, []), - ts.factory.createTypeReferenceNode(valueIdentifier, []) - ]), - ts.factory.createPropertyAccessExpression( - ts.factory.createIdentifier(valueIdentifier), - 'make' - ) - ) - ], - ts.NodeFlags.Const - ) - ); - } - - function generateTopLevelMaker(key: string, name = 'make', resultType?: string) { - const makerFun = 'createMaker'; - const shape = options.nameMapper(key, 'shape'); - resultType = resultType || options.nameMapper(key, 'value'); - return ts.factory.createVariableStatement( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - name + options.nameMapper(key, 'value'), - undefined, - ts.factory.createTypeReferenceNode(fromLib('make', 'Maker'), [ - ts.factory.createTypeReferenceNode(shape, []), - ts.factory.createTypeReferenceNode(resultType, []) - ]), - makeCall(makerFun, [ - ts.factory.createFunctionExpression( - undefined, - undefined, - undefined, - undefined, - [], - undefined, - ts.factory.createBlock([ - ts.factory.createReturnStatement(generateReflectionMaker(key)) - ]) - ) - ]) - ) - ], - ts.NodeFlags.Const - ) - ); - } - - function brandTypeName(key: string): string { - return 'BrandOf' + options.nameMapper(key, 'value'); - } - - function generateBrand(key: string) { - return ts.factory.createEnumDeclaration(undefined, brandTypeName(key), []); - } - - function generateTypeShape(key: string, valueIdentifier: string) { - return ts.factory.createTypeAliasDeclaration( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - options.nameMapper(key, 'shape'), - undefined, - ts.factory.createTypeReferenceNode(fromLib('ShapeOf'), [ - ts.factory.createTypeReferenceNode(valueIdentifier, []) - ]) - ); - } - - function generateTopLevelClass(key: string, schema: oas.SchemaObject): readonly ts.Node[] { - if (schema.nullable) { - const classKey = oautil.nonNullableClass(key); - const proxy = generateTopLevelType(key, { - oneOf: [{ type: 'null' }, { $ref: '#/components/schemas/' + classKey }] - }); - return [...generateTopLevelClass(classKey, { ...schema, nullable: false }), ...proxy]; - } - const valueIdentifier = options.nameMapper(key, 'value'); - return [ - generateTypeShape(key, valueIdentifier), - generateValueClass(key, valueIdentifier, schema), - generateTopLevelClassBuilder(key, valueIdentifier), - generateTopLevelClassMaker(key, valueIdentifier), - generateNamedTypeDefinitionDeclaration(key, schema) - ]; - } - - function generateTopLevelType( - key: string, - schema: oas.SchemaObject | oas.ReferenceObject - ): readonly ts.Node[] { - const valueIdentifier = options.nameMapper(key, 'value'); - if (oautil.isReferenceObject(schema)) { - const resolved = resolveRefToTypeName(schema.$ref, 'value'); - const type = resolved.qualified - ? ts.factory.createQualifiedName(resolved.qualified, resolved.member) - : resolved.member; - return [ - ts.factory.createTypeAliasDeclaration( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - valueIdentifier, - undefined, - ts.factory.createTypeReferenceNode(type, undefined) - ), - generateTypeShape(key, valueIdentifier), - generateTopLevelMaker(key), - generateNamedTypeDefinitionDeclaration(key, schema) - ]; - } - if (schema.type === 'object') { - return generateTopLevelClass(key, schema); - } - - if (isScalar(schema)) { - return [ - generateBrand(key), - ts.factory.createTypeAliasDeclaration( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - options.nameMapper(key, 'value'), - undefined, - scalarTypeWithBrand(key, generateType(schema)) - ), - generateTypeShape(key, valueIdentifier), - generateTopLevelMaker(key), - generateNamedTypeDefinitionDeclaration(key, schema) - ]; - } - - return [ - ts.factory.createTypeAliasDeclaration( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - options.nameMapper(key, 'value'), - undefined, - generateType(schema) - ), - generateTypeShape(key, valueIdentifier), - generateTopLevelMaker(key), - generateNamedTypeDefinitionDeclaration(key, schema) - ]; - } - - function generateIsAForScalar(key: string) { - return ts.factory.createArrowFunction( - undefined, - undefined, - [makeAnyProperty('value')], - undefined, - ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - ts.factory.createCallExpression( - ts.factory.createPropertyAccessExpression( - ts.factory.createCallExpression( - ts.factory.createIdentifier('make' + options.nameMapper(key, 'value')), - undefined, - [ts.factory.createIdentifier('value')] - ), - 'isSuccess' - ), - undefined, - [] - ) - ); - } - - function scalarTypeWithBrand(key: string, type: ts.TypeNode): ts.TypeNode { - return ts.factory.createTypeReferenceNode(fromLib('BrandedScalar'), [ - type, - ts.factory.createTypeReferenceNode(brandTypeName(key), []) - ]); - } - - function isScalar(schema: oas.SchemaObject): boolean { - if (!schema.type) return false; - if (Array.isArray(schema.type)) return schema.type.findIndex(t => scalarTypes.includes(t)) >= 0; - return scalarTypes.includes(schema.type); - } + return result; +} - function generateComponentSchemas(opts: Options): ts.Node[] { - const oas = opts.oas; - const schemas = oas.components?.schemas; - if (!schemas) { - return []; - } - const nodes: ts.Node[] = []; - Object.keys(schemas).map(key => { - const schema = schemas[key]; - const types = generateTopLevelType(key, schema); - types.map(t => nodes.push(t)); - }); - return nodes; - } +/** + * Generates all components from the OpenAPI spec. + */ +function generateComponents(ctx: ReturnType): string[] { + const { options } = ctx; + const result: string[] = []; + + result.push(...errorTag('in component.schemas', () => generateComponentSchemas(ctx))); + result.push( + ...errorTag('in component.responses', () => + generateComponentRequestsAndResponses(options.oas.components?.responses, ctx) + ) + ); + result.push( + ...errorTag('in component.requestBodies', () => + generateComponentRequestsAndResponses(options.oas.components?.requestBodies, ctx) + ) + ); - function generateComponentRequestsAndResponses(components?: { - [key: string]: oas.ResponseObject | oas.RequestBodyObject | oas.ReferenceObject; - }): ts.Node[] { - const nodes: ts.Node[] = []; - if (components) { - Object.keys(components).map(key => { - const component = components[key]; - const schema = oautil.isReferenceObject(component) - ? { $ref: component.$ref } - : generateContentSchemaType(component.content || assert.fail('missing content')); - nodes.push(...generateTopLevelType(key, schema)); - }); - } - return nodes; - } + return result; +} - function generateComponents(opts: Options): ts.NodeArray { - const oas = opts.oas; - const nodes = []; - nodes.push(...oautil.errorTag('in component.schemas', () => generateComponentSchemas(opts))); - nodes.push( - ...oautil.errorTag('in component.responses', () => - generateComponentRequestsAndResponses(oas.components?.responses) - ) - ); - nodes.push( - ...oautil.errorTag('in component.requestBodies', () => - generateComponentRequestsAndResponses(oas.components?.requestBodies) - ) - ); - return ts.factory.createNodeArray(nodes); - } +/** + * Main entry point for type generation. + */ +export function run(options: Options): string | undefined { + options.targetFile = './' + path.normalize(options.targetFile); - function fromLib(...names: string[]): ts.QualifiedName { - return ts.factory.createQualifiedName(runtimeLibrary, names.join('.')); + if (isGenerating(options.targetFile) && !options.forceGenerateTypes) { + return; } + generatedFiles.add(options.targetFile); - function generateExternals(imports: readonly ImportDefinition[]) { - return imports.map(external => { - return ts.factory.createImportDeclaration( - undefined, - ts.factory.createImportClause( - false, - undefined, - ts.factory.createNamespaceImport(ts.factory.createIdentifier(external.importAs)) - ), - ts.factory.createStringLiteral(external.importFile) - ); - }); - } + const state: GenerationState = { + cwd: path.dirname(options.sourceFile), + imports: {} as Record, + actions: [] as Array<() => Promise> + }; - function generateBuiltins() { - return ts.factory.createNodeArray([ - ts.factory.createImportDeclaration( - undefined, - ts.factory.createImportClause( - false, - undefined, - ts.factory.createNamespaceImport(runtimeLibrary) - ), - ts.factory.createStringLiteral(options.runtimeModule) - ), - ts.factory.createTypeAliasDeclaration( - undefined, - 'InternalUnsafeConstructorOption', - undefined, - ts.factory.createTypeLiteralNode([ - ts.factory.createPropertySignature( - undefined, - ts.factory.createIdentifier('unSafeSet'), - undefined, - ts.factory.createLiteralTypeNode(ts.factory.createTrue()) - ) - ]) - ) - ]); - } + const ctx = createContext(options, state, generatedFiles); - function addIndexSignatureIgnores(src: string) { - const result: string[] = []; - src.split('\n').forEach(line => { - const m = line.match(new RegExp('\\[\\s*' + valueClassIndexSignatureKey)); - if (m) { - if (!/\b(unknown|any)\b/.test(line)) { - result.push(' // @ts-ignore tsc does not like the branding type in index signatures'); - result.push(line); - return; - } - } - const brandMatch = line.match(new RegExp('\\s*readonly #' + oatsBrandFieldName)); - if (brandMatch) { - result.push(' // @ts-ignore tsc does not like unused privates'); - result.push(line); - return; - } - result.push(line); - }); - return result.join('\n'); - } + const builtins = generateBuiltins(options); + const types = generateComponents(ctx); + const queryTypes = generateQueryTypes(ctx); - function addToImports( - importAs: string, - importFile: string | undefined, - action?: (() => Promise) | undefined - ) { - if (!state.imports[importAs]) { - if (importFile) { - importFile = /^(\.|\/)/.test(importFile) ? './' + path.normalize(importFile) : importFile; - state.imports[importAs] = importFile; - if (action) { - if (generatedFiles.has(importFile)) { - return; - } - generatedFiles.add(importFile); - state.actions.push(action); - } - } - } - } + const externals = Object.entries(state.imports).map(([importAs, importFile]) => + generateExternalImport({ + importAs, + importFile: resolveModule(options.targetFile, importFile) + }) + ); - function resolveRefToTypeName( - ref: string, - kind: NameKind - ): { qualified?: ts.Identifier; member: string } { - const external = options.resolve(ref, options, kind); - if (external) { - if ('importAs' in external) { - const importAs = external.importAs; - addToImports(importAs, external.importFrom, external.generate); - return { member: external.name, qualified: ts.factory.createIdentifier(importAs) }; - } - return { member: external.name }; - } - if (ref[0] === '#') { - return { member: options.nameMapper(oautil.refToTypeName(ref), kind) }; - } - return assert.fail('could not resolve typename for ' + ref); - } + state.actions.forEach(action => action()); - function resolveModule(fromModule: string, toModule: string): string { - if (!toModule.startsWith('.')) { - info(`importing ${toModule} to ${fromModule} as a package import`); - return toModule; - } - const p = path.relative(path.dirname(fromModule), toModule); - info(`importing ${toModule} to ${fromModule} as a relative import`); - if (p[0] === '.') { - return p; - } - return './' + p; - } -} + const src = [...builtins, ...externals, ...types, ...queryTypes].join('\n'); -export enum AdditionalPropertiesIndexSignature { - /** emit unknown typed index signature when additionalProperties: true or missing (defaults to true) */ - emit = 'emit', - /** do not emit unknown typed index signature when additionalProperties: true or missing*/ - omit = 'omit' + return addIndexSignatureIgnores(src); } diff --git a/packages/oats/src/template.ts b/packages/oats/src/template.ts new file mode 100644 index 00000000..2da51f3a --- /dev/null +++ b/packages/oats/src/template.ts @@ -0,0 +1,55 @@ +/** + * Tagged template literal utilities for TypeScript code generation. + */ + +/** + * Tagged template that builds strings with array interpolation. + * Arrays are joined with newlines, null/undefined values are omitted. + */ +export function ts( + strings: TemplateStringsArray, + ...values: (string | readonly string[] | undefined | null)[] +): string { + let result = strings[0]; + for (let i = 0; i < values.length; i++) { + const value = values[i]; + if (Array.isArray(value)) { + result += value.join('\n'); + } else if (value != null) { + result += value; + } + result += strings[i + 1]; + } + return result; +} + +/** + * Helper to quote a property name if it contains special characters. + */ +export function quoteProp(name: string): string { + if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)) { + return name; + } + return JSON.stringify(name); +} + +/** + * Helper to create a string literal in generated code. + */ +export function str(value: string): string { + return JSON.stringify(value); +} + +/** + * Conditionally include content in template. + */ +export function when(condition: boolean | undefined | null, content: string): string { + return condition ? content : ''; +} + +/** + * Join array elements with a separator, filtering out empty strings. + */ +export function join(items: (string | undefined | null)[], separator: string): string { + return items.filter(item => item != null && item !== '').join(separator); +} diff --git a/packages/oats/test/codegen/classes.spec.ts b/packages/oats/test/codegen/classes.spec.ts new file mode 100644 index 00000000..a92d2271 --- /dev/null +++ b/packages/oats/test/codegen/classes.spec.ts @@ -0,0 +1,206 @@ +import { createContext, GenerationState, Options } from '../../src/codegen/context'; +import { + generateValueClass, + generateClassConstructor, + generateReflectionProperty, + generateClassMakeMethod, + generateClassBuiltinMembers +} from '../../src/codegen/classes'; + +function printNode(node: string): string { + return node; +} + +function createTestContext(optionOverrides: Partial = {}) { + const options: Options = { + header: '', + sourceFile: './test.yaml', + targetFile: './test.generated.ts', + resolve: () => undefined, + oas: { openapi: '3.0.0', info: { title: 'Test', version: '1.0.0' }, paths: {} }, + runtimeModule: '@smartlyio/oats-runtime', + emitStatusCode: () => true, + nameMapper: (name, kind) => { + const capitalized = name.charAt(0).toUpperCase() + name.slice(1); + if (kind === 'shape') return 'ShapeOf' + capitalized; + if (kind === 'reflection') return 'type' + capitalized; + return capitalized; + }, + ...optionOverrides + }; + + const state: GenerationState = { + cwd: '.', + imports: {}, + actions: [] + }; + + return createContext(options, state, new Set()); +} + +describe('codegen/classes', () => { + describe('generateClassConstructor', () => { + it('generates constructor method', () => { + const ctx = createTestContext(); + const result = generateClassConstructor('User', ctx); + expect(printNode(result)).toBe( + 'public constructor(value: ShapeOfUser, opts?: oar.make.MakeOptions | InternalUnsafeConstructorOption) { super(); oar.instanceAssign(this, value, opts, buildUser); }' + ); + }); + + it('uses name mapper for shape type', () => { + const ctx = createTestContext({ + nameMapper: (name, kind) => (kind === 'shape' ? `${name}Input` : name) + }); + const result = generateClassConstructor('User', ctx); + expect(printNode(result)).toContain('value: UserInput'); + }); + }); + + describe('generateReflectionProperty', () => { + it('generates static reflection property', () => { + const ctx = createTestContext(); + const result = generateReflectionProperty('User', ctx); + expect(printNode(result)).toBe( + 'public static reflection: oar.reflection.NamedTypeDefinitionDeferred = () => { return typeUser; };' + ); + }); + }); + + describe('generateClassMakeMethod', () => { + it('generates static make method', () => { + const ctx = createTestContext(); + const result = generateClassMakeMethod('User', ctx); + // Prettier formats this - check key parts + expect(printNode(result)).toContain('static make(value: ShapeOfUser'); + expect(printNode(result)).toContain('if (value instanceof User)'); + expect(printNode(result)).toContain('return oar.make.Make.ok(value)'); + expect(printNode(result)).toContain('const make = buildUser(value, opts)'); + }); + }); + + describe('generateClassBuiltinMembers', () => { + it('generates all three builtin members', () => { + const ctx = createTestContext(); + const result = generateClassBuiltinMembers('User', ctx); + expect(result).toHaveLength(3); + + const printed = result.map(printNode); + expect(printed[0]).toContain('constructor'); + expect(printed[1]).toContain('static reflection'); + expect(printed[2]).toContain('static make'); + }); + }); + + describe('generateValueClass', () => { + it('generates class with single required property', () => { + const ctx = createTestContext(); + const result = generateValueClass( + 'User', + 'User', + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false + }, + ctx + ); + const printed = printNode(result); + expect(printed).toContain('export class User extends oar.valueClass.ValueClass'); + expect(printed).toContain('readonly name!: string'); + expect(printed).toContain('constructor'); + expect(printed).toContain('static reflection'); + expect(printed).toContain('static make'); + }); + + it('generates class with optional property', () => { + const ctx = createTestContext(); + const result = generateValueClass( + 'User', + 'User', + { + type: 'object', + properties: { name: { type: 'string' } }, + additionalProperties: false + }, + ctx + ); + const printed = printNode(result); + expect(printed).toContain('readonly name?: string'); + }); + + it('generates class with multiple properties', () => { + const ctx = createTestContext(); + const result = generateValueClass( + 'User', + 'User', + { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + active: { type: 'boolean' } + }, + required: ['id', 'name'], + additionalProperties: false + }, + ctx + ); + const printed = printNode(result); + expect(printed).toContain('readonly id!: number'); + expect(printed).toContain('readonly name!: string'); + expect(printed).toContain('readonly active?: boolean'); + }); + + it('generates class with index signature for additionalProperties', () => { + const ctx = createTestContext(); + const result = generateValueClass( + 'User', + 'User', + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: true + }, + ctx + ); + const printed = printNode(result); + expect(printed).toContain('readonly name!: string'); + expect(printed).toContain('[instanceIndexSignatureKey: string]: unknown'); + }); + + it('generates class with no properties', () => { + const ctx = createTestContext(); + const result = generateValueClass( + 'Empty', + 'Empty', + { + type: 'object', + additionalProperties: false + }, + ctx + ); + const printed = printNode(result); + expect(printed).toContain('export class Empty extends oar.valueClass.ValueClass'); + expect(printed).toContain('constructor'); + }); + + it('includes brand tag property', () => { + const ctx = createTestContext(); + const result = generateValueClass( + 'User', + 'User', + { + type: 'object', + properties: { name: { type: 'string' } }, + additionalProperties: false + }, + ctx + ); + const printed = printNode(result); + expect(printed).toContain('#__oats_value_class_brand_tag'); + }); + }); +}); diff --git a/packages/oats/test/codegen/context.spec.ts b/packages/oats/test/codegen/context.spec.ts new file mode 100644 index 00000000..eaf024cf --- /dev/null +++ b/packages/oats/test/codegen/context.spec.ts @@ -0,0 +1,196 @@ +import { createContext, GenerationState, Options } from '../../src/codegen/context'; + +function createTestOptions(overrides: Partial = {}): Options { + return { + header: '', + sourceFile: './test.yaml', + targetFile: './test.generated.ts', + resolve: () => undefined, + oas: { openapi: '3.0.0', info: { title: 'Test', version: '1.0.0' }, paths: {} }, + runtimeModule: '@smartlyio/oats-runtime', + emitStatusCode: () => true, + nameMapper: (name, kind) => { + const capitalized = name.charAt(0).toUpperCase() + name.slice(1); + if (kind === 'shape') return 'ShapeOf' + capitalized; + if (kind === 'reflection') return 'type' + capitalized; + return capitalized; + }, + ...overrides + }; +} + +function createTestState(): GenerationState { + return { + cwd: '.', + imports: {}, + actions: [] + }; +} + +describe('codegen/context', () => { + describe('createContext', () => { + it('creates context with options accessible', () => { + const options = createTestOptions(); + const state = createTestState(); + const generatedFiles = new Set(); + + const ctx = createContext(options, state, generatedFiles); + + expect(ctx.options).toBe(options); + }); + + describe('addImport', () => { + it('adds import to state', () => { + const options = createTestOptions(); + const state = createTestState(); + const generatedFiles = new Set(); + const ctx = createContext(options, state, generatedFiles); + + ctx.addImport('types', './types.generated.ts'); + + expect(state.imports['types']).toBe('./types.generated.ts'); + }); + + it('normalizes relative paths', () => { + const options = createTestOptions(); + const state = createTestState(); + const generatedFiles = new Set(); + const ctx = createContext(options, state, generatedFiles); + + ctx.addImport('types', './foo/../types.ts'); + + // path.normalize removes the ../ + expect(state.imports['types']).toBe('./types.ts'); + }); + + it('does not overwrite existing import', () => { + const options = createTestOptions(); + const state = createTestState(); + state.imports['types'] = './original.ts'; + const generatedFiles = new Set(); + const ctx = createContext(options, state, generatedFiles); + + ctx.addImport('types', './new.ts'); + + expect(state.imports['types']).toBe('./original.ts'); + }); + + it('ignores undefined import file', () => { + const options = createTestOptions(); + const state = createTestState(); + const generatedFiles = new Set(); + const ctx = createContext(options, state, generatedFiles); + + ctx.addImport('types', undefined); + + expect(state.imports['types']).toBeUndefined(); + }); + + it('adds action for new imports with generator', () => { + const options = createTestOptions(); + const state = createTestState(); + const generatedFiles = new Set(); + const ctx = createContext(options, state, generatedFiles); + const action = jest.fn().mockResolvedValue(undefined); + + ctx.addImport('types', './types.ts', action); + + expect(state.actions).toHaveLength(1); + expect(generatedFiles.has('./types.ts')).toBe(true); + }); + + it('does not add action for already generated files', () => { + const options = createTestOptions(); + const state = createTestState(); + const generatedFiles = new Set(['./types.ts']); + const ctx = createContext(options, state, generatedFiles); + const action = jest.fn().mockResolvedValue(undefined); + + ctx.addImport('types', './types.ts', action); + + expect(state.actions).toHaveLength(0); + }); + }); + + describe('resolveRefToTypeName', () => { + it('resolves local reference', () => { + const options = createTestOptions(); + const state = createTestState(); + const generatedFiles = new Set(); + const ctx = createContext(options, state, generatedFiles); + + const result = ctx.resolveRefToTypeName('#/components/schemas/User', 'value'); + + expect(result.member).toBe('User'); + expect(result.qualified).toBeUndefined(); + }); + + it('uses nameMapper for local references', () => { + const options = createTestOptions({ + nameMapper: (name, kind) => (kind === 'shape' ? 'Shape' + name : name) + }); + const state = createTestState(); + const generatedFiles = new Set(); + const ctx = createContext(options, state, generatedFiles); + + const result = ctx.resolveRefToTypeName('#/components/schemas/User', 'shape'); + + expect(result.member).toBe('ShapeUser'); + }); + + it('resolves external reference via resolve function', () => { + const options = createTestOptions({ + resolve: (ref, _opts, _kind) => { + if (ref.startsWith('./external')) { + return { + importAs: 'external', + importFrom: './external.generated.ts', + name: 'ExternalType' + }; + } + return undefined; + } + }); + const state = createTestState(); + const generatedFiles = new Set(); + const ctx = createContext(options, state, generatedFiles); + + const result = ctx.resolveRefToTypeName('./external.yaml#/components/schemas/Foo', 'value'); + + expect(result.member).toBe('ExternalType'); + expect(result.qualified).toBe('external'); + expect(state.imports['external']).toBe('./external.generated.ts'); + }); + + it('resolves reference with name only (no import)', () => { + const options = createTestOptions({ + resolve: ref => { + if (ref === '#/components/schemas/Local') { + return { name: 'LocalType' }; + } + return undefined; + } + }); + const state = createTestState(); + const generatedFiles = new Set(); + const ctx = createContext(options, state, generatedFiles); + + const result = ctx.resolveRefToTypeName('#/components/schemas/Local', 'value'); + + expect(result.member).toBe('LocalType'); + expect(result.qualified).toBeUndefined(); + }); + + it('throws for unresolvable non-local reference', () => { + const options = createTestOptions(); + const state = createTestState(); + const generatedFiles = new Set(); + const ctx = createContext(options, state, generatedFiles); + + expect(() => { + ctx.resolveRefToTypeName('./unknown.yaml#/schemas/Foo', 'value'); + }).toThrow('could not resolve typename'); + }); + }); + }); +}); diff --git a/packages/oats/test/codegen/helpers.spec.ts b/packages/oats/test/codegen/helpers.spec.ts new file mode 100644 index 00000000..c37077d2 --- /dev/null +++ b/packages/oats/test/codegen/helpers.spec.ts @@ -0,0 +1,254 @@ +import { + quotedProp, + generateLiteral, + generateNumericLiteral, + fromLib, + brandTypeName, + isScalar, + addIndexSignatureIgnores, + resolveModule, + valueClassIndexSignatureKey, + oatsBrandFieldName +} from '../../src/codegen/helpers'; + +function printNode(node: string): string { + return node; +} + +describe('codegen/helpers', () => { + describe('quotedProp', () => { + it('returns identifier for simple property names', () => { + const result = quotedProp('foo'); + expect(printNode(result)).toBe('foo'); + }); + + it('returns identifier for names with underscores', () => { + const result = quotedProp('foo_bar'); + expect(printNode(result)).toBe('foo_bar'); + }); + + it('returns identifier for names with numbers', () => { + const result = quotedProp('foo123'); + expect(printNode(result)).toBe('foo123'); + }); + + it('returns string literal for names with dashes', () => { + const result = quotedProp('foo-bar'); + expect(printNode(result)).toBe('"foo-bar"'); + }); + + it('returns string literal for names with spaces', () => { + const result = quotedProp('foo bar'); + expect(printNode(result)).toBe('"foo bar"'); + }); + }); + + describe('generateLiteral', () => { + it('generates true literal', () => { + const result = generateLiteral(true); + expect(printNode(result)).toBe('true'); + }); + + it('generates false literal', () => { + const result = generateLiteral(false); + expect(printNode(result)).toBe('false'); + }); + + it('generates null literal', () => { + const result = generateLiteral(null); + expect(printNode(result)).toBe('null'); + }); + + it('generates string literal', () => { + const result = generateLiteral('hello'); + expect(printNode(result)).toBe('"hello"'); + }); + + it('generates positive number literal', () => { + const result = generateLiteral(42); + expect(printNode(result)).toBe('42'); + }); + + it('generates negative number literal', () => { + const result = generateLiteral(-42); + expect(printNode(result)).toBe('-42'); + }); + + it('generates zero literal', () => { + const result = generateLiteral(0); + expect(printNode(result)).toBe('0'); + }); + + it('throws for unsupported types', () => { + expect(() => generateLiteral(undefined)).toThrow('unsupported enum value'); + expect(() => generateLiteral({})).toThrow('unsupported enum value'); + expect(() => generateLiteral([])).toThrow('unsupported enum value'); + }); + }); + + describe('generateNumericLiteral', () => { + it('generates positive integer', () => { + const result = generateNumericLiteral(123); + expect(printNode(result)).toBe('123'); + }); + + it('generates negative integer with prefix', () => { + const result = generateNumericLiteral(-123); + expect(printNode(result)).toBe('-123'); + }); + + it('generates zero', () => { + const result = generateNumericLiteral(0); + expect(printNode(result)).toBe('0'); + }); + + it('handles string input', () => { + const result = generateNumericLiteral('456'); + expect(printNode(result)).toBe('456'); + }); + + it('handles negative string input', () => { + const result = generateNumericLiteral('-789'); + expect(printNode(result)).toBe('-789'); + }); + }); + + describe('fromLib', () => { + it('creates qualified name from single part', () => { + const result = fromLib('make'); + expect(printNode(result)).toBe('oar.make'); + }); + + it('creates qualified name from multiple parts joined with dot', () => { + const result = fromLib('make', 'Maker'); + expect(printNode(result)).toBe('oar.make.Maker'); + }); + + it('creates qualified name for reflection types', () => { + const result = fromLib('reflection', 'NamedTypeDefinition'); + expect(printNode(result)).toBe('oar.reflection.NamedTypeDefinition'); + }); + }); + + describe('brandTypeName', () => { + it('generates brand type name with nameMapper', () => { + const nameMapper = (name: string, kind: string) => + kind === 'value' ? name.charAt(0).toUpperCase() + name.slice(1) : name; + + const result = brandTypeName('myType', nameMapper); + expect(result).toBe('BrandOfMyType'); + }); + + it('handles already capitalized names', () => { + const nameMapper = (name: string) => name; + const result = brandTypeName('MyType', nameMapper); + expect(result).toBe('BrandOfMyType'); + }); + }); + + describe('isScalar', () => { + it('returns true for string type', () => { + expect(isScalar({ type: 'string' })).toBe(true); + }); + + it('returns true for integer type', () => { + expect(isScalar({ type: 'integer' })).toBe(true); + }); + + it('returns true for number type', () => { + expect(isScalar({ type: 'number' })).toBe(true); + }); + + it('returns true for boolean type', () => { + expect(isScalar({ type: 'boolean' })).toBe(true); + }); + + it('returns false for object type', () => { + expect(isScalar({ type: 'object' })).toBe(false); + }); + + it('returns false for array type', () => { + expect(isScalar({ type: 'array' })).toBe(false); + }); + + it('returns false for schema without type', () => { + expect(isScalar({})).toBe(false); + }); + + it('handles array of types with scalar', () => { + expect(isScalar({ type: ['string', 'null'] as any })).toBe(true); + }); + + it('handles array of types without scalar', () => { + expect(isScalar({ type: ['object', 'array'] as any })).toBe(false); + }); + }); + + describe('addIndexSignatureIgnores', () => { + it('adds ts-ignore before index signature lines', () => { + const input = `class Foo { + readonly [${valueClassIndexSignatureKey}: string]: SomeType; +}`; + const result = addIndexSignatureIgnores(input); + expect(result).toContain( + '// @ts-ignore tsc does not like the branding type in index signatures' + ); + expect(result).toContain(valueClassIndexSignatureKey); + }); + + it('does not add ts-ignore for unknown index signatures', () => { + const input = `class Foo { + readonly [${valueClassIndexSignatureKey}: string]: unknown; +}`; + const result = addIndexSignatureIgnores(input); + expect(result).not.toContain('// @ts-ignore'); + }); + + it('does not add ts-ignore for any index signatures', () => { + const input = `class Foo { + readonly [${valueClassIndexSignatureKey}: string]: any; +}`; + const result = addIndexSignatureIgnores(input); + expect(result).not.toContain('// @ts-ignore'); + }); + + it('adds ts-ignore before brand property', () => { + const input = `class Foo { + readonly #${oatsBrandFieldName}!: string; +}`; + const result = addIndexSignatureIgnores(input); + expect(result).toContain('// @ts-ignore tsc does not like unused privates'); + }); + + it('leaves unrelated lines unchanged', () => { + const input = `class Foo { + readonly name: string; +}`; + const result = addIndexSignatureIgnores(input); + expect(result).toBe(input); + }); + }); + + describe('resolveModule', () => { + it('returns package imports unchanged', () => { + expect(resolveModule('./output.ts', '@smartlyio/oats-runtime')).toBe( + '@smartlyio/oats-runtime' + ); + }); + + it('resolves relative path in same directory', () => { + const result = resolveModule('./output/types.ts', './output/other.ts'); + expect(result).toBe('./other.ts'); + }); + + it('resolves relative path in parent directory', () => { + const result = resolveModule('./sub/output.ts', './other.ts'); + expect(result).toBe('../other.ts'); + }); + + it('adds ./ prefix when needed', () => { + const result = resolveModule('./a/b.ts', './a/c.ts'); + expect(result.startsWith('./')).toBe(true); + }); + }); +}); diff --git a/packages/oats/test/codegen/makers.spec.ts b/packages/oats/test/codegen/makers.spec.ts new file mode 100644 index 00000000..be62e1c0 --- /dev/null +++ b/packages/oats/test/codegen/makers.spec.ts @@ -0,0 +1,190 @@ +import { createContext, GenerationState, Options } from '../../src/codegen/context'; +import { + generateTypeShape, + generateBrand, + generateTopLevelMaker, + generateTopLevelClassMaker, + generateTopLevelClassBuilder, + generateTopLevelType +} from '../../src/codegen/makers'; + +function printNode(node: string): string { + return node; +} + +function printNodes(nodes: readonly string[]): string { + return nodes.join('\n'); +} + +function createTestContext(optionOverrides: Partial = {}) { + const options: Options = { + header: '', + sourceFile: './test.yaml', + targetFile: './test.generated.ts', + resolve: () => undefined, + oas: { openapi: '3.0.0', info: { title: 'Test', version: '1.0.0' }, paths: {} }, + runtimeModule: '@smartlyio/oats-runtime', + emitStatusCode: () => true, + nameMapper: (name, kind) => { + const capitalized = name.charAt(0).toUpperCase() + name.slice(1); + if (kind === 'shape') return 'ShapeOf' + capitalized; + if (kind === 'reflection') return 'type' + capitalized; + if (kind === 'maker') return 'make' + capitalized; + return capitalized; + }, + ...optionOverrides + }; + + const state: GenerationState = { + cwd: '.', + imports: {}, + actions: [] + }; + + return createContext(options, state, new Set()); +} + +describe('codegen/makers', () => { + describe('generateTypeShape', () => { + it('generates shape type alias', () => { + const ctx = createTestContext(); + const result = generateTypeShape('User', 'User', ctx); + expect(printNode(result)).toBe('export type ShapeOfUser = oar.ShapeOf;'); + }); + + it('uses custom name mapper', () => { + const ctx = createTestContext({ + nameMapper: (name, kind) => (kind === 'shape' ? `${name}Shape` : name) + }); + const result = generateTypeShape('User', 'User', ctx); + expect(printNode(result)).toBe('export type UserShape = oar.ShapeOf;'); + }); + }); + + describe('generateBrand', () => { + it('generates empty enum for branding', () => { + const ctx = createTestContext(); + const result = generateBrand('UserId', ctx); + expect(printNode(result)).toBe('enum BrandOfUserId {}'); + }); + }); + + describe('generateTopLevelMaker', () => { + it('generates maker function', () => { + const ctx = createTestContext(); + const result = generateTopLevelMaker('User', ctx); + expect(printNode(result)).toContain( + 'export const makeUser: oar.make.Maker' + ); + expect(printNode(result)).toContain('oar.make.createMaker'); + expect(printNode(result)).toContain('return oar.fromReflection(typeUser.definition);'); + }); + + it('generates builder function with custom name', () => { + const ctx = createTestContext(); + const result = generateTopLevelMaker('User', ctx, 'build', 'User'); + expect(printNode(result)).toContain( + 'export const buildUser: oar.make.Maker' + ); + expect(printNode(result)).toContain('return oar.fromReflection(typeUser.definition);'); + }); + }); + + describe('generateTopLevelClassMaker', () => { + it('generates class maker referencing static make method', () => { + const ctx = createTestContext(); + const result = generateTopLevelClassMaker('User', 'User', ctx); + expect(printNode(result)).toBe( + 'export const makeUser: oar.make.Maker = User.make;' + ); + }); + }); + + describe('generateTopLevelClassBuilder', () => { + it('generates class builder function', () => { + const ctx = createTestContext(); + const result = generateTopLevelClassBuilder('User', 'User', ctx); + expect(printNode(result)).toContain( + 'export const buildUser: oar.make.Maker' + ); + expect(printNode(result)).toContain('return oar.fromReflection(typeUser.definition);'); + }); + }); + + describe('generateTopLevelType', () => { + it('generates type alias for reference', () => { + const ctx = createTestContext(); + const result = generateTopLevelType('MyUser', { $ref: '#/components/schemas/User' }, ctx); + const printed = printNodes(result); + expect(printed).toContain('export type MyUser = User;'); + expect(printed).toContain('export type ShapeOfMyUser = oar.ShapeOf;'); + expect(printed).toContain('export const makeMyUser'); + expect(printed).toContain('export const typeMyUser'); + }); + + it('generates branded scalar type for string', () => { + const ctx = createTestContext(); + const result = generateTopLevelType('UserId', { type: 'string' }, ctx); + const printed = printNodes(result); + expect(printed).toContain('enum BrandOfUserId {}'); + expect(printed).toContain('export type UserId = oar.BrandedScalar;'); + expect(printed).toContain('export type ShapeOfUserId = oar.ShapeOf;'); + expect(printed).toContain('export const makeUserId'); + expect(printed).toContain('isA: (value: any) => makeUserId(value).isSuccess()'); + }); + + it('generates branded scalar type for integer', () => { + const ctx = createTestContext(); + const result = generateTopLevelType('Count', { type: 'integer' }, ctx); + const printed = printNodes(result); + expect(printed).toContain('enum BrandOfCount {}'); + expect(printed).toContain('export type Count = oar.BrandedScalar;'); + }); + + it('generates plain type alias for array', () => { + const ctx = createTestContext(); + const result = generateTopLevelType( + 'Items', + { type: 'array', items: { type: 'string' } }, + ctx + ); + const printed = printNodes(result); + expect(printed).toContain('export type Items = ReadonlyArray;'); + expect(printed).toContain('export type ShapeOfItems = oar.ShapeOf;'); + expect(printed).toContain('type: "array"'); + expect(printed).toContain('type: "string"'); + }); + + it('generates plain type alias for union', () => { + const ctx = createTestContext(); + const result = generateTopLevelType( + 'Mixed', + { oneOf: [{ type: 'string' }, { type: 'number' }] }, + ctx + ); + const printed = printNodes(result); + expect(printed).toContain('export type Mixed = string | number;'); + expect(printed).toContain('type: "union"'); + }); + + it('generates class for object type', () => { + const ctx = createTestContext(); + const result = generateTopLevelType( + 'User', + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false + }, + ctx + ); + const printed = printNodes(result); + expect(printed).toContain('export type ShapeOfUser = oar.ShapeOf;'); + expect(printed).toContain('export class User extends oar.valueClass.ValueClass'); + expect(printed).toContain('readonly name!: string'); + expect(printed).toContain('export const buildUser'); + expect(printed).toContain('export const makeUser'); + }); + }); +}); diff --git a/packages/oats/test/codegen/query-types.spec.ts b/packages/oats/test/codegen/query-types.spec.ts new file mode 100644 index 00000000..4f85aeb1 --- /dev/null +++ b/packages/oats/test/codegen/query-types.spec.ts @@ -0,0 +1,389 @@ +import * as oas from 'openapi3-ts'; +import { createContext, GenerationState, Options } from '../../src/codegen/context'; +import { + generateContentSchemaType, + generateHeadersSchemaType, + generateQueryType, + generateParameterType, + generateRequestBodyType, + generateResponseType +} from '../../src/codegen/query-types'; + +function printNodes(nodes: readonly string[]): string { + return nodes.join('\n'); +} + +function createTestContext(optionOverrides: Partial = {}) { + const oasSpec: oas.OpenAPIObject = { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {} + }; + + const options: Options = { + header: '', + sourceFile: './test.yaml', + targetFile: './test.generated.ts', + resolve: () => undefined, + oas: oasSpec, + runtimeModule: '@smartlyio/oats-runtime', + emitStatusCode: () => true, + nameMapper: (name, kind) => { + const capitalized = name.charAt(0).toUpperCase() + name.slice(1); + if (kind === 'shape') return 'ShapeOf' + capitalized; + if (kind === 'reflection') return 'type' + capitalized; + return capitalized; + }, + ...optionOverrides + }; + + const state: GenerationState = { + cwd: '.', + imports: {}, + actions: [] + }; + + return createContext(options, state, new Set()); +} + +describe('codegen/query-types', () => { + describe('generateContentSchemaType', () => { + it('generates schema for single content type', () => { + const result = generateContentSchemaType({ + 'application/json': { schema: { type: 'object' } } + }); + expect(result).toEqual({ + oneOf: [ + { + type: 'object', + properties: { + contentType: { type: 'string', enum: ['application/json'] }, + value: { type: 'object' } + }, + required: ['contentType', 'value'], + additionalProperties: false + } + ] + }); + }); + + it('generates union schema for multiple content types', () => { + const result = generateContentSchemaType({ + 'application/json': { schema: { type: 'object' } }, + 'text/plain': { schema: { type: 'string' } } + }); + expect(result.oneOf).toHaveLength(2); + expect(result.oneOf![0].properties?.contentType.enum).toEqual(['application/json']); + expect(result.oneOf![1].properties?.contentType.enum).toEqual(['text/plain']); + }); + }); + + describe('generateHeadersSchemaType', () => { + it('generates schema for required header', () => { + const result = generateHeadersSchemaType({ + 'X-Request-Id': { schema: { type: 'string' }, required: true } + }); + expect(result).toEqual({ + type: 'object', + properties: { 'X-Request-Id': { type: 'string' } }, + required: ['X-Request-Id'], + additionalProperties: { type: 'string' } + }); + }); + + it('generates schema for optional header', () => { + const result = generateHeadersSchemaType({ + 'X-Request-Id': { schema: { type: 'string' } } + }); + expect(result.required).toEqual([]); + }); + + it('generates schema for reference header', () => { + const result = generateHeadersSchemaType({ + 'X-Request-Id': { $ref: '#/components/headers/RequestId' } + }); + expect(result.properties?.['X-Request-Id']).toEqual({ + $ref: '#/components/headers/RequestId' + }); + expect(result.required).toContain('X-Request-Id'); + }); + }); + + describe('generateQueryType', () => { + it('generates empty object for no parameters', () => { + const ctx = createTestContext(); + const result = generateQueryType('GetUsersQuery', undefined, ctx.options.oas, ctx); + const printed = printNodes(result); + expect(printed).toContain('export type ShapeOfGetUsersQuery'); + expect(printed).toContain('GetUsersQuery'); + }); + + it('generates empty object for parameters with no query params', () => { + const ctx = createTestContext(); + const params: oas.ParameterObject[] = [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } } + ]; + const result = generateQueryType('GetUserQuery', params, ctx.options.oas, ctx); + const printed = printNodes(result); + expect(printed).toContain('GetUserQuery'); + }); + + it('generates type for query parameters', () => { + const ctx = createTestContext(); + const params: oas.ParameterObject[] = [ + { name: 'limit', in: 'query', required: true, schema: { type: 'integer' } }, + { name: 'offset', in: 'query', schema: { type: 'integer' } } + ]; + const result = generateQueryType('ListUsersQuery', params, ctx.options.oas, ctx); + const printed = printNodes(result); + expect(printed).toContain('readonly limit!: number'); + expect(printed).toContain('readonly offset?: number'); + }); + + it('handles exploded object parameter', () => { + const ctx = createTestContext(); + const params: oas.ParameterObject[] = [ + { + name: 'filter', + in: 'query', + explode: true, + schema: { + type: 'object', + properties: { name: { type: 'string' } }, + additionalProperties: false + } + } + ]; + const result = generateQueryType('SearchQuery', params, ctx.options.oas, ctx); + const printed = printNodes(result); + expect(printed).toContain('readonly name?: string'); + }); + }); + + describe('generateParameterType', () => { + it('generates void for no parameters', () => { + const ctx = createTestContext(); + const result = generateParameterType( + 'path', + 'GetUsersParams', + undefined, + ctx.options.oas, + ctx + ); + const printed = printNodes(result); + expect(printed).toContain('export type GetUsersParams = void'); + }); + + it('generates void for parameters with no matching type', () => { + const ctx = createTestContext(); + const params: oas.ParameterObject[] = [ + { name: 'limit', in: 'query', schema: { type: 'integer' } } + ]; + const result = generateParameterType('path', 'GetUsersParams', params, ctx.options.oas, ctx); + const printed = printNodes(result); + expect(printed).toContain('export type GetUsersParams = void'); + }); + + it('generates type for path parameters', () => { + const ctx = createTestContext(); + const params: oas.ParameterObject[] = [ + { name: 'userId', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'postId', in: 'path', required: true, schema: { type: 'integer' } } + ]; + const result = generateParameterType('path', 'GetPostParams', params, ctx.options.oas, ctx); + const printed = printNodes(result); + expect(printed).toContain('readonly userId!: string'); + expect(printed).toContain('readonly postId!: number'); + }); + + it('generates type for header parameters', () => { + const ctx = createTestContext(); + const params: oas.ParameterObject[] = [ + { name: 'X-Api-Key', in: 'header', required: true, schema: { type: 'string' } } + ]; + const result = generateParameterType('header', 'AuthHeaders', params, ctx.options.oas, ctx); + const printed = printNodes(result); + expect(printed).toContain('"X-Api-Key"'); + expect(printed).toContain('string'); + }); + + it('applies normalize function to header names', () => { + const ctx = createTestContext(); + const params: oas.ParameterObject[] = [ + { name: 'X-Api-Key', in: 'header', required: true, schema: { type: 'string' } } + ]; + const result = generateParameterType( + 'header', + 'AuthHeaders', + params, + ctx.options.oas, + ctx, + name => name.toLowerCase() + ); + const printed = printNodes(result); + expect(printed).toContain('"x-api-key"'); + }); + }); + + describe('generateRequestBodyType', () => { + it('generates void for no request body', () => { + const ctx = createTestContext(); + const result = generateRequestBodyType('CreateUserBody', undefined, ctx); + const printed = printNodes(result); + expect(printed).toContain('export type CreateUserBody = void'); + }); + + it('generates reference type for reference request body', () => { + const ctx = createTestContext(); + const result = generateRequestBodyType( + 'CreateUserBody', + { $ref: '#/components/requestBodies/UserInput' }, + ctx + ); + const printed = printNodes(result); + expect(printed).toContain('export type CreateUserBody = UserInput'); + }); + + it('generates content schema for required request body', () => { + const ctx = createTestContext(); + const result = generateRequestBodyType( + 'CreateUserBody', + { + required: true, + content: { + 'application/json': { + schema: { type: 'object', properties: { name: { type: 'string' } } } + } + } + }, + ctx + ); + const printed = printNodes(result); + expect(printed).toContain('contentType'); + expect(printed).toContain('"application/json"'); + }); + + it('generates optional union for non-required request body', () => { + const ctx = createTestContext(); + const result = generateRequestBodyType( + 'UpdateUserBody', + { + content: { + 'application/json': { schema: { type: 'object' } } + } + }, + ctx + ); + const printed = printNodes(result); + expect(printed).toContain('type: "union"'); + }); + }); + + describe('generateResponseType', () => { + it('generates response type for single status code', () => { + const ctx = createTestContext(); + const result = generateResponseType( + 'GetUserResponse', + { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { type: 'object', properties: { id: { type: 'string' } } } + } + } + } + }, + ctx + ); + const printed = printNodes(result); + expect(printed).toContain('status'); + expect(printed).toContain('value'); + expect(printed).toContain('headers'); + }); + + it('generates union response type for multiple status codes', () => { + const ctx = createTestContext(); + const result = generateResponseType( + 'CreateUserResponse', + { + '200': { + description: 'Success', + content: { 'application/json': { schema: { type: 'object' } } } + }, + '400': { + description: 'Bad request', + content: { + 'application/json': { + schema: { type: 'object', properties: { error: { type: 'string' } } } + } + } + } + }, + ctx + ); + const printed = printNodes(result); + expect(printed).toContain('type: "union"'); + }); + + it('generates void for empty responses', () => { + const ctx = createTestContext({ emitStatusCode: () => false }); + const result = generateResponseType( + 'DeleteUserResponse', + { + '204': { description: 'No content' } + }, + ctx + ); + const printed = printNodes(result); + expect(printed).toContain('export type DeleteUserResponse = void'); + }); + + it('generates null content for response without content', () => { + const ctx = createTestContext(); + const result = generateResponseType( + 'NoContentResponse', + { + '204': { description: 'No content' } + }, + ctx + ); + const printed = printNodes(result); + expect(printed).toContain('oatsNoContent'); + }); + + it('handles reference response', () => { + const ctx = createTestContext(); + const result = generateResponseType( + 'GetUserResponse', + { + '200': { $ref: '#/components/responses/UserResponse' } + }, + ctx + ); + const printed = printNodes(result); + expect(printed).toContain('type: "named"'); + }); + + it('respects emitStatusCode filter', () => { + const ctx = createTestContext({ emitStatusCode: code => code < 400 }); + const result = generateResponseType( + 'GetUserResponse', + { + '200': { + description: 'Success', + content: { 'application/json': { schema: { type: 'object' } } } + }, + '500': { + description: 'Error', + content: { 'application/json': { schema: { type: 'object' } } } + } + }, + ctx + ); + const printed = printNodes(result); + expect(printed).toContain('200'); + expect(printed).not.toContain('500'); + }); + }); +}); diff --git a/packages/oats/test/codegen/reflection.spec.ts b/packages/oats/test/codegen/reflection.spec.ts new file mode 100644 index 00000000..9fd35bc2 --- /dev/null +++ b/packages/oats/test/codegen/reflection.spec.ts @@ -0,0 +1,333 @@ +import { createContext, GenerationState, Options } from '../../src/codegen/context'; +import { + generateReflectionType, + generateAdditionalPropsReflectionType, + generateIsA, + inventIsA +} from '../../src/codegen/reflection'; + +function printNode(node: string): string { + return node; +} + +function createTestContext(optionOverrides: Partial = {}) { + const options: Options = { + header: '', + sourceFile: './test.yaml', + targetFile: './test.generated.ts', + resolve: () => undefined, + oas: { openapi: '3.0.0', info: { title: 'Test', version: '1.0.0' }, paths: {} }, + runtimeModule: '@smartlyio/oats-runtime', + emitStatusCode: () => true, + nameMapper: (name, kind) => { + const capitalized = name.charAt(0).toUpperCase() + name.slice(1); + if (kind === 'shape') return 'ShapeOf' + capitalized; + if (kind === 'reflection') return 'type' + capitalized; + return capitalized; + }, + ...optionOverrides + }; + + const state: GenerationState = { + cwd: '.', + imports: {}, + actions: [] + }; + + return createContext(options, state, new Set()); +} + +describe('codegen/reflection', () => { + describe('generateReflectionType', () => { + it('generates string reflection', () => { + const ctx = createTestContext(); + const result = generateReflectionType({ type: 'string' }, ctx); + expect(printNode(result)).toBe('{ type: "string" }'); + }); + + it('generates string reflection with enum', () => { + const ctx = createTestContext(); + const result = generateReflectionType({ type: 'string', enum: ['a', 'b'] }, ctx); + expect(printNode(result)).toBe('{ type: "string", enum: ["a", "b"] }'); + }); + + it('generates string reflection with format', () => { + const ctx = createTestContext(); + const result = generateReflectionType({ type: 'string', format: 'date-time' }, ctx); + expect(printNode(result)).toBe('{ type: "string", format: "date-time" }'); + }); + + it('generates string reflection with pattern', () => { + const ctx = createTestContext(); + const result = generateReflectionType({ type: 'string', pattern: '^[a-z]+$' }, ctx); + expect(printNode(result)).toBe('{ type: "string", pattern: "^[a-z]+$" }'); + }); + + it('generates string reflection with length constraints', () => { + const ctx = createTestContext(); + const result = generateReflectionType({ type: 'string', minLength: 1, maxLength: 100 }, ctx); + expect(printNode(result)).toBe('{ type: "string", minLength: 1, maxLength: 100 }'); + }); + + it('generates binary reflection for binary format', () => { + const ctx = createTestContext(); + const result = generateReflectionType({ type: 'string', format: 'binary' }, ctx); + expect(printNode(result)).toBe('{ type: "binary" }'); + }); + + it('generates integer reflection', () => { + const ctx = createTestContext(); + const result = generateReflectionType({ type: 'integer' }, ctx); + expect(printNode(result)).toBe('{ type: "integer" }'); + }); + + it('generates integer reflection with enum', () => { + const ctx = createTestContext(); + const result = generateReflectionType({ type: 'integer', enum: [1, 2, 3] }, ctx); + expect(printNode(result)).toBe('{ type: "integer", enum: [1, 2, 3] }'); + }); + + it('generates number reflection with bounds', () => { + const ctx = createTestContext(); + const result = generateReflectionType({ type: 'number', minimum: 0, maximum: 100 }, ctx); + expect(printNode(result)).toBe('{ type: "number", minimum: 0, maximum: 100 }'); + }); + + it('generates number reflection with negative minimum', () => { + const ctx = createTestContext(); + const result = generateReflectionType({ type: 'number', minimum: -10, maximum: 10 }, ctx); + expect(printNode(result)).toBe('{ type: "number", minimum: -10, maximum: 10 }'); + }); + + it('generates boolean reflection', () => { + const ctx = createTestContext(); + const result = generateReflectionType({ type: 'boolean' }, ctx); + expect(printNode(result)).toBe('{ type: "boolean" }'); + }); + + it('generates boolean reflection with enum', () => { + const ctx = createTestContext(); + const result = generateReflectionType({ type: 'boolean', enum: [true] }, ctx); + expect(printNode(result)).toBe('{ type: "boolean", enum: [true] }'); + }); + + it('generates array reflection', () => { + const ctx = createTestContext(); + const result = generateReflectionType({ type: 'array', items: { type: 'string' } }, ctx); + expect(printNode(result)).toBe('{ type: "array", items: { type: "string" } }'); + }); + + it('generates array reflection with min/max items', () => { + const ctx = createTestContext(); + const result = generateReflectionType( + { + type: 'array', + items: { type: 'string' }, + minItems: 1, + maxItems: 10 + }, + ctx + ); + expect(printNode(result)).toBe( + '{ type: "array", items: { type: "string" }, minItems: 1, maxItems: 10 }' + ); + }); + + it('generates object reflection with required property', () => { + const ctx = createTestContext(); + const result = generateReflectionType( + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }, + ctx + ); + expect(printNode(result)).toBe( + '{ type: "object", additionalProperties: true, properties: { "name": { required: true, value: { type: "string" } } } }' + ); + }); + + it('generates object reflection with optional property', () => { + const ctx = createTestContext(); + const result = generateReflectionType( + { + type: 'object', + properties: { name: { type: 'string' } } + }, + ctx + ); + expect(printNode(result)).toBe( + '{ type: "object", additionalProperties: true, properties: { "name": { required: false, value: { type: "string" } } } }' + ); + }); + + it('generates object reflection with additionalProperties false', () => { + const ctx = createTestContext(); + const result = generateReflectionType( + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false + }, + ctx + ); + expect(printNode(result)).toBe( + '{ type: "object", additionalProperties: false, properties: { "name": { required: true, value: { type: "string" } } } }' + ); + }); + + it('generates object reflection with propertyNameMapper', () => { + const ctx = createTestContext({ + propertyNameMapper: name => (name === 'snake_case' ? 'snakeCase' : name) + }); + const result = generateReflectionType( + { + type: 'object', + properties: { snake_case: { type: 'string' } }, + required: ['snake_case'] + }, + ctx + ); + expect(printNode(result)).toBe( + '{ type: "object", additionalProperties: true, properties: { "snakeCase": { required: true, networkName: "snake_case", value: { type: "string" } } } }' + ); + }); + + it('generates union reflection for oneOf', () => { + const ctx = createTestContext(); + const result = generateReflectionType( + { + oneOf: [{ type: 'string' }, { type: 'number' }] + }, + ctx + ); + expect(printNode(result)).toBe( + '{ type: "union", options: [{ type: "string" }, { type: "number" }] }' + ); + }); + + it('generates intersection reflection for allOf', () => { + const ctx = createTestContext(); + const result = generateReflectionType( + { + allOf: [{ type: 'string' }, { type: 'number' }] + }, + ctx + ); + expect(printNode(result)).toBe( + '{ type: "intersection", options: [{ type: "string" }, { type: "number" }] }' + ); + }); + + it('generates named reference reflection', () => { + const ctx = createTestContext(); + const result = generateReflectionType({ $ref: '#/components/schemas/User' }, ctx); + expect(printNode(result)).toBe('{ type: "named", reference: () => { return typeUser; } }'); + }); + + it('generates nullable reflection as union', () => { + const ctx = createTestContext(); + const result = generateReflectionType({ type: 'string', nullable: true }, ctx); + expect(printNode(result)).toBe( + '{ type: "union", options: [{ type: "string" }, { type: "null" }] }' + ); + }); + + it('generates void reflection', () => { + const ctx = createTestContext(); + const result = generateReflectionType({ type: 'void' as any }, ctx); + expect(printNode(result)).toBe('{ type: "void" }'); + }); + + it('generates null reflection', () => { + const ctx = createTestContext(); + const result = generateReflectionType({ type: 'null' }, ctx); + expect(printNode(result)).toBe('{ type: "null" }'); + }); + + it('generates unknown reflection for missing type', () => { + const ctx = createTestContext(); + const result = generateReflectionType({}, ctx); + expect(printNode(result)).toBe('{ type: "unknown" }'); + }); + }); + + describe('generateAdditionalPropsReflectionType', () => { + it('returns false for additionalProperties: false', () => { + const ctx = createTestContext(); + const result = generateAdditionalPropsReflectionType(false, ctx); + expect(printNode(result)).toBe('false'); + }); + + it('returns true for additionalProperties: true', () => { + const ctx = createTestContext(); + const result = generateAdditionalPropsReflectionType(true, ctx); + expect(printNode(result)).toBe('true'); + }); + + it('returns true for undefined', () => { + const ctx = createTestContext(); + const result = generateAdditionalPropsReflectionType(undefined, ctx); + expect(printNode(result)).toBe('true'); + }); + + it('returns true for empty object', () => { + const ctx = createTestContext(); + const result = generateAdditionalPropsReflectionType({}, ctx); + expect(printNode(result)).toBe('true'); + }); + + it('returns reflection type for schema', () => { + const ctx = createTestContext(); + const result = generateAdditionalPropsReflectionType({ type: 'string' }, ctx); + expect(printNode(result)).toBe('{ type: "string" }'); + }); + }); + + describe('generateIsA', () => { + it('generates instanceof check', () => { + const result = generateIsA('MyClass'); + expect(printNode(result)).toBe('(value: any) => value instanceof MyClass'); + }); + }); + + describe('inventIsA', () => { + it('returns undefined for reference types', () => { + const ctx = createTestContext(); + const result = inventIsA('User', { $ref: '#/components/schemas/Other' }, ctx); + expect(result).toBeUndefined(); + }); + + it('generates instanceof isA for object types', () => { + const ctx = createTestContext(); + const result = inventIsA('User', { type: 'object' }, ctx); + expect(printNode(result!)).toBe('(value: any) => value instanceof User'); + }); + + it('generates maker-based isA for scalar types', () => { + const ctx = createTestContext(); + const result = inventIsA('UserId', { type: 'string' }, ctx); + expect(printNode(result!)).toBe('(value: any) => makeUserId(value).isSuccess()'); + }); + + it('generates maker-based isA for integer types', () => { + const ctx = createTestContext(); + const result = inventIsA('Count', { type: 'integer' }, ctx); + expect(printNode(result!)).toBe('(value: any) => makeCount(value).isSuccess()'); + }); + + it('returns undefined for array types', () => { + const ctx = createTestContext(); + const result = inventIsA('Items', { type: 'array', items: { type: 'string' } }, ctx); + expect(result).toBeUndefined(); + }); + + it('returns undefined for union types', () => { + const ctx = createTestContext(); + const result = inventIsA('Mixed', { oneOf: [{ type: 'string' }, { type: 'number' }] }, ctx); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/oats/test/codegen/test-utils.ts b/packages/oats/test/codegen/test-utils.ts new file mode 100644 index 00000000..e1248c7c --- /dev/null +++ b/packages/oats/test/codegen/test-utils.ts @@ -0,0 +1,32 @@ +/** + * Test utilities for codegen tests. + * Uses Prettier to normalize TypeScript code for comparison. + */ + +import * as prettier from 'prettier'; + +const PRETTIER_CONFIG: prettier.Options = { + parser: 'typescript', + singleQuote: true, + printWidth: 100, + tabWidth: 4, + trailingComma: 'none' +}; + +/** + * Formats TypeScript code using Prettier for normalized comparison. + * Trims and normalizes whitespace so tests don't depend on exact formatting. + */ +export async function format(code: string): Promise { + const formatted = await prettier.format(code, PRETTIER_CONFIG); + return formatted.trim(); +} + +/** + * Asserts that two TypeScript code strings are equivalent after formatting. + */ +export async function expectCodeEqual(actual: string, expected: string): Promise { + const formattedActual = await format(actual); + const formattedExpected = await format(expected); + expect(formattedActual).toBe(formattedExpected); +} diff --git a/packages/oats/test/codegen/types.spec.ts b/packages/oats/test/codegen/types.spec.ts new file mode 100644 index 00000000..2bb0628e --- /dev/null +++ b/packages/oats/test/codegen/types.spec.ts @@ -0,0 +1,367 @@ +import { + createContext, + GenerationState, + Options, + AdditionalPropertiesIndexSignature +} from '../../src/codegen/context'; +import { + generateType, + generateStringType, + generateAdditionalPropType, + generateObjectMembers +} from '../../src/codegen/types'; + +function printNode(node: string): string { + return node; +} + +function createTestContext(optionOverrides: Partial = {}) { + const options: Options = { + header: '', + sourceFile: './test.yaml', + targetFile: './test.generated.ts', + resolve: () => undefined, + oas: { openapi: '3.0.0', info: { title: 'Test', version: '1.0.0' }, paths: {} }, + runtimeModule: '@smartlyio/oats-runtime', + emitStatusCode: () => true, + nameMapper: (name, kind) => { + const capitalized = name.charAt(0).toUpperCase() + name.slice(1); + if (kind === 'shape') return 'ShapeOf' + capitalized; + if (kind === 'reflection') return 'type' + capitalized; + return capitalized; + }, + ...optionOverrides + }; + + const state: GenerationState = { + cwd: '.', + imports: {}, + actions: [] + }; + + return createContext(options, state, new Set()); +} + +describe('codegen/types', () => { + describe('generateType', () => { + it('generates string type', () => { + const ctx = createTestContext(); + const result = generateType({ type: 'string' }, ctx); + expect(printNode(result)).toBe('string'); + }); + + it('generates number type for integer', () => { + const ctx = createTestContext(); + const result = generateType({ type: 'integer' }, ctx); + expect(printNode(result)).toBe('number'); + }); + + it('generates number type for number', () => { + const ctx = createTestContext(); + const result = generateType({ type: 'number' }, ctx); + expect(printNode(result)).toBe('number'); + }); + + it('generates boolean type', () => { + const ctx = createTestContext(); + const result = generateType({ type: 'boolean' }, ctx); + expect(printNode(result)).toBe('boolean'); + }); + + it('generates void type', () => { + const ctx = createTestContext(); + const result = generateType({ type: 'void' as any }, ctx); + expect(printNode(result)).toBe('void'); + }); + + it('generates null type', () => { + const ctx = createTestContext(); + const result = generateType({ type: 'null' }, ctx); + expect(printNode(result)).toBe('null'); + }); + + it('generates unknown for missing type', () => { + const ctx = createTestContext(); + const result = generateType({}, ctx); + expect(printNode(result)).toBe('unknown'); + }); + + it('generates array type', () => { + const ctx = createTestContext(); + const result = generateType({ type: 'array', items: { type: 'string' } }, ctx); + expect(printNode(result)).toBe('ReadonlyArray'); + }); + + it('generates array type with unknown items when items missing', () => { + const ctx = createTestContext(); + const result = generateType({ type: 'array' }, ctx); + expect(printNode(result)).toBe('ReadonlyArray'); + }); + + it('generates nested array type', () => { + const ctx = createTestContext(); + const result = generateType( + { + type: 'array', + items: { type: 'array', items: { type: 'number' } } + }, + ctx + ); + expect(printNode(result)).toBe('ReadonlyArray>'); + }); + + it('generates enum type from string values', () => { + const ctx = createTestContext(); + const result = generateType({ enum: ['a', 'b', 'c'] }, ctx); + expect(printNode(result)).toBe('"a" | "b" | "c"'); + }); + + it('generates enum type from number values', () => { + const ctx = createTestContext(); + const result = generateType({ enum: [1, 2, 3] }, ctx); + expect(printNode(result)).toBe('1 | 2 | 3'); + }); + + it('generates enum type with negative numbers', () => { + const ctx = createTestContext(); + const result = generateType({ enum: [-1, 0, 1] }, ctx); + expect(printNode(result)).toBe('-1 | 0 | 1'); + }); + + it('generates enum type with boolean values', () => { + const ctx = createTestContext(); + const result = generateType({ enum: [true, false] }, ctx); + expect(printNode(result)).toBe('true | false'); + }); + + it('generates union type for oneOf', () => { + const ctx = createTestContext(); + const result = generateType( + { + oneOf: [{ type: 'string' }, { type: 'number' }] + }, + ctx + ); + expect(printNode(result)).toBe('string | number'); + }); + + it('generates intersection type for allOf', () => { + const ctx = createTestContext(); + const result = generateType( + { + allOf: [ + { type: 'object', properties: { a: { type: 'string' } }, additionalProperties: false }, + { type: 'object', properties: { b: { type: 'number' } }, additionalProperties: false } + ] + }, + ctx + ); + expect(printNode(result)).toBe('{ readonly a?: string; } & { readonly b?: number; }'); + }); + + it('generates nullable type', () => { + const ctx = createTestContext(); + const result = generateType({ type: 'string', nullable: true }, ctx); + expect(printNode(result)).toBe('string | null'); + }); + + it('generates object type literal with required property', () => { + const ctx = createTestContext(); + const result = generateType( + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: false + }, + ctx + ); + expect(printNode(result)).toBe('{ readonly name: string; }'); + }); + + it('generates object type literal with optional property', () => { + const ctx = createTestContext(); + const result = generateType( + { + type: 'object', + properties: { name: { type: 'string' } }, + additionalProperties: false + }, + ctx + ); + expect(printNode(result)).toBe('{ readonly name?: string; }'); + }); + + it('generates object type literal with index signature', () => { + const ctx = createTestContext(); + const result = generateType( + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: true + }, + ctx + ); + expect(printNode(result)).toBe('{ readonly name: string; readonly [key: string]: unknown; }'); + }); + + it('generates reference type', () => { + const ctx = createTestContext(); + const result = generateType({ $ref: '#/components/schemas/User' }, ctx); + expect(printNode(result)).toBe('User'); + }); + + it('applies typeMapper to reference types', () => { + const ctx = createTestContext(); + const result = generateType( + { $ref: '#/components/schemas/User' }, + ctx, + name => 'Shape' + name + ); + expect(printNode(result)).toBe('ShapeUser'); + }); + }); + + describe('generateStringType', () => { + it('generates string type for undefined format', () => { + const result = generateStringType(undefined); + expect(printNode(result)).toBe('string'); + }); + + it('generates string type for regular formats', () => { + const result = generateStringType('date-time'); + expect(printNode(result)).toBe('string'); + }); + + it('generates Binary type for binary format', () => { + const result = generateStringType('binary'); + expect(printNode(result)).toBe('oar.make.Binary'); + }); + }); + + describe('generateAdditionalPropType', () => { + it('returns undefined for false', () => { + const ctx = createTestContext(); + const result = generateAdditionalPropType(false, ctx); + expect(result).toBeUndefined(); + }); + + it('returns unknown for true', () => { + const ctx = createTestContext(); + const result = generateAdditionalPropType(true, ctx); + expect(printNode(result!)).toBe('unknown'); + }); + + it('returns unknown for undefined (default)', () => { + const ctx = createTestContext(); + const result = generateAdditionalPropType(undefined, ctx); + expect(printNode(result!)).toBe('unknown'); + }); + + it('returns undefined when configured to omit', () => { + const ctx = createTestContext({ + unknownAdditionalPropertiesIndexSignature: AdditionalPropertiesIndexSignature.omit + }); + const result = generateAdditionalPropType(true, ctx); + expect(result).toBeUndefined(); + }); + + it('generates type union with undefined for schema', () => { + const ctx = createTestContext({ emitUndefinedForIndexTypes: true }); + const result = generateAdditionalPropType({ type: 'string' }, ctx); + expect(printNode(result!)).toBe('string | undefined'); + }); + + it('generates type without undefined when configured', () => { + const ctx = createTestContext({ emitUndefinedForIndexTypes: false }); + const result = generateAdditionalPropType({ type: 'string' }, ctx); + expect(printNode(result!)).toBe('string'); + }); + }); + + describe('generateObjectMembers', () => { + it('generates required property without question mark', () => { + const ctx = createTestContext(); + const result = generateObjectMembers({ name: { type: 'string' } }, ['name'], false, ctx); + + expect(result).toHaveLength(1); + expect(printNode(result[0])).toBe('readonly name: string;'); + }); + + it('generates optional property with question mark', () => { + const ctx = createTestContext(); + const result = generateObjectMembers({ name: { type: 'string' } }, [], false, ctx); + + expect(result).toHaveLength(1); + expect(printNode(result[0])).toBe('readonly name?: string;'); + }); + + it('generates multiple properties', () => { + const ctx = createTestContext(); + const result = generateObjectMembers( + { + id: { type: 'integer' }, + name: { type: 'string' }, + active: { type: 'boolean' } + }, + ['id', 'name'], + false, + ctx + ); + + expect(result).toHaveLength(3); + expect(printNode(result[0])).toBe('readonly id: number;'); + expect(printNode(result[1])).toBe('readonly name: string;'); + expect(printNode(result[2])).toBe('readonly active?: boolean;'); + }); + + it('adds index signature for additional properties true', () => { + const ctx = createTestContext(); + const result = generateObjectMembers({ name: { type: 'string' } }, ['name'], true, ctx); + + expect(result).toHaveLength(2); + expect(printNode(result[0])).toBe('readonly name: string;'); + expect(printNode(result[1])).toBe('readonly [key: string]: unknown;'); + }); + + it('adds typed index signature for additional properties schema', () => { + const ctx = createTestContext({ emitUndefinedForIndexTypes: false }); + const result = generateObjectMembers( + { name: { type: 'string' } }, + ['name'], + { type: 'number' }, + ctx + ); + + expect(result).toHaveLength(2); + expect(printNode(result[1])).toBe('readonly [key: string]: number;'); + }); + + it('applies propertyNameMapper', () => { + const ctx = createTestContext({ + propertyNameMapper: name => (name === 'snake_case' ? 'snakeCase' : name) + }); + const result = generateObjectMembers( + { snake_case: { type: 'string' } }, + ['snake_case'], + false, + ctx + ); + + expect(printNode(result[0])).toBe('readonly snakeCase: string;'); + }); + + it('quotes special property names', () => { + const ctx = createTestContext(); + const result = generateObjectMembers( + { 'content-type': { type: 'string' } }, + ['content-type'], + false, + ctx + ); + + expect(printNode(result[0])).toBe('readonly "content-type": string;'); + }); + }); +}); diff --git a/packages/oats/test/template.spec.ts b/packages/oats/test/template.spec.ts new file mode 100644 index 00000000..128e9194 --- /dev/null +++ b/packages/oats/test/template.spec.ts @@ -0,0 +1,149 @@ +import { ts, quoteProp, str, when, join } from '../src/template'; + +describe('template utilities', () => { + describe('ts tagged template', () => { + it('concatenates strings', () => { + const result = ts`const x = 1; const y = 2;`; + expect(result).toBe('const x = 1; const y = 2;'); + }); + + it('interpolates string values', () => { + const name = 'myVar'; + const result = ts`const ${name} = 1;`; + expect(result).toBe('const myVar = 1;'); + }); + + it('interpolates arrays by joining with newlines', () => { + const members = ['a: string;', 'b: number;']; + const result = ts`interface Foo { ${members} }`; + expect(result).toBe('interface Foo { a: string;\nb: number; }'); + }); + + it('handles empty arrays', () => { + const members: string[] = []; + const result = ts`interface Foo { ${members} }`; + expect(result).toBe('interface Foo { }'); + }); + + it('handles null and undefined values by omitting them', () => { + const maybeValue: string | null = null; + const maybeUndefined: string | undefined = undefined; + const result = ts`const x = "prefix${maybeValue}${maybeUndefined}suffix";`; + expect(result).toBe('const x = "prefixsuffix";'); + }); + + it('handles nested templates', () => { + const inner = 'nested: true,'; + const result = ts`const obj = { ${inner} outer: false };`; + expect(result).toBe('const obj = { nested: true, outer: false };'); + }); + + it('preserves whitespace exactly', () => { + const result = ts` + some + indented + content + `; + expect(result).toBe('\n some\n indented\n content\n '); + }); + }); + + describe('quoteProp', () => { + it('returns simple identifiers unchanged', () => { + expect(quoteProp('foo')).toBe('foo'); + expect(quoteProp('_private')).toBe('_private'); + expect(quoteProp('$jquery')).toBe('$jquery'); + expect(quoteProp('camelCase')).toBe('camelCase'); + expect(quoteProp('PascalCase')).toBe('PascalCase'); + expect(quoteProp('with123numbers')).toBe('with123numbers'); + }); + + it('quotes properties starting with numbers', () => { + expect(quoteProp('123abc')).toBe('"123abc"'); + expect(quoteProp('0')).toBe('"0"'); + }); + + it('quotes properties with special characters', () => { + expect(quoteProp('with-dash')).toBe('"with-dash"'); + expect(quoteProp('with.dot')).toBe('"with.dot"'); + expect(quoteProp('with space')).toBe('"with space"'); + expect(quoteProp('/path/like')).toBe('"/path/like"'); + expect(quoteProp('has:colon')).toBe('"has:colon"'); + }); + + it('handles empty string', () => { + expect(quoteProp('')).toBe('""'); + }); + }); + + describe('str', () => { + it('creates JSON string literals', () => { + expect(str('hello')).toBe('"hello"'); + expect(str('')).toBe('""'); + }); + + it('escapes special characters', () => { + expect(str('line1\nline2')).toBe('"line1\\nline2"'); + expect(str('tab\there')).toBe('"tab\\there"'); + expect(str('quote"here')).toBe('"quote\\"here"'); + expect(str('back\\slash')).toBe('"back\\\\slash"'); + }); + + it('handles unicode', () => { + expect(str('émoji 🎉')).toBe('"émoji 🎉"'); + }); + }); + + describe('when', () => { + it('returns content when condition is true', () => { + expect(when(true, 'included')).toBe('included'); + }); + + it('returns empty string when condition is false', () => { + expect(when(false, 'excluded')).toBe(''); + }); + + it('returns empty string for null condition', () => { + expect(when(null, 'excluded')).toBe(''); + }); + + it('returns empty string for undefined condition', () => { + expect(when(undefined, 'excluded')).toBe(''); + }); + + it('treats truthy values as true', () => { + expect(when(1 as unknown as boolean, 'included')).toBe('included'); + expect(when('yes' as unknown as boolean, 'included')).toBe('included'); + }); + }); + + describe('join', () => { + it('joins strings with separator', () => { + expect(join(['a', 'b', 'c'], ', ')).toBe('a, b, c'); + }); + + it('filters out null values', () => { + expect(join(['a', null, 'c'], ', ')).toBe('a, c'); + }); + + it('filters out undefined values', () => { + expect(join(['a', undefined, 'c'], ', ')).toBe('a, c'); + }); + + it('filters out empty strings', () => { + expect(join(['a', '', 'c'], ', ')).toBe('a, c'); + }); + + it('handles empty array', () => { + expect(join([], ', ')).toBe(''); + }); + + it('handles array with single element', () => { + expect(join(['only'], ', ')).toBe('only'); + }); + + it('handles newline separator', () => { + expect(join(['line1', 'line2'], '\n')).toBe('line1\nline2'); + }); + }); +});