Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/oats/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 0 additions & 21 deletions packages/oats/src/builder.ts

This file was deleted.

83 changes: 83 additions & 0 deletions packages/oats/src/codegen/classes.ts
Original file line number Diff line number Diff line change
@@ -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)
];
}
117 changes: 117 additions & 0 deletions packages/oats/src/codegen/context.ts
Original file line number Diff line number Diff line change
@@ -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<void> }
| { name: string }
| undefined;

export enum AdditionalPropertiesIndexSignature {
emit = 'emit',
omit = 'omit'
}

export interface GenerationState {
cwd: string;
imports: Record<string, string>;
actions: Array<() => Promise<void>>;
}

/**
* 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>): 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<string>
): GenerationContext {
return {
options,

addImport(
importAs: string,
importFile: string | undefined,
action?: () => Promise<void>
): 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);
}
};
}
130 changes: 130 additions & 0 deletions packages/oats/src/codegen/helpers.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading