diff --git a/.prettierignore b/.prettierignore index 675c40126..13f719735 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,6 +10,9 @@ **/.vitepress/dist **/test/generated **/__snapshots__ +**/cache +.angular +.gen **/CHANGELOG.md pnpm-lock.yaml diff --git a/packages/codegen-core/src/__tests__/file.test.ts b/packages/codegen-core/src/__tests__/file.test.ts index bb2d1b1d7..fc8252c4e 100644 --- a/packages/codegen-core/src/__tests__/file.test.ts +++ b/packages/codegen-core/src/__tests__/file.test.ts @@ -2,13 +2,16 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { CodegenFile } from '../files/file'; import type { ICodegenImport } from '../imports/types'; -import type { ICodegenSymbol } from '../symbols/types'; +import { CodegenProject } from '../project/project'; +import type { ICodegenSymbolIn } from '../symbols/types'; describe('CodegenFile', () => { let file: CodegenFile; + let project: CodegenProject; beforeEach(() => { - file = new CodegenFile('a.ts'); + project = new CodegenProject(); + file = new CodegenFile('a.ts', project); }); it('initializes with empty imports and symbols', () => { @@ -111,35 +114,52 @@ describe('CodegenFile', () => { }); it('adds symbols', () => { - const sym1: ICodegenSymbol = { name: 'a' }; - const sym2: ICodegenSymbol = { name: 'b' }; + const sym1: ICodegenSymbolIn = { name: 'a' }; + const sym2: ICodegenSymbolIn = { name: 'b' }; + const sym3: ICodegenSymbolIn = { headless: true, name: 'c' }; file.addSymbol(sym1); file.addSymbol(sym2); + file.addSymbol(sym3); expect(file.symbols.length).toBe(2); + expect(file.symbols[0]).not.toBeUndefined(); expect(file.symbols[0]).not.toBe(sym1); - expect(file.symbols[0]).toEqual(sym1); + expect(file.symbols[0]).toEqual({ + ...sym1, + id: 0, + placeholder: '_heyapi_0_', + }); + expect(file.symbols[1]).not.toBeUndefined(); expect(file.symbols[1]).not.toBe(sym2); - expect(file.symbols[1]).toEqual(sym2); + expect(file.symbols[1]).toEqual({ + ...sym2, + id: 1, + placeholder: '_heyapi_1_', + }); }); - it('merges duplicate symbols', () => { - const sym1: ICodegenSymbol = { + it('updates symbols', () => { + const sym1: ICodegenSymbolIn = { + headless: true, name: 'a', value: 1, }; - const sym2: ICodegenSymbol = { - name: 'a', + const inserted = file.addSymbol(sym1); + expect(file.symbols.length).toBe(0); + + const sym2: ICodegenSymbolIn = { + headless: false, + name: 'b', value: 'foo', }; - - file.addSymbol(sym1); - file.addSymbol(sym2); + file.patchSymbol(inserted.id, sym2); expect(file.symbols.length).toBe(1); expect(file.symbols[0]).toEqual({ - name: 'a', + id: 0, + name: 'b', + placeholder: '_heyapi_0_', value: 'foo', }); }); @@ -162,9 +182,9 @@ describe('CodegenFile', () => { }); it('hasSymbol returns true if symbol exists', () => { - file.addSymbol({ name: 'Exists', value: {} }); - expect(file.hasSymbol('Exists')).toBe(true); - expect(file.hasSymbol('Missing')).toBe(false); + const symbol = file.addSymbol({ name: 'Exists', value: {} }); + expect(file.hasSymbol(symbol.id)).toBe(true); + expect(file.hasSymbol(-1)).toBe(false); }); it('imports, exports, and symbols getters cache arrays and update after add', () => { @@ -182,7 +202,13 @@ describe('CodegenFile', () => { expect(file.imports).toEqual([imp]); file.addSymbol(symbol); - expect(file.symbols).toEqual([symbol]); + expect(file.symbols).toEqual([ + { + ...symbol, + id: 0, + placeholder: '_heyapi_0_', + }, + ]); }); it('returns relative path to another files', () => { diff --git a/packages/codegen-core/src/__tests__/file.ts b/packages/codegen-core/src/__tests__/file.ts new file mode 100644 index 000000000..22240de86 --- /dev/null +++ b/packages/codegen-core/src/__tests__/file.ts @@ -0,0 +1,11 @@ +/* @ts-nocheck */ + +/** + * something about _heyapi_1_. Did you know that __heyapi_1__? + */ +export class _heyapi_1_ { + // _heyapi_1_ is great! + _heyapi_2_(_heyapi_12_: ReturnType<_heyapi_4_>): _heyapi_5_ { + return _heyapi_12_; + } +} diff --git a/packages/codegen-core/src/__tests__/project.test.ts b/packages/codegen-core/src/__tests__/project.test.ts index 588bad69a..032db028a 100644 --- a/packages/codegen-core/src/__tests__/project.test.ts +++ b/packages/codegen-core/src/__tests__/project.test.ts @@ -61,7 +61,11 @@ describe('CodegenProject', () => { const file = project.getFileByPath('a.ts')!; expect(file).toBeDefined(); expect(file.symbols.length).toBe(1); - expect(file.symbols[0]).toEqual(symbol); + expect(file.symbols[0]).toEqual({ + ...symbol, + id: 0, + placeholder: '_heyapi_0_', + }); }); it('getAllSymbols returns all symbols from all files', () => { @@ -87,7 +91,7 @@ describe('CodegenProject', () => { // @ts-expect-error // mutate returned array should not affect internal state - files.push(new CodegenFile('b.ts')); + files.push(new CodegenFile('b.ts', project)); expect(project.files).toEqual([file]); }); diff --git a/packages/codegen-core/src/__tests__/renderer.test.ts b/packages/codegen-core/src/__tests__/renderer.test.ts new file mode 100644 index 000000000..ba60b5355 --- /dev/null +++ b/packages/codegen-core/src/__tests__/renderer.test.ts @@ -0,0 +1,40 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { replacePlaceholders } from '../renderers/renderer'; + +describe('replacePlaceholders', () => { + it('replaces ids with names', () => { + const source = fs.readFileSync(path.resolve(__dirname, 'file.ts'), { + encoding: 'utf8', + }); + + const substitutions: Record = { + _heyapi_12_: 'baz', + _heyapi_1_: 'Foo', + _heyapi_2_: 'bar', + _heyapi_4_: '() => string', + _heyapi_5_: 'string', + }; + + const replaced = replacePlaceholders({ + source, + substitutions, + }); + + expect(replaced).toEqual(`/* @ts-nocheck */ + +/** + * something about Foo. Did you know that _Foo_? + */ +export class Foo { + // Foo is great! + bar(baz: ReturnType<() => string>): string { + return baz; + } +} +`); + }); +}); diff --git a/packages/codegen-core/src/files/file.ts b/packages/codegen-core/src/files/file.ts index 82f19fc87..18f5d56ea 100644 --- a/packages/codegen-core/src/files/file.ts +++ b/packages/codegen-core/src/files/file.ts @@ -1,28 +1,37 @@ import path from 'node:path'; import type { ICodegenImport } from '../imports/types'; -import type { ICodegenSymbol } from '../symbols/types'; +import type { ICodegenProject } from '../project/types'; +import { idToPlaceholder } from '../renderers/renderer'; +import type { + ICodegenSymbolIn, + ICodegenSymbolOut, + ICodegenSymbolSelector, +} from '../symbols/types'; import type { ICodegenFile } from './types'; export class CodegenFile implements ICodegenFile { private cache: { exports?: ReadonlyArray; imports?: ReadonlyArray; - symbols?: ReadonlyArray; + symbols?: ReadonlyArray; } = {}; private state: { exports: Map; imports: Map; - symbols: Map; + renderSymbols: Array; + symbols: Map; } = { exports: new Map(), imports: new Map(), + renderSymbols: [], symbols: new Map(), }; constructor( public path: string, + public project: ICodegenProject, public meta: ICodegenFile['meta'] = {}, ) { let filePath = CodegenFile.pathToFilePath(path); @@ -50,25 +59,57 @@ export class CodegenFile implements ICodegenFile { ): void { const key = typeof value.from === 'string' ? value.from : value.from.path; const existing = this.state[field].get(key); + // cast type names to names to allow for cleaner API, + // otherwise users would have to define the same values twice + if (!value.names) value.names = []; + for (const typeName of value.typeNames ?? []) { + if (!value.names.includes(typeName)) { + value.names = [...value.names, typeName]; + } + } if (existing) { this.mergeImportExportValues(existing, value); this.state[field].set(key, existing); } else { this.state[field].set(key, { ...value }); // clone to avoid mutation } - this.cache[field] = undefined; + this.cache[field] = undefined; // invalidate cache } - addSymbol(symbol: ICodegenSymbol): void { - const key = symbol.name; - const existing = this.state.symbols.get(key); - if (existing) { - existing.value = symbol.value; - this.state.symbols.set(key, existing); - } else { - this.state.symbols.set(key, { ...symbol }); // clone to avoid mutation + private addRenderSymbol(id: number): void { + this.state.renderSymbols.push(id); + this.cache.symbols = undefined; // invalidate cache + } + + addSymbol(symbol: ICodegenSymbolIn): ICodegenSymbolOut { + const id = this.project.incrementId(); + const inserted: ICodegenSymbolOut = { + ...symbol, // clone to avoid mutation + id, + placeholder: idToPlaceholder(id), + }; + if (inserted.value === undefined) { + // register symbols without value as headless + inserted.headless = true; + } else if (!inserted.headless) { + delete inserted.headless; + } + this.state.symbols.set(id, inserted); + this.project.registerSymbol(inserted, this); + if (!inserted.headless) { + this.addRenderSymbol(id); } - this.cache.symbols = undefined; + return inserted; + } + + ensureSymbol( + symbol: Partial & + Pick, 'selector'>, + ): ICodegenSymbolOut { + return ( + this.selectSymbolFirst(symbol.selector) || + this.addSymbol({ name: '', ...symbol }) + ); } get exports(): ReadonlyArray { @@ -78,28 +119,40 @@ export class CodegenFile implements ICodegenFile { return this.cache.exports; } - getAllSymbols(): ReadonlyArray { + getAllSymbols(): ReadonlyArray { return [ ...this.symbols, ...this.imports.flatMap((imp) => (imp.names ?? []).map((name) => ({ + // TODO: real ID + id: -1, name: imp.aliases?.[name] ?? name, + // TODO: real placeholder + placeholder: '', })), ), ...this.exports.flatMap((imp) => (imp.names ?? []).map((name) => ({ + // TODO: real ID + id: -1, name: imp.aliases?.[name] ?? name, + // TODO: real placeholder + placeholder: '', })), ), ]; } + getSymbolById(id: number): ICodegenSymbolOut | undefined { + return this.state.symbols.get(id); + } + hasContent(): boolean { - return this.state.symbols.size > 0 || this.state.exports.size > 0; + return this.state.exports.size > 0 || this.symbols.length > 0; } - hasSymbol(name: string): boolean { - return this.state.symbols.has(name); + hasSymbol(id: number): boolean { + return this.state.symbols.has(id); } get imports(): ReadonlyArray { @@ -134,6 +187,26 @@ export class CodegenFile implements ICodegenFile { } } + patchSymbol( + id: number, + symbol: Partial, + ): ICodegenSymbolOut { + const existing = this.state.symbols.get(id); + if (!existing) { + throw new Error(`symbol with id ${id} not found`); + } + const patched: ICodegenSymbolOut = { ...existing, ...symbol, id }; + // symbols with value can't be headless, clear redundant flag otherwise + if (!patched.headless || patched.value) { + delete patched.headless; + } + this.state.symbols.set(patched.id, patched); + if (existing.headless && !patched.headless) { + this.addRenderSymbol(id); + } + return patched; + } + static pathToFilePath(source: string): string { if (source.includes('/')) { return source.split('/').filter(Boolean).join(path.sep); @@ -166,9 +239,35 @@ export class CodegenFile implements ICodegenFile { return relativePath; } - get symbols(): ReadonlyArray { + selectSymbolAll( + selector: ICodegenSymbolSelector, + ): ReadonlyArray { + return this.project.selectSymbolAll(selector, this); + } + + selectSymbolFirst( + selector: ICodegenSymbolSelector, + ): ICodegenSymbolOut | undefined { + return this.project.selectSymbolFirst(selector, this); + } + + selectSymbolFirstOrThrow( + selector: ICodegenSymbolSelector, + ): ICodegenSymbolOut { + return this.project.selectSymbolFirstOrThrow(selector, this); + } + + selectSymbolLast( + selector: ICodegenSymbolSelector, + ): ICodegenSymbolOut | undefined { + return this.project.selectSymbolLast(selector, this); + } + + get symbols(): ReadonlyArray { if (!this.cache.symbols) { - this.cache.symbols = Array.from(this.state.symbols.values()); + this.cache.symbols = this.state.renderSymbols.map( + (id) => this.state.symbols.get(id)!, + ); } return this.cache.symbols; } diff --git a/packages/codegen-core/src/files/types.d.ts b/packages/codegen-core/src/files/types.d.ts index 9d5bdb8c4..377598579 100644 --- a/packages/codegen-core/src/files/types.d.ts +++ b/packages/codegen-core/src/files/types.d.ts @@ -1,8 +1,13 @@ import type { ICodegenImport } from '../imports/types'; +import type { ICodegenProject } from '../project/types'; import type { ICodegenRenderer } from '../renderers/types'; -import type { ICodegenSymbol } from '../symbols/types'; +import type { + ICodegenSymbolIn, + ICodegenSymbolOut, + SelectorMethods, +} from '../symbols/types'; -export interface ICodegenFile { +export interface ICodegenFile extends SelectorMethods { /** * Adds an export to this file. * @@ -22,7 +27,20 @@ export interface ICodegenFile { * * @param symbol The symbol to add */ - addSymbol(symbol: ICodegenSymbol): void; + addSymbol(symbol: ICodegenSymbolIn): ICodegenSymbolOut; + /** + * Ensures a symbol for the given selector exists, so it can be + * safely used. + * + * @param symbol The symbol to find. The required selector is used + * to match a symbol. If there's no match, we create a headless + * instance with the provided fields. + * @returns The symbol if it exists, headless instance otherwise. + */ + ensureSymbol( + symbol: Partial & + Pick, 'selector'>, + ): ICodegenSymbolOut; /** * Symbols exported from other files. **/ @@ -32,7 +50,14 @@ export interface ICodegenFile { * * @returns List of all symbols used in this file */ - getAllSymbols(): ReadonlyArray; + getAllSymbols(): ReadonlyArray; + /** + * Finds a symbol by symbol ID. + * + * @param id Symbol ID + * @returns The symbol if it exists, undefined otherwise. + */ + getSymbolById(id: number): ICodegenSymbolOut | undefined; /** * Checks if this file contains any content. * @@ -45,10 +70,10 @@ export interface ICodegenFile { /** * Checks if this file defines a symbol with the given name. * - * @param name Symbol name to check + * @param id Symbol ID to check * @returns True if the symbol is defined by this file */ - hasSymbol(name: string): boolean; + hasSymbol(id: number): boolean; /** * Symbols imported from other files. **/ @@ -82,12 +107,26 @@ export interface ICodegenFile { */ renderer?: ICodegenRenderer['id']; }; + /** + * Partially updates a symbol defined by this file. + * + * @param symbol The symbol to patch. + * @returns The patched symbol. + */ + patchSymbol( + id: number, + symbol: Partial, + ): ICodegenSymbolOut; /** * Logical output path (used for writing the file). * * @example "models/user.ts" */ path: string; + /** + * Parent project this file belongs to. + */ + project: ICodegenProject; /** * Returns a relative path to this file from another file. * @@ -105,5 +144,5 @@ export interface ICodegenFile { /** * Top-level symbols declared in this file. **/ - symbols: ReadonlyArray; + symbols: ReadonlyArray; } diff --git a/packages/codegen-core/src/index.ts b/packages/codegen-core/src/index.ts index 39b55a169..ce95ddc5e 100644 --- a/packages/codegen-core/src/index.ts +++ b/packages/codegen-core/src/index.ts @@ -6,4 +6,4 @@ export type { ICodegenOutput } from './output/types'; export { CodegenProject } from './project/project'; export type { ICodegenProject } from './project/types'; export type { ICodegenRenderer } from './renderers/types'; -export type { ICodegenSymbol } from './symbols/types'; +export type { ICodegenSymbolIn, ICodegenSymbolOut } from './symbols/types'; diff --git a/packages/codegen-core/src/project/project.ts b/packages/codegen-core/src/project/project.ts index f859c69f3..964acd682 100644 --- a/packages/codegen-core/src/project/project.ts +++ b/packages/codegen-core/src/project/project.ts @@ -1,44 +1,67 @@ import { CodegenFile } from '../files/file'; +import type { ICodegenFile } from '../files/types'; import type { ICodegenImport } from '../imports/types'; import type { ICodegenMeta } from '../meta/types'; import type { ICodegenOutput } from '../output/types'; import type { ICodegenRenderer } from '../renderers/types'; -import type { ICodegenSymbol } from '../symbols/types'; +import type { + ICodegenSymbolIn, + ICodegenSymbolOut, + ICodegenSymbolSelector, +} from '../symbols/types'; import type { ICodegenProject } from './types'; export class CodegenProject implements ICodegenProject { - private filesMap: Map = new Map(); - private filesOrder: Array = []; - private renderers: Map = new Map(); + private state: { + files: Map; + filesOrder: Array; + id: number; + idToFile: Map; + renderers: Map; + selectorToIds: Map>; + } = { + files: new Map(), + filesOrder: [], + id: 0, + idToFile: new Map(), + renderers: new Map(), + selectorToIds: new Map(), + }; - addExportToFile(fileOrPath: CodegenFile | string, imp: ICodegenImport): void { + addExportToFile( + fileOrPath: ICodegenFile | string, + imp: ICodegenImport, + ): void { const file = this.ensureFile(fileOrPath); file.addExport(imp); } - addImportToFile(fileOrPath: CodegenFile | string, imp: ICodegenImport): void { + addImportToFile( + fileOrPath: ICodegenFile | string, + imp: ICodegenImport, + ): void { const file = this.ensureFile(fileOrPath); file.addImport(imp); } addSymbolToFile( - fileOrPath: CodegenFile | string, - symbol: ICodegenSymbol, - ): void { + fileOrPath: ICodegenFile | string, + symbol: ICodegenSymbolIn, + ): ICodegenSymbolOut { const file = this.ensureFile(fileOrPath); - file.addSymbol(symbol); + return file.addSymbol(symbol); } createFile( path: string, - meta: Omit & { + meta: Omit & { /** * Renderer to use to render this file. */ renderer?: ICodegenRenderer; } = {}, - ): CodegenFile { - const { renderer, ...metadata } = meta; + ): ICodegenFile { + const { renderer, ..._meta } = meta; if (renderer) { this.ensureRenderer(renderer); } @@ -52,16 +75,16 @@ export class CodegenProject implements ICodegenProject { return existing; } - const file = new CodegenFile(path, { - ...metadata, + const file = new CodegenFile(path, this, { + ..._meta, renderer: renderer?.id, }); - this.filesOrder.push(file); - this.filesMap.set(path, file); + this.state.filesOrder.push(file); + this.state.files.set(path, file); return file; } - ensureFile(fileOrPath: CodegenFile | string): CodegenFile { + ensureFile(fileOrPath: ICodegenFile | string): ICodegenFile { if (typeof fileOrPath !== 'string') { return fileOrPath; } @@ -73,32 +96,97 @@ export class CodegenProject implements ICodegenProject { } private ensureRenderer(renderer: ICodegenRenderer): ICodegenRenderer { - if (!this.renderers.has(renderer.id)) { - this.renderers.set(renderer.id, renderer); + if (!this.state.renderers.has(renderer.id)) { + this.state.renderers.set(renderer.id, renderer); } - return this.renderers.get(renderer.id)!; + return this.state.renderers.get(renderer.id)!; + } + + get files(): ReadonlyArray { + return [...this.state.filesOrder]; } - get files(): ReadonlyArray { - return [...this.filesOrder]; + getAllSymbols(): ReadonlyArray { + return this.state.filesOrder.flatMap((file) => file.getAllSymbols()); + } + + getFileByPath(path: string): ICodegenFile | undefined { + return this.state.files.get(path); + } + + getFileBySymbol(symbol: Pick): ICodegenFile { + const file = this.state.idToFile.get(symbol.id); + if (!file) + throw new Error(`file for symbol not found: ${String(symbol.id)}`); + return file; } - getAllSymbols(): ReadonlyArray { - return this.filesOrder.flatMap((file) => file.getAllSymbols()); + incrementId(): number { + return this.state.id++; } - getFileByPath(path: string): CodegenFile | undefined { - return this.filesMap.get(path); + registerSymbol(symbol: ICodegenSymbolOut, file: ICodegenFile): void { + this.state.idToFile.set(symbol.id, file); + if (symbol.selector) { + const selector = JSON.stringify(symbol.selector); + const ids = this.state.selectorToIds.get(selector) ?? []; + ids.push(symbol.id); + this.state.selectorToIds.set(selector, ids); + } } render(meta?: ICodegenMeta): ReadonlyArray { const results: Array = []; - for (const file of this.filesOrder) { + for (const file of this.state.filesOrder) { if (!file.meta.renderer) continue; - const renderer = this.renderers.get(file.meta.renderer); + const renderer = this.state.renderers.get(file.meta.renderer); if (!renderer) continue; results.push(renderer.render(file, meta)); } return results; } + + selectSymbolAll( + selector: ICodegenSymbolSelector, + file?: ICodegenFile, + ): ReadonlyArray { + const ids = this.state.selectorToIds.get(JSON.stringify(selector)) ?? []; + const symbols: Array = []; + for (const id of ids) { + const f = this.state.idToFile.get(id); + if (!f || (file && file !== f)) continue; + const symbol = f.getSymbolById(id); + if (!symbol) continue; + symbols.push(symbol); + } + return symbols; + } + + selectSymbolFirst( + selector: ICodegenSymbolSelector, + file?: ICodegenFile, + ): ICodegenSymbolOut | undefined { + const symbols = this.selectSymbolAll(selector, file); + return symbols[0]; + } + + selectSymbolFirstOrThrow( + selector: ICodegenSymbolSelector, + file?: ICodegenFile, + ): ICodegenSymbolOut { + const symbol = this.selectSymbolFirst(selector, file); + if (!symbol) + throw new Error( + `symbol for selector not found: ${JSON.stringify(selector)}`, + ); + return symbol; + } + + selectSymbolLast( + selector: ICodegenSymbolSelector, + file?: ICodegenFile, + ): ICodegenSymbolOut | undefined { + const symbols = this.selectSymbolAll(selector, file); + return symbols[symbols.length - 1]; + } } diff --git a/packages/codegen-core/src/project/types.d.ts b/packages/codegen-core/src/project/types.d.ts index 46551e2dc..f6a5bf6df 100644 --- a/packages/codegen-core/src/project/types.d.ts +++ b/packages/codegen-core/src/project/types.d.ts @@ -3,13 +3,17 @@ import type { ICodegenImport } from '../imports/types'; import type { ICodegenMeta } from '../meta/types'; import type { ICodegenOutput } from '../output/types'; import type { ICodegenRenderer } from '../renderers/types'; -import type { ICodegenSymbol } from '../symbols/types'; +import type { + ICodegenSymbolIn, + ICodegenSymbolOut, + SelectorMethods, +} from '../symbols/types'; /** * Represents a code generation project consisting of multiple codegen files. * Manages imports, symbols, and output generation across the project. */ -export interface ICodegenProject { +export interface ICodegenProject extends SelectorMethods { /** * Adds an export declaration to a specific file, creating the file if it doesn't exist. * @@ -33,13 +37,14 @@ export interface ICodegenProject { * * @param fileOrPath - File instance or file path where to add the symbol. * @param symbol - The symbol to add. + * @returns The inserted symbol. * @example * project.addSymbolToFile("models/user.ts", { name: "User", value: tsNode }); */ addSymbolToFile( fileOrPath: ICodegenFile | string, - symbol: ICodegenSymbol, - ): void; + symbol: ICodegenSymbolIn, + ): ICodegenSymbolOut; /** * Creates a new codegen file with optional metadata and adds it to the project. * @@ -82,7 +87,7 @@ export interface ICodegenProject { * @example * project.getAllSymbols().filter(s => s.name === "User"); */ - getAllSymbols(): ReadonlyArray; + getAllSymbols(): ReadonlyArray; /** * Retrieves a file by its logical output path. * @@ -92,6 +97,28 @@ export interface ICodegenProject { * const file = project.getFileByPath("models/user.ts"); */ getFileByPath(path: string): ICodegenFile | undefined; + /** + * Retrieves a file from symbol ID included in the file. + * + * @param id The symbol ID to find. + * @returns The file if found, or throw otherwise. + * @example + * const file = project.getFileBySymbol(symbol); + */ + getFileBySymbol(symbol: Pick): ICodegenFile; + /** + * Returns the current ID and increments it. + * + * @returns ID before being incremented + */ + incrementId(): number; + /** + * Tracks added symbol across the project. + * + * @param symbol The symbol added to file. + * @param file The file containing the added symbol. + */ + registerSymbol(symbol: ICodegenSymbolOut, file: ICodegenFile): void; /** * Produces output representations for all files in the project. * diff --git a/packages/codegen-core/src/renderers/renderer.ts b/packages/codegen-core/src/renderers/renderer.ts new file mode 100644 index 000000000..2e27c76c3 --- /dev/null +++ b/packages/codegen-core/src/renderers/renderer.ts @@ -0,0 +1,41 @@ +/** + * Wraps an ID in namespace to avoid collisions when replacing it. + * + * @param id Stringified ID to use. + * @returns The wrapped placeholder ID. + */ +const wrapId = (id: string): string => `_heyapi_${id}_`; + +/** + * Returns a RegExp instance to match ID placeholders. + * + * @returns RegExp instance to match ID placeholders. + */ +export const createPlaceholderRegExp = (): RegExp => + new RegExp(wrapId('\\d+'), 'g'); + +/** + * Generates a placeholder ID. + * + * @param id the numeric ID to use. + * @returns The wrapped placeholder ID. + */ +export const idToPlaceholder = (id: number): string => wrapId(String(id)); + +/** + * @returns The replaced source string. + */ +export const replacePlaceholders = ({ + source, + substitutions, +}: { + source: string; + /** + * Map of IDs and their final names. + */ + substitutions: Record; +}): string => + source.replace( + createPlaceholderRegExp(), + (match) => substitutions[match] || match, + ); diff --git a/packages/codegen-core/src/renderers/types.d.ts b/packages/codegen-core/src/renderers/types.d.ts index 1c0f10033..a7225866c 100644 --- a/packages/codegen-core/src/renderers/types.d.ts +++ b/packages/codegen-core/src/renderers/types.d.ts @@ -1,4 +1,4 @@ -import type { CodegenFile } from '../files/file'; +import type { ICodegenFile } from '../files/types'; import type { ICodegenMeta } from '../meta/types'; import type { ICodegenOutput } from '../output/types'; @@ -20,5 +20,5 @@ export interface ICodegenRenderer { * @param meta Arbitrary metadata. * @returns Output for file emit step */ - render(file: CodegenFile, meta?: ICodegenMeta): ICodegenOutput; + render(file: ICodegenFile, meta?: ICodegenMeta): ICodegenOutput; } diff --git a/packages/codegen-core/src/symbols/types.d.ts b/packages/codegen-core/src/symbols/types.d.ts index 5a07ebcdb..b9fefb15d 100644 --- a/packages/codegen-core/src/symbols/types.d.ts +++ b/packages/codegen-core/src/symbols/types.d.ts @@ -1,25 +1,119 @@ -export interface ICodegenSymbol { +import type { ICodegenFile } from '../files/types'; + +/** + * Selector array used to select symbols. It doesn't have to be + * unique, but in practice it might be desirable. + * + * @example ["zod", "#/components/schemas/Foo"] + */ +export type ICodegenSymbolSelector = ReadonlyArray; + +export interface ICodegenSymbolIn { /** - * Optional description or doc comment. + * Symbols can be **headed** or **headless**. * - * @example "Represents a user in the system" - */ - description?: string; - /** - * Optional kind of symbol (e.g. "class", "function", "type", etc.). + * Headless symbols never render their `value`. Headed symbols render their + * `value` if defined. + * + * Symbols are rendered in the order they were registered as headed. + * + * Example 1: We register headless symbol `foo`, headed `bar`, and headed + * `foo`. The render order is [`bar`, `foo`]. * - * @example "class" + * Example 2: We register headed symbol `foo` and headed `bar`. The render + * order is [`foo`, `bar`]. + * + * Headless symbols can be used to claim a symbol or to represent imports + * or exports. + * + * @default false */ - kind?: string; + headless?: boolean; /** - * Unique identifier for the symbol within its file. + * The desired name for the symbol within its file. If there are multiple symbols + * with the same desired name, this might not end up being the actual name. * * @example "UserModel" */ name: string; /** - * Internal representation of the symbol (e.g. AST node, IR object, raw - * code). Used to generate output. + * Selector array used to select this symbol. It doesn't have to be + * unique, but in practice it might be desirable. + * + * @example ["zod", "#/components/schemas/Foo"] + */ + selector?: ICodegenSymbolSelector; + /** + * Internal representation of the symbol (e.g. AST node, IR object, raw code). + * Used to generate output. If left undefined, this symbol becomes `headless`. */ value?: unknown; } + +export interface ICodegenSymbolOut extends ICodegenSymbolIn { + /** + * Unique symbol ID. + */ + id: number; + /** + * Placeholder name for the symbol to be replaced later with the final value. + * + * @example "_heyapi_31_" + */ + placeholder: string; +} + +export interface SelectorMethods { + /** + * Retrieves symbols matching the selector. + * + * @param selector The symbol selector to find. + * @param file Find symbols only in this file. + * @returns The array of all symbols matching the selector. + * @example + * const symbols = project.selectSymbolAll(["zod", "#/components/schemas/Foo"]); + */ + selectSymbolAll( + selector: ICodegenSymbolSelector, + file?: ICodegenFile, + ): ReadonlyArray; + /** + * Retrieves the first symbol from all symbols matching the selector. + * + * @param selector The symbol selector to find. + * @param file Find symbols only in this file. + * @returns The symbol if found, or undefined otherwise. + * @example + * const symbol = project.selectSymbolFirst(["zod", "#/components/schemas/Foo"]); + */ + selectSymbolFirst( + selector: ICodegenSymbolSelector, + file?: ICodegenFile, + ): ICodegenSymbolOut | undefined; + /** + * Retrieves the first symbol from all symbols matching the selector. + * + * @param selector The symbol selector to find. + * @param file Find symbols only in this file. + * @returns The symbol if found, or throw otherwise. + * @example + * const symbol = project.selectSymbolFirstOrThrow(["zod", "#/components/schemas/Foo"]); + */ + selectSymbolFirstOrThrow( + selector: ICodegenSymbolSelector, + file?: ICodegenFile, + ): ICodegenSymbolOut; + /** + * Retrieves the last symbol from all symbols matching the selector. + * + * @param selector The symbol selector to find. + * @param file Find symbols only in this file. + * @returns The symbol if found, or undefined otherwise. + * @example + * const symbol = project.selectSymbolLast(["zod", "#/components/schemas/Foo"]); + */ + selectSymbolLast( + selector: ICodegenSymbolSelector, + file?: ICodegenFile, + ): ICodegenSymbolOut | undefined; +} diff --git a/packages/openapi-ts-tests/main/test/openapi-ts.config.ts b/packages/openapi-ts-tests/main/test/openapi-ts.config.ts index 592ceda11..db3b43f9f 100644 --- a/packages/openapi-ts-tests/main/test/openapi-ts.config.ts +++ b/packages/openapi-ts-tests/main/test/openapi-ts.config.ts @@ -36,8 +36,9 @@ export default defineConfig(() => { '3.1.x', // 'invalid', // 'openai.yaml', - // 'full.yaml', - 'opencode.yaml', + 'full.yaml', + // 'opencode.yaml', + // 'validators-circular-ref-2.yaml', // 'zoom-video-sdk.json', ), // https://registry.scalar.com/@lubos-heyapi-dev-team/apis/demo-api-scalar-galaxy/latest?format=json @@ -137,7 +138,7 @@ export default defineConfig(() => { { // baseUrl: false, // exportFromIndex: true, - name: '@hey-api/client-fetch', + // name: '@hey-api/client-fetch', // name: 'legacy/angular', // strictBaseUrl: true, // throwOnError: true, @@ -155,7 +156,7 @@ export default defineConfig(() => { // error: '他們_error_{{name}}', // name: '你們_errors_{{name}}', // }, - name: '@hey-api/typescript', + // name: '@hey-api/typescript', // requests: '我們_data_{{name}}', // responses: { // name: '我_responses_{{name}}', @@ -181,10 +182,10 @@ export default defineConfig(() => { // responseStyle: 'data', // transformer: '@hey-api/transformers', // transformer: true, - // validator: 'valibot', + validator: 'valibot', // validator: { - // request: 'zod', - // response: 'zod', + // request: 'valibot', + // response: 'valibot', // }, }, { @@ -208,7 +209,7 @@ export default defineConfig(() => { // mutationOptions: { // name: '{{name}}MO', // }, - name: '@tanstack/react-query', + // name: '@tanstack/react-query', // queryKeys: { // name: '{{name}}QK', // }, @@ -250,7 +251,7 @@ export default defineConfig(() => { { // case: 'snake_case', // comments: false, - compatibilityVersion: 3, + compatibilityVersion: 4, dates: { local: true, // offset: true, @@ -278,11 +279,11 @@ export default defineConfig(() => { // infer: 'F{{name}}ResponseZodType', // }, // }, - // types: { - // infer: { - // case: 'snake_case', - // }, - // }, + types: { + // infer: { + // case: 'snake_case', + // }, + }, }, { exportFromIndex: true, @@ -298,7 +299,7 @@ export default defineConfig(() => { { // groupByTag: true, // mutationOptions: '{{name}}Mutationssss', - name: '@pinia/colada', + // name: '@pinia/colada', // queryOptions: { // name: '{{name}}Queryyyyy', // }, diff --git a/packages/openapi-ts/src/generate/file/index.ts b/packages/openapi-ts/src/generate/file/index.ts index 86cc97edf..225f32380 100644 --- a/packages/openapi-ts/src/generate/file/index.ts +++ b/packages/openapi-ts/src/generate/file/index.ts @@ -17,8 +17,8 @@ import type { Identifiers, Namespace, NodeInfo, - NodeReference, } from './types'; + export class GeneratedFile { private _case: StringCase | undefined; /** @@ -49,11 +49,6 @@ export class GeneratedFile { * ``` */ private names: Record = {}; - /** - * Another approach for named nodes, with proper support for renaming. Keys - * are node IDs and values are an array of references for given ID. - */ - private nodeReferences: Record> = {}; /** * Text value from node is kept in sync with `names`. * @@ -70,17 +65,8 @@ export class GeneratedFile { * } * ``` */ - // TODO: nodes can be possibly replaced with `nodeReferences`, i.e. keep - // the name `nodes` and rewrite their functionality private nodes: Record = {}; - /** - * Path relative to the client output root. - */ - // TODO: parser - add relative path property for quick access, currently - // everything is resolved into an absolute path with cwd - // public relativePath: string; - public constructor({ case: _case, dir, @@ -121,25 +107,6 @@ export class GeneratedFile { this._items = this._items.concat(nodes); } - /** - * Adds a reference node for a name. This can be used later to rename - * identifiers. - */ - public addNodeReference( - id: string, - node: Pick, 'factory'>, - ): T { - if (!this.nodeReferences[id]) { - this.nodeReferences[id] = []; - } - const result = node.factory(this.names[id] ?? ''); - this.nodeReferences[id].push({ - factory: node.factory, - node: result as void, - }); - return result; - } - public get exportFromIndex(): boolean { return this._exportFromIndex; } @@ -403,28 +370,6 @@ export class GeneratedFile { return this.nodes[id]; } - /** - * Updates collected reference nodes for a name with the latest value. - * - * @param id Node ID. - * @param name Updated name for the nodes. - * @returns noop - */ - public updateNodeReferences(id: string, name: string): void { - if (!this.nodeReferences[id]) { - return; - } - const finalName = getUniqueComponentName({ - base: ensureValidIdentifier(name), - components: Object.values(this.names), - }); - this.names[id] = finalName; - for (const node of this.nodeReferences[id]) { - const nextNode = node.factory(finalName); - Object.assign(node.node as unknown as object, nextNode); - } - } - public write(separator = '\n', tsConfig: ts.ParsedCommandLine | null = null) { if (this.isEmpty()) { this.remove({ force: true }); diff --git a/packages/openapi-ts/src/generate/file/types.d.ts b/packages/openapi-ts/src/generate/file/types.d.ts index d45928737..2512021e1 100644 --- a/packages/openapi-ts/src/generate/file/types.d.ts +++ b/packages/openapi-ts/src/generate/file/types.d.ts @@ -75,17 +75,3 @@ export type NodeInfo = { */ node: ts.TypeReferenceNode; }; - -export type NodeReference = { - /** - * Factory function that creates the node reference. - * - * @param name Identifier name. - * @returns Reference to the node object. - */ - factory: (name: string) => T; - /** - * Reference to the node object. - */ - node: T; -}; diff --git a/packages/openapi-ts/src/generate/renderer.ts b/packages/openapi-ts/src/generate/renderer.ts index 818a96d9f..1072158f2 100644 --- a/packages/openapi-ts/src/generate/renderer.ts +++ b/packages/openapi-ts/src/generate/renderer.ts @@ -144,7 +144,7 @@ export class TypeScriptRenderer implements ICodegenRenderer { for (const value of group) { if (value.defaultImport) { - defaultImport = ts.factory.createIdentifier(value.defaultImport); + defaultImport = tsc.identifier({ text: value.defaultImport }); if (value.typeDefaultImport) { isTypeOnly = true; } @@ -159,21 +159,35 @@ export class TypeScriptRenderer implements ICodegenRenderer { } } - for (const name of value.names ?? []) { - const alias = value.aliases?.[name]; - const id = tsc.identifier({ text: name }); - const spec = - alias && alias !== name - ? ts.factory.createImportSpecifier( - false, - id, - tsc.identifier({ text: alias }), - ) - : ts.factory.createImportSpecifier(false, undefined, id); - if (value.typeNames?.includes(name)) { + if (value.names && value.names.length > 0) { + if ( + !isTypeOnly && + value.names.every((name) => value.typeNames?.includes(name)) + ) { isTypeOnly = true; } - specifiers.push(spec); + + for (const name of value.names) { + const alias = value.aliases?.[name]; + const id = tsc.identifier({ text: name }); + const spec = + alias && alias !== name + ? ts.factory.createImportSpecifier( + isTypeOnly + ? false + : (value.typeNames?.includes(name) ?? false), + id, + tsc.identifier({ text: alias }), + ) + : ts.factory.createImportSpecifier( + isTypeOnly + ? false + : (value.typeNames?.includes(name) ?? false), + undefined, + id, + ); + specifiers.push(spec); + } } } diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-angular/bundle/client.ts b/packages/openapi-ts/src/plugins/@hey-api/client-angular/bundle/client.ts index 9a9b6b7b6..7083d5bd5 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/client-angular/bundle/client.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/client-angular/bundle/client.ts @@ -1,258 +1,257 @@ -import type { HttpResponse } from '@angular/common/http'; import { HttpClient, - HttpErrorResponse, - HttpEventType, + HttpContextToken, HttpRequest, } from '@angular/common/http'; -import { - assertInInjectionContext, - inject, - provideAppInitializer, - runInInjectionContext, -} from '@angular/core'; -import { firstValueFrom } from 'rxjs'; -import { filter } from 'rxjs/operators'; - -import { createSseClient } from '../../client-core/bundle/serverSentEvents'; +import { inject, Injectable, InjectionToken } from '@angular/core'; + import type { HttpMethod } from '../../client-core/bundle/types'; import type { Client, Config, + Options, RequestOptions, - ResolvedRequestOptions, ResponseStyle, + SseFn, + TDataShape, } from './types'; import { - buildUrl, createConfig, - createInterceptors, + createQuerySerializer, + getUrl, + mapToResponseStyle, mergeConfigs, mergeHeaders, - setAuthParams, } from './utils'; -export function provideHeyApiClient(client: Client) { - return provideAppInitializer(() => { - const httpClient = inject(HttpClient); - client.setConfig({ httpClient }); - }); +// default injection token for the client (allows replacing impl and multiple clients) +export const DEFAULT_HEY_API_CLIENT = new InjectionToken( + 'HEY_API_CLIENT', +); +export const HEY_API_CONTEXT = new HttpContextToken(() => ({})); + +/** + * Provide HeyApiClient with the given configuration. + * @param userConfig + * @returns + */ +export function provideHeyApiClient(userConfig: Config) { + const Klass = userConfig.client ?? HeyApiClient; + + return { + deps: [], + provide: DEFAULT_HEY_API_CLIENT, + useFactory: () => new Klass(userConfig), + }; } -export const createClient = (config: Config = {}): Client => { - let _config = mergeConfigs(createConfig(), config); +export class HeyApiSseClient implements Record { + constructor(private client: Client) { + console.log(this.client); + } + + makeSseFn = + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_method: Uppercase) => + < + TData = unknown, + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = false, + >( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _options: RequestOptions, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _overrides?: HttpRequest, + ): never => { + throw new Error('Method not implemented.'); + // TODO: Also done thru interceptor + // const requestOptions = this.beforeRequest(options); + // return createSseClient({ + // ...requestOptions, + // body: requestOptions.body as BodyInit | null | undefined, + // headers: requestOptions.headers as unknown as Record, + // method, + // url, + // }); + }; + + connect = this.makeSseFn('CONNECT'); + delete = this.makeSseFn('DELETE'); + get = this.makeSseFn('GET'); + head = this.makeSseFn('HEAD'); + options = this.makeSseFn('OPTIONS'); + patch = this.makeSseFn('PATCH'); + post = this.makeSseFn('POST'); + put = this.makeSseFn('PUT'); + trace = this.makeSseFn('TRACE'); +} - const getConfig = (): Config => ({ ..._config }); +@Injectable({ providedIn: 'root' }) +export class HeyApiClient implements Client { + #config: Config; + #httpClient = inject(HttpClient); + sse: HeyApiSseClient = new HeyApiSseClient(this); + + constructor(readonly userConfig: Config) { + this.#config = mergeConfigs(createConfig(), userConfig); + } + + getConfig(): Config { + return { ...this.#config }; + } + + setConfig(config: Config): Config { + this.#config = mergeConfigs(this.#config, config); + return this.getConfig(); + } + + buildUrl( + options: Pick & + Pick< + Options, + 'baseUrl' | 'path' | 'query' | 'querySerializer' + >, + ) { + return getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + } + + request< + TResponseBody, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', + >( + userOptions: RequestOptions, + overrides?: HttpRequest, + ) { + const req = this.requestOptions(userOptions, overrides); - const setConfig = (config: Config): Config => { - _config = mergeConfigs(_config, config); - return getConfig(); - }; + const stream$ = this.#httpClient.request(req); + mapToResponseStyle(stream$, userOptions); - const interceptors = createInterceptors< - HttpRequest, - HttpResponse, - unknown, - ResolvedRequestOptions - >(); + return stream$; + } - const requestOptions = < + requestOptions< + TRequestBody, ThrowOnError extends boolean = false, TResponseStyle extends ResponseStyle = 'fields', >( - options: RequestOptions, - ) => { - const opts = { - ..._config, - ...options, - headers: mergeHeaders(_config.headers, options.headers), - httpClient: options.httpClient ?? _config.httpClient, - serializedBody: options.body as any, + userOptions: RequestOptions, + overrides?: HttpRequest, + ): HttpRequest { + const bodyToUse = overrides?.body ?? userOptions.body; + + const requestOptions = { + ...this.#config, + ...userOptions, + body: + bodyToUse && userOptions.bodySerializer + ? userOptions.bodySerializer(bodyToUse) + : bodyToUse, + headers: mergeHeaders( + this.#config.headers, + userOptions.headers, + overrides?.headers, + ), + method: + overrides?.method ?? userOptions.method ?? this.#config.method ?? 'GET', + url: overrides?.url ?? this.buildUrl(userOptions), }; - if (!opts.httpClient) { - if (opts.injector) { - opts.httpClient = runInInjectionContext(opts.injector, () => - inject(HttpClient), - ); - } else { - assertInInjectionContext(requestOptions); - opts.httpClient = inject(HttpClient); - } + if (requestOptions.body === undefined || requestOptions.body === '') { + requestOptions.headers?.delete('Content-Type'); } - if (opts.body && opts.bodySerializer) { - opts.serializedBody = opts.bodySerializer(opts.body); - } + const { body, method, url, ...init } = requestOptions; - // remove Content-Type header if body is empty to avoid sending invalid requests - if (opts.serializedBody === undefined || opts.serializedBody === '') { - opts.headers.delete('Content-Type'); - } + const initOverrides = { ...overrides }; + delete initOverrides.method; + delete initOverrides.url; + delete initOverrides.body; - const url = buildUrl(opts as any); - - const req = new HttpRequest( - opts.method ?? 'GET', + const req = new HttpRequest( + method ?? 'GET', url, - opts.serializedBody || null, + body ?? null, { redirect: 'follow', - ...opts, + ...init, + ...initOverrides, }, ); - return { opts, req, url }; - }; + req.context.set(HEY_API_CONTEXT, { + overrides, + requestOptions, + }); - const beforeRequest = async (options: RequestOptions) => { - const { opts, req, url } = requestOptions(options); + // TODO: Move firstValueFrom to config and include in sdks - if (opts.security) { - await setAuthParams({ - ...opts, - security: opts.security, - }); - } + // result.response = await firstValueFrom( + // opts + // .httpClient!.request(req) + // .pipe(filter((event: any): event is HttpResponse => event.type === HttpEventType.Response)), + // ); - if (opts.requestValidator) { - await opts.requestValidator(opts); - } + //// RESPONSE - return { opts, req, url }; - }; + // TODO: Move this to interceptor + // if (opts.responseValidator) { + // await opts.responseValidator(bodyResponse); + // } - const request: Client['request'] = async (options) => { - // @ts-expect-error - const { opts, req: initialReq } = await beforeRequest(options); + // if (opts.responseTransformer) { + // bodyResponse = await opts.responseTransformer(bodyResponse); + // } + return req; + } - let req = initialReq; + beforeRequest = async (options: RequestOptions) => { + const requestOptions = this.requestOptions(options); - for (const fn of interceptors.request._fns) { - if (fn) { - req = await fn(req, opts as any); - } - } + // TODO: Move this to interceptor + // if (requestOptions.security) { + // await setAuthParams({ + // ...requestOptions, + // security: requestOptions.security, + // }); + // } - const result: { - request: HttpRequest; - response: any; - } = { - request: req, - response: null, - }; + // if (requestOptions.requestValidator) { + // await requestOptions.requestValidator(requestOptions); + // } - try { - result.response = (await firstValueFrom( - opts - .httpClient!.request(req) - .pipe(filter((event) => event.type === HttpEventType.Response)), - )) as HttpResponse; - - for (const fn of interceptors.response._fns) { - if (fn) { - result.response = await fn(result.response, req, opts as any); - } - } - - let bodyResponse = result.response.body; - - if (opts.responseValidator) { - await opts.responseValidator(bodyResponse); - } - - if (opts.responseTransformer) { - bodyResponse = await opts.responseTransformer(bodyResponse); - } - - return opts.responseStyle === 'data' - ? bodyResponse - : { data: bodyResponse, ...result }; - } catch (error) { - if (error instanceof HttpErrorResponse) { - result.response = error; - } - - let finalError = error instanceof HttpErrorResponse ? error.error : error; - - for (const fn of interceptors.error._fns) { - if (fn) { - finalError = (await fn( - finalError, - result.response as any, - req, - opts as any, - )) as string; - } - } - - if (opts.throwOnError) { - throw finalError; - } - - return opts.responseStyle === 'data' - ? undefined - : { - error: finalError, - ...result, - }; - } + return requestOptions; }; - const makeMethodFn = - (method: Uppercase) => (options: RequestOptions) => - request({ ...options, method }); - - const makeSseFn = - (method: Uppercase) => async (options: RequestOptions) => { - const { opts, url } = await beforeRequest(options); - return createSseClient({ - ...opts, - body: opts.body as BodyInit | null | undefined, - headers: opts.headers as unknown as Record, - method, - url, - }); - }; - - return { - buildUrl, - connect: makeMethodFn('CONNECT'), - delete: makeMethodFn('DELETE'), - get: makeMethodFn('GET'), - getConfig, - head: makeMethodFn('HEAD'), - interceptors, - options: makeMethodFn('OPTIONS'), - patch: makeMethodFn('PATCH'), - post: makeMethodFn('POST'), - put: makeMethodFn('PUT'), - request, - requestOptions: (options) => { - if (options.security) { - throw new Error('Security is not supported in requestOptions'); - } - - if (options.requestValidator) { - throw new Error( - 'Request validation is not supported in requestOptions', - ); - } - - return requestOptions(options).req; - }, - setConfig, - sse: { - connect: makeSseFn('CONNECT'), - delete: makeSseFn('DELETE'), - get: makeSseFn('GET'), - head: makeSseFn('HEAD'), - options: makeSseFn('OPTIONS'), - patch: makeSseFn('PATCH'), - post: makeSseFn('POST'), - put: makeSseFn('PUT'), - trace: makeSseFn('TRACE'), - }, - trace: makeMethodFn('TRACE'), - } as Client; -}; + makeMethodFn = + (method: Uppercase) => + < + TData = unknown, + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = false, + >( + options: RequestOptions, + overrides?: HttpRequest, + ) => + this.request({ ...options, method }, overrides); + + connect = this.makeMethodFn('CONNECT'); + delete = this.makeMethodFn('DELETE'); + get = this.makeMethodFn('GET'); + head = this.makeMethodFn('HEAD'); + options = this.makeMethodFn('OPTIONS'); + patch = this.makeMethodFn('PATCH'); + post = this.makeMethodFn('POST'); + put = this.makeMethodFn('PUT'); + trace = this.makeMethodFn('TRACE'); +} diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-angular/bundle/index.ts b/packages/openapi-ts/src/plugins/@hey-api/client-angular/bundle/index.ts index a237c773b..74db6b41b 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/client-angular/bundle/index.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/client-angular/bundle/index.ts @@ -6,7 +6,6 @@ export { urlSearchParamsBodySerializer, } from '../../client-core/bundle/bodySerializer'; export { buildClientParams } from '../../client-core/bundle/params'; -export { createClient } from './client'; export type { Client, ClientOptions, @@ -21,3 +20,8 @@ export type { TDataShape, } from './types'; export { createConfig, mergeHeaders } from './utils'; +export { + HeyApiInterceptor, + INTERCEPTORS_CONTEXT, + OPTIONS_CONTEXT, +} from './utils'; diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-angular/bundle/types.ts b/packages/openapi-ts/src/plugins/@hey-api/client-angular/bundle/types.ts index 1eb603e4b..5a06ee68c 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/client-angular/bundle/types.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/client-angular/bundle/types.ts @@ -1,11 +1,12 @@ import type { - HttpClient, HttpErrorResponse, + HttpEvent, HttpHeaders, HttpRequest, HttpResponse, } from '@angular/common/http'; -import type { Injector } from '@angular/core'; +import type { InjectionToken, Injector } from '@angular/core'; +import type { Observable } from 'rxjs'; import type { Auth } from '../../client-core/bundle/auth'; import type { @@ -16,7 +17,7 @@ import type { Client as CoreClient, Config as CoreConfig, } from '../../client-core/bundle/types'; -import type { Middleware } from './utils'; +import type { HeyApiClient } from './client'; export type ResponseStyle = 'data' | 'fields'; @@ -27,6 +28,12 @@ export interface Config * Base URL for all requests made by this client. */ baseUrl?: T['baseUrl']; + + /** + * Custom HeyApi client. Either with your own implementation or as n-th additional client. + * @default HeyApiClient + */ + client?: { new (): HeyApiClient }; /** * An object containing any HTTP headers that you want to pre-populate your * `HttpHeaders` object with. @@ -45,10 +52,13 @@ export interface Config | undefined | unknown >; + /** - * The HTTP client to use for making requests. + * Under which injection token to provide the client. + * @default DEFAULT_HEY_API_CLIENT */ - httpClient?: HttpClient; + provide?: InjectionToken; + /** * Should we return only data or multiple fields (data, error, response, etc.)? * @@ -157,25 +167,27 @@ export interface ClientOptions { type MethodFn = < TData = unknown, - TError = unknown, + // TError = unknown, ThrowOnError extends boolean = false, TResponseStyle extends ResponseStyle = 'fields', >( options: Omit, 'method'>, -) => RequestResult; +) => Observable>; +// TODO: Move to sdk: RequestResult -type SseFn = < +export type SseFn = < TData = unknown, TError = unknown, ThrowOnError extends boolean = false, TResponseStyle extends ResponseStyle = 'fields', >( options: Omit, 'method'>, -) => Promise>; +) => ServerSentEventsResult; type RequestFn = < + TResponseBody, TData = unknown, - TError = unknown, + // TError = unknown, ThrowOnError extends boolean = false, TResponseStyle extends ResponseStyle = 'fields', >( @@ -184,7 +196,9 @@ type RequestFn = < Required>, 'method' >, -) => RequestResult; + overrides?: HttpRequest, + // TODO: RequestResult may be a a sdk type, here we still stick with HttpEvent +) => Observable>; type RequestOptionsFn = < ThrowOnError extends boolean = false, @@ -211,12 +225,13 @@ export type Client = CoreClient< BuildUrlFn, SseFn > & { - interceptors: Middleware< - HttpRequest, - HttpResponse, - unknown, - ResolvedRequestOptions - >; + // TODO: Move to Angular interceptor + // interceptors: Middleware< + // HttpRequest, + // HttpResponse, + // unknown, + // ResolvedRequestOptions + // >; requestOptions: RequestOptionsFn; }; @@ -235,8 +250,8 @@ export type CreateClientConfig = ( export interface TDataShape { body?: unknown; headers?: unknown; - path?: unknown; - query?: unknown; + path?: Record; + query?: Record; url: string; } diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-angular/bundle/utils.ts b/packages/openapi-ts/src/plugins/@hey-api/client-angular/bundle/utils.ts index 65b0bc366..182b01d77 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/client-angular/bundle/utils.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/client-angular/bundle/utils.ts @@ -1,4 +1,19 @@ -import { HttpHeaders } from '@angular/common/http'; +import type { + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest, + HttpResponse, +} from '@angular/common/http'; +import { + HttpContextToken, + HttpEventType, + HttpHeaders, +} from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import type { Observable } from 'rxjs'; +import { from, of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; import { getAuthToken } from '../../client-core/bundle/auth'; import type { @@ -10,7 +25,14 @@ import { serializeObjectParam, serializePrimitiveParam, } from '../../client-core/bundle/pathSerializer'; -import type { Client, ClientOptions, Config, RequestOptions } from './types'; +import type { + Client, + ClientOptions, + Config, + RequestOptions, + ResolvedRequestOptions, + ResponseStyle, +} from './types'; interface PathSerializer { path: Record; @@ -338,39 +360,39 @@ type ResInterceptor = ( ) => Res | Promise; class Interceptors { - _fns: (Interceptor | null)[]; + fns: (Interceptor | null)[]; constructor() { - this._fns = []; + this.fns = []; } clear() { - this._fns = []; + this.fns = []; } getInterceptorIndex(id: number | Interceptor): number { if (typeof id === 'number') { - return this._fns[id] ? id : -1; + return this.fns[id] ? id : -1; } else { - return this._fns.indexOf(id); + return this.fns.indexOf(id); } } exists(id: number | Interceptor) { const index = this.getInterceptorIndex(id); - return !!this._fns[index]; + return !!this.fns[index]; } eject(id: number | Interceptor) { const index = this.getInterceptorIndex(id); - if (this._fns[index]) { - this._fns[index] = null; + if (this.fns[index]) { + this.fns[index] = null; } } update(id: number | Interceptor, fn: Interceptor) { const index = this.getInterceptorIndex(id); - if (this._fns[index]) { - this._fns[index] = fn; + if (this.fns[index]) { + this.fns[index] = fn; return id; } else { return false; @@ -378,8 +400,8 @@ class Interceptors { } use(fn: Interceptor) { - this._fns = [...this._fns, fn]; - return this._fns.length - 1; + this.fns = [...this.fns, fn]; + return this.fns.length - 1; } } @@ -388,12 +410,15 @@ class Interceptors { export interface Middleware { error: Pick< Interceptors>, - 'eject' | 'use' + 'eject' | 'use' | 'fns' + >; + request: Pick< + Interceptors>, + 'eject' | 'use' | 'fns' >; - request: Pick>, 'eject' | 'use'>; response: Pick< Interceptors>, - 'eject' | 'use' + 'eject' | 'use' | 'fns' >; } @@ -427,3 +452,132 @@ export const createConfig = ( querySerializer: defaultQuerySerializer, ...override, }); + +export const INTERCEPTORS_CONTEXT = new HttpContextToken< + Middleware | undefined +>(() => undefined); +export const OPTIONS_CONTEXT = new HttpContextToken(() => undefined); + +@Injectable() +export class HeyApiInterceptor implements HttpInterceptor { + interceptors = createInterceptors< + HttpRequest, + HttpResponse, + unknown, + ResolvedRequestOptions + >(); + + intercept( + req: HttpRequest, + next: HttpHandler, + ): Observable> { + const interceptors = req.context.get(INTERCEPTORS_CONTEXT); + if (!interceptors) { + return next.handle(req); + } + + const options = req.context.get(OPTIONS_CONTEXT) || {}; + return from( + Promise.resolve().then(async () => { + let modifiedReq = req; + for (const fn of interceptors.request.fns) { + if (fn) { + modifiedReq = await fn(modifiedReq, options); + } + } + return modifiedReq; + }), + ).pipe( + switchMap((modifiedReq) => next.handle(modifiedReq)), + switchMap((event) => { + if (event.type === HttpEventType.Response) { + return from( + Promise.resolve().then(async () => { + let modifiedResponse = event; + for (const fn of interceptors.response.fns) { + if (fn) { + modifiedResponse = await fn(modifiedResponse, {}, options); + } + } + return modifiedResponse; + }), + ); + } + return of(event); + }), + catchError((error) => + from( + Promise.resolve().then(async () => { + let modifiedError = error; + for (const fn of interceptors.error.fns) { + if (fn) { + modifiedError = await fn(modifiedError, error, req, options); + } + } + throw modifiedError; + }), + ), + ), + ); + } +} + +export function isResponseEvent( + event: HttpEvent, +): event is HttpResponse { + return event.type === HttpEventType.Response; +} + +export const mapToResponseStyle = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + source: Observable>, + requestOptions: RequestOptions, +) => + source.pipe( + map((event) => { + if (!isResponseEvent(event)) { + return event; + } + + const result: any = { + body: event.body as TData, + headers: event.headers, + status: event.status, + statusText: event.statusText, + url: event.url || undefined, + }; + + if (requestOptions.responseStyle === 'data') { + return result.body; + } else if (requestOptions.responseStyle === 'fields') { + return result; + } + + return result; + }), + catchError((error) => { + if (requestOptions.throwOnError) { + return error; + } + + const errResult: any = { + error: error.error as TError, + headers: error.headers, + status: error.status, + statusText: error.statusText, + url: error.url || undefined, + }; + + if (requestOptions.responseStyle === 'data') { + return of(errResult.error); + } else if (requestOptions.responseStyle === 'fields') { + return of(errResult); + } + + return of(errResult); + }), + ); diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/api.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/api.ts new file mode 100644 index 000000000..c747e1009 --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/api.ts @@ -0,0 +1,32 @@ +export type Api = { + /** + * @param type Selector type. + * @param value Depends on `type`: + * - `buildClientParams`: never + * - `client`: never + * - `formDataBodySerializer`: never + * - `function`: `operation.id` string + * - `Options`: never + * - `urlSearchParamsBodySerializer`: never + * @returns Selector array + */ + getSelector: ( + type: + | 'buildClientParams' + | 'client' + | 'formDataBodySerializer' + | 'function' + | 'Options' + | 'urlSearchParamsBodySerializer', + value?: string, + ) => ReadonlyArray; +}; + +const getSelector: Api['getSelector'] = (...args) => [ + 'sdk', + ...(args as Array), +]; + +export const api: Api = { + getSelector, +}; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts index a5f59f09a..0618964b8 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts @@ -1,9 +1,11 @@ import { definePluginConfig } from '../../shared/utils/config'; +import { api } from './api'; import { handler } from './plugin'; import { handlerLegacy } from './plugin-legacy'; import type { HeyApiSdkPlugin } from './types'; export const defaultConfig: HeyApiSdkPlugin['Config'] = { + api, config: { asClass: false, auth: true, diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/operation.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/operation.ts index 684684eb1..7fe220117 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/operation.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/operation.ts @@ -1,6 +1,7 @@ +import type { ICodegenSymbolOut } from '@hey-api/codegen-core'; import type ts from 'typescript'; -import { clientApi, clientModulePath } from '../../../generate/client'; +import { clientModulePath } from '../../../generate/client'; import type { GeneratedFile } from '../../../generate/file'; import { statusCodeToGroup } from '../../../ir/operation'; import type { IR } from '../../../ir/types'; @@ -168,14 +169,25 @@ export const operationOptionsType = ({ const pluginTypeScript = plugin.getPlugin('@hey-api/typescript')!; const fileTypeScript = plugin.context.file({ id: typesId })!; - const dataImport = file.import({ - asType: true, - module: file.relativePathToFile({ context: plugin.context, id: typesId }), - name: fileTypeScript.getName( - pluginTypeScript.api.getId({ operation, type: 'data' }), - ), + + const f = plugin.gen.ensureFile(plugin.output); + + const dataTypeName = fileTypeScript.getName( + pluginTypeScript.api.getId({ operation, type: 'data' }), + ); + if (dataTypeName) { + f.addImport({ + from: f.relativePathToFile({ + path: fileTypeScript.nameWithoutExtension(), + }), + typeNames: [dataTypeName], + }); + } + const dataType = dataTypeName || 'unknown'; + + const symbolOptions = f.ensureSymbol({ + selector: plugin.api.getSelector('Options'), }); - const optionsName = clientApi.Options.name; if (isNuxtClient) { const responseImport = file.import({ @@ -188,14 +200,16 @@ export const operationOptionsType = ({ }), ), }); - return `${optionsName}<${nuxtTypeComposable}, ${dataImport.name || 'unknown'}, ${responseImport.name || 'unknown'}, ${nuxtTypeDefault}>`; + return `${symbolOptions.placeholder}<${nuxtTypeComposable}, ${dataType}, ${responseImport.name || 'unknown'}, ${nuxtTypeDefault}>`; } // TODO: refactor this to be more generic, works for now if (throwOnError) { - return `${optionsName}<${dataImport.name || 'unknown'}, ${throwOnError}>`; + return `${symbolOptions.placeholder}<${dataType}, ${throwOnError}>`; } - return dataImport.name ? `${optionsName}<${dataImport.name}>` : optionsName; + return dataTypeName + ? `${symbolOptions.placeholder}<${dataTypeName}>` + : symbolOptions.placeholder; }; type OperationParameters = { @@ -404,34 +418,45 @@ export const operationStatements = ({ operation: IR.OperationObject; plugin: HeyApiSdkPlugin['Instance']; }): Array => { - const file = plugin.context.file({ id: sdkId })!; - const sdkOutput = file.nameWithoutExtension(); + const f = plugin.gen.ensureFile(plugin.output); const client = getClientPlugin(plugin.context.config); const isNuxtClient = client.name === '@hey-api/client-nuxt'; const pluginTypeScript = plugin.getPlugin('@hey-api/typescript')!; const fileTypeScript = plugin.context.file({ id: typesId })!; - const responseImport = file.import({ - asType: true, - module: file.relativePathToFile({ context: plugin.context, id: typesId }), - name: fileTypeScript.getName( - pluginTypeScript.api.getId({ - operation, - type: isNuxtClient ? 'response' : 'responses', + + const responseTypeName = fileTypeScript.getName( + pluginTypeScript.api.getId({ + operation, + type: isNuxtClient ? 'response' : 'responses', + }), + ); + if (responseTypeName) { + f.addImport({ + from: f.relativePathToFile({ + path: fileTypeScript.nameWithoutExtension(), }), - ), - }); - const errorImport = file.import({ - asType: true, - module: file.relativePathToFile({ context: plugin.context, id: typesId }), - name: fileTypeScript.getName( - pluginTypeScript.api.getId({ - operation, - type: isNuxtClient ? 'error' : 'errors', + typeNames: [responseTypeName], + }); + } + const responseType = responseTypeName || 'unknown'; + + const errorTypeName = fileTypeScript.getName( + pluginTypeScript.api.getId({ + operation, + type: isNuxtClient ? 'error' : 'errors', + }), + ); + if (errorTypeName) { + f.addImport({ + from: f.relativePathToFile({ + path: fileTypeScript.nameWithoutExtension(), }), - ), - }); + typeNames: [errorTypeName], + }); + } + const errorType = errorTypeName || 'unknown'; // TODO: transform parameters // const query = { @@ -449,19 +474,26 @@ export const operationStatements = ({ // } // } - const requestOptions: ObjectValue[] = []; + const requestOptions: Array = []; if (operation.body) { switch (operation.body.type) { case 'form-data': { - const imported = file.import({ - module: clientModulePath({ + const symbol = f.ensureSymbol({ + name: 'formDataBodySerializer', + selector: plugin.api.getSelector('formDataBodySerializer'), + }); + f.addImport({ + aliases: { + formDataBodySerializer: symbol.placeholder, + }, + from: clientModulePath({ config: plugin.context.config, - sourceOutput: sdkOutput, + sourceOutput: f.path, }), - name: 'formDataBodySerializer', + names: ['formDataBodySerializer'], }); - requestOptions.push({ spread: imported.name }); + requestOptions.push({ spread: symbol.placeholder }); break; } case 'json': @@ -476,14 +508,21 @@ export const operationStatements = ({ }); break; case 'url-search-params': { - const imported = file.import({ - module: clientModulePath({ + const symbol = f.ensureSymbol({ + name: 'urlSearchParamsBodySerializer', + selector: plugin.api.getSelector('urlSearchParamsBodySerializer'), + }); + f.addImport({ + aliases: { + urlSearchParamsBodySerializer: symbol.placeholder, + }, + from: clientModulePath({ config: plugin.context.config, - sourceOutput: sdkOutput, + sourceOutput: f.path, }), - name: 'urlSearchParamsBodySerializer', + names: ['urlSearchParamsBodySerializer'], }); - requestOptions.push({ spread: imported.name }); + requestOptions.push({ spread: symbol.placeholder }); break; } } @@ -540,6 +579,7 @@ export const operationStatements = ({ }); if (identifierTransformer.name) { + const file = plugin.context.file({ id: sdkId })!; file.import({ module: file.relativePathToFile({ context: plugin.context, @@ -644,17 +684,24 @@ export const operationStatements = ({ } config.push(tsc.objectExpression({ obj })); } - const imported = file.import({ - module: clientModulePath({ + const symbol = f.ensureSymbol({ + name: 'buildClientParams', + selector: plugin.api.getSelector('buildClientParams'), + }); + f.addImport({ + aliases: { + buildClientParams: symbol.placeholder, + }, + from: clientModulePath({ config: plugin.context.config, - sourceOutput: sdkOutput, + sourceOutput: f.path, }), - name: 'buildClientParams', + names: ['buildClientParams'], }); statements.push( tsc.constVariable({ expression: tsc.callExpression({ - functionName: imported.name, + functionName: symbol.placeholder, parameters: [ tsc.arrayLiteralExpression({ elements: args }), tsc.arrayLiteralExpression({ elements: config }), @@ -703,19 +750,22 @@ export const operationStatements = ({ } } - const responseType = responseImport.name || 'unknown'; - const errorType = errorImport.name || 'unknown'; - - const heyApiClient = plugin.config.client - ? file.import({ - alias: '_heyApiClient', - module: file.relativePathToFile({ - context: plugin.context, - id: clientId, - }), - name: 'client', - }) - : undefined; + let symbolClient: ICodegenSymbolOut | undefined; + if (plugin.config.client) { + symbolClient = f.ensureSymbol({ + name: '_heyApiClient', + selector: plugin.api.getSelector('client'), + }); + f.addImport({ + aliases: { + client: symbolClient.placeholder, + }, + from: f.relativePathToFile({ + path: plugin.context.file({ id: clientId })!.nameWithoutExtension(), + }), + names: ['client'], + }); + } const optionsClient = tsc.propertyAccessExpression({ expression: tsc.identifier({ text: 'options' }), @@ -734,11 +784,11 @@ export const operationStatements = ({ name: '_client', }), }); - } else if (heyApiClient?.name) { + } else if (symbolClient) { clientExpression = tsc.binaryExpression({ left: optionsClient, operator: '??', - right: tsc.identifier({ text: heyApiClient.name }), + right: symbolClient.placeholder, }); } else { clientExpression = optionsClient; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts index 5466af08c..e2a0bfc3f 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts @@ -1,6 +1,7 @@ import ts from 'typescript'; import { clientApi, clientModulePath } from '../../../generate/client'; +import { TypeScriptRenderer } from '../../../generate/renderer'; import { tsc } from '../../../tsc'; import { stringCase } from '../../../utils/stringCase'; import { @@ -38,10 +39,15 @@ const createClientClassNodes = ({ }), }); + const f = plugin.gen.ensureFile(plugin.output); + return [ tsc.propertyDeclaration({ initializer: plugin.config.client - ? tsc.identifier({ text: '_heyApiClient' }) + ? tsc.identifier({ + text: f.ensureSymbol({ selector: plugin.api.getSelector('client') }) + .placeholder, + }) : undefined, modifier: 'protected', name: '_client', @@ -339,6 +345,7 @@ const generateFlatSdk = ({ }) => { const client = getClientPlugin(plugin.context.config); const isNuxtClient = client.name === '@hey-api/client-nuxt'; + const f = plugin.gen.ensureFile(plugin.output); const file = plugin.context.file({ id: sdkId })!; plugin.forEach('operation', ({ operation }) => { @@ -357,6 +364,16 @@ const generateFlatSdk = ({ ) : undefined, }); + if (isNuxtClient) { + f.addImport({ + from: file.relativePathToFile({ context: plugin.context, id: typesId }), + typeNames: [ + fileTypeScript.getName( + pluginTypeScript.api.getId({ operation, type: 'response' }), + )!, + ], + }); + } const opParameters = operationParameters({ file, isRequiredOptions, @@ -369,6 +386,15 @@ const generateFlatSdk = ({ operation, plugin, }); + const symbol = f.addSymbol({ + name: serviceFunctionIdentifier({ + config: plugin.context.config, + handleIllegal: true, + id: operation.id, + operation, + }), + selector: plugin.api.getSelector('function', operation.id), + }); const node = tsc.constVariable({ comment: createOperationComment({ operation }), exportConst: true, @@ -408,50 +434,47 @@ const generateFlatSdk = ({ }, ], }), - name: serviceFunctionIdentifier({ - config: plugin.context.config, - handleIllegal: true, - id: operation.id, - operation, - }), + name: symbol.placeholder, }); - file.add(node); + f.patchSymbol(symbol.id, { value: node }); }); }; export const handler: HeyApiSdkPlugin['Handler'] = ({ plugin }) => { - const file = plugin.createFile({ - id: sdkId, - path: plugin.output, + const f = plugin.gen.createFile(plugin.output, { + extension: '.ts', + path: '{{path}}.gen', + renderer: new TypeScriptRenderer(), }); // import required packages and core files + // TODO: remove + plugin.createFile({ + id: sdkId, + path: plugin.output, + }); const clientModule = clientModulePath({ config: plugin.context.config, - sourceOutput: file.nameWithoutExtension(), + sourceOutput: f.path, }); - const clientOptions = file.import({ - ...clientApi.Options, - alias: 'ClientOptions', - module: clientModule, + const clientOptions = f.addSymbol({ name: 'ClientOptions' }); + f.addImport({ + aliases: { + [clientApi.Options.name]: clientOptions.placeholder, + }, + from: clientModule, + typeNames: [clientApi.Options.name], }); const client = getClientPlugin(plugin.context.config); const isAngularClient = client.name === '@hey-api/client-angular'; const isNuxtClient = client.name === '@hey-api/client-nuxt'; if (isNuxtClient) { - file.import({ - asType: true, - module: clientModule, - name: 'Composable', - }); + f.addImport({ from: clientModule, typeNames: ['Composable'] }); } if (isAngularClient && plugin.config.asClass) { - file.import({ - module: '@angular/core', - name: 'Injectable', - }); + f.addImport({ from: '@angular/core', names: ['Injectable'] }); } createTypeOptions({ clientOptions, plugin }); @@ -461,4 +484,9 @@ export const handler: HeyApiSdkPlugin['Handler'] = ({ plugin }) => { } else { generateFlatSdk({ plugin }); } + + if (plugin.config.exportFromIndex && f.hasContent()) { + const index = plugin.gen.ensureFile('index'); + index.addExport({ from: f, namespaceImport: true }); + } }; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/typeOptions.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/typeOptions.ts index 50be94f18..fd46ec2c4 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/typeOptions.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/typeOptions.ts @@ -1,39 +1,44 @@ +import type { ICodegenSymbolOut } from '@hey-api/codegen-core'; + import { clientModulePath } from '../../../generate/client'; -import type { FileImportResult } from '../../../generate/file/types'; import { tsc } from '../../../tsc'; import { getClientPlugin } from '../client-core/utils'; -import { nuxtTypeDefault, nuxtTypeResponse, sdkId } from './constants'; +import { nuxtTypeDefault, nuxtTypeResponse } from './constants'; import type { HeyApiSdkPlugin } from './types'; export const createTypeOptions = ({ clientOptions, plugin, }: { - clientOptions: FileImportResult; + clientOptions: ICodegenSymbolOut; plugin: HeyApiSdkPlugin['Instance']; }) => { - const file = plugin.context.file({ id: sdkId })!; + const f = plugin.gen.ensureFile(plugin.output); const client = getClientPlugin(plugin.context.config); const isNuxtClient = client.name === '@hey-api/client-nuxt'; const clientModule = clientModulePath({ config: plugin.context.config, - sourceOutput: file.nameWithoutExtension(), - }); - const tDataShape = file.import({ - asType: true, - module: clientModule, - name: 'TDataShape', + sourceOutput: f.path, }); - const clientType = file.import({ - asType: true, - module: clientModule, - name: 'Client', + const symbolTDataShape = f.addSymbol({ name: 'TDataShape' }); + const symbolClient = f.addSymbol({ name: 'Client' }); + f.addImport({ + aliases: { + [symbolClient.name]: symbolClient.placeholder, + [symbolTDataShape.name]: symbolTDataShape.placeholder, + }, + from: clientModule, + typeNames: [symbolClient.name, symbolTDataShape.name], }); + const symbolOptions = f.addSymbol({ + name: 'Options', + selector: plugin.api.getSelector('Options'), + }); const typeOptions = tsc.typeAliasDeclaration({ exportType: true, - name: 'Options', + name: symbolOptions.placeholder, type: tsc.typeIntersectionNode({ types: [ tsc.typeReferenceNode({ @@ -48,7 +53,7 @@ export const createTypeOptions = ({ tsc.typeReferenceNode({ typeName: 'TData' }), tsc.typeReferenceNode({ typeName: 'ThrowOnError' }), ], - typeName: clientOptions.name, + typeName: clientOptions.placeholder, }), tsc.typeInterfaceNode({ properties: [ @@ -60,7 +65,9 @@ export const createTypeOptions = ({ ], isRequired: !plugin.config.client, name: 'client', - type: tsc.typeReferenceNode({ typeName: clientType.name }), + type: tsc.typeReferenceNode({ + typeName: symbolClient.placeholder, + }), }, { comment: [ @@ -90,10 +97,10 @@ export const createTypeOptions = ({ }), tsc.typeParameterDeclaration({ constraint: tsc.typeReferenceNode({ - typeName: tDataShape.name, + typeName: symbolTDataShape.placeholder, }), defaultType: tsc.typeReferenceNode({ - typeName: tDataShape.name, + typeName: symbolTDataShape.placeholder, }), name: 'TData', }), @@ -109,10 +116,10 @@ export const createTypeOptions = ({ : [ tsc.typeParameterDeclaration({ constraint: tsc.typeReferenceNode({ - typeName: tDataShape.name, + typeName: symbolTDataShape.placeholder, }), defaultType: tsc.typeReferenceNode({ - typeName: tDataShape.name, + typeName: symbolTDataShape.placeholder, }), name: 'TData', }), @@ -123,6 +130,5 @@ export const createTypeOptions = ({ }), ], }); - - file.add(typeOptions); + f.patchSymbol(symbolOptions.id, { value: typeOptions }); }; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/types.d.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/types.d.ts index 1a76275c2..2cb625a7a 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/types.d.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/types.d.ts @@ -7,6 +7,7 @@ import type { PluginClientNames, PluginValidatorNames, } from '../../types'; +import type { Api } from './api'; export type UserConfig = Plugin.Name<'@hey-api/sdk'> & { /** @@ -337,4 +338,4 @@ export type Config = Plugin.Name<'@hey-api/sdk'> & { response: 'body' | 'response'; }; -export type HeyApiSdkPlugin = DefinePlugin; +export type HeyApiSdkPlugin = DefinePlugin; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/validator.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/validator.ts index cd115251f..885702838 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/validator.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/validator.ts @@ -1,51 +1,41 @@ import type { IR } from '../../../ir/types'; -import { sdkId } from './constants'; import type { HeyApiSdkPlugin } from './types'; +interface ValidatorProps { + operation: IR.OperationObject; + plugin: HeyApiSdkPlugin['Instance']; +} + export const createRequestValidator = ({ operation, plugin, -}: { - operation: IR.OperationObject; - plugin: HeyApiSdkPlugin['Instance']; -}) => { - if (!plugin.config.validator.request) { - return; - } +}: ValidatorProps) => { + if (!plugin.config.validator.request) return; - const pluginValidator = plugin.getPlugin(plugin.config.validator.request); - if (!pluginValidator || !pluginValidator.api.createRequestValidator) { - return; - } + const validator = plugin.getPlugin(plugin.config.validator.request); + if (!validator?.api.createRequestValidator) return; - return pluginValidator.api.createRequestValidator({ - file: plugin.context.file({ id: sdkId })!, + return validator.api.createRequestValidator({ + file: plugin.gen.ensureFile(plugin.output), operation, // @ts-expect-error - plugin: pluginValidator, + plugin: validator, }); }; export const createResponseValidator = ({ operation, plugin, -}: { - operation: IR.OperationObject; - plugin: HeyApiSdkPlugin['Instance']; -}) => { - if (!plugin.config.validator.response) { - return; - } +}: ValidatorProps) => { + if (!plugin.config.validator.response) return; - const pluginValidator = plugin.getPlugin(plugin.config.validator.response); - if (!pluginValidator || !pluginValidator.api.createResponseValidator) { - return; - } + const validator = plugin.getPlugin(plugin.config.validator.response); + if (!validator?.api.createResponseValidator) return; - return pluginValidator.api.createResponseValidator({ - file: plugin.context.file({ id: sdkId })!, + return validator.api.createResponseValidator({ + file: plugin.gen.ensureFile(plugin.output), operation, // @ts-expect-error - plugin: pluginValidator, + plugin: validator, }); }; diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/api.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/api.ts index 114f026f9..d6ad58703 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/api.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/api.ts @@ -3,27 +3,35 @@ import type ts from 'typescript'; import type { IR } from '../../../ir/types'; import { schemaToType } from './plugin'; -type GetIdArgs = - | { - type: 'ClientOptions' | 'Webhooks'; - } - | { - operation: IR.OperationObject; - type: - | 'data' - | 'error' - | 'errors' - | 'response' - | 'responses' - | 'webhook-payload' - | 'webhook-request'; - } - | { - type: 'ref'; - value: string; - }; +export type Api = { + getId: ( + args: + | { + type: 'ClientOptions' | 'Webhooks'; + } + | { + operation: IR.OperationObject; + type: + | 'data' + | 'error' + | 'errors' + | 'response' + | 'responses' + | 'webhook-payload' + | 'webhook-request'; + } + | { + type: 'ref'; + value: string; + }, + ) => string; + schemaToType: ( + args: Omit[0], 'onRef'> & + Pick[0]>, 'onRef'>, + ) => ts.TypeNode; +}; -const getId = (args: GetIdArgs): string => { +const getId: Api['getId'] = (args) => { switch (args.type) { case 'data': case 'error': @@ -40,14 +48,6 @@ const getId = (args: GetIdArgs): string => { } }; -export type Api = { - getId: (args: GetIdArgs) => string; - schemaToType: ( - args: Omit[0], 'onRef'> & - Pick[0]>, 'onRef'>, - ) => ts.TypeNode; -}; - export const api: Api = { getId, schemaToType: (args) => diff --git a/packages/openapi-ts/src/plugins/valibot/api.ts b/packages/openapi-ts/src/plugins/valibot/api.ts index 4b7a4132b..72670e4dc 100644 --- a/packages/openapi-ts/src/plugins/valibot/api.ts +++ b/packages/openapi-ts/src/plugins/valibot/api.ts @@ -1,52 +1,56 @@ +import type { ICodegenFile } from '@hey-api/codegen-core'; import type ts from 'typescript'; -import type { GeneratedFile } from '../../generate/file'; import type { IR } from '../../ir/types'; import { tsc } from '../../tsc'; -import { identifiers, valibotId } from './constants'; +import { identifiers } from './constants'; import type { ValibotPlugin } from './types'; -const createRequestValidator = ({ +export type Api = { + createRequestValidator: (args: { + file: ICodegenFile; + operation: IR.OperationObject; + plugin: ValibotPlugin['Instance']; + }) => ts.ArrowFunction | undefined; + createResponseValidator: (args: { + file: ICodegenFile; + operation: IR.OperationObject; + plugin: ValibotPlugin['Instance']; + }) => ts.ArrowFunction | undefined; + /** + * @param type Selector type. + * @param value Depends on `type`: + * - `data`: `operation.id` string + * - `import`: headless symbols representing module imports + * - `ref`: `$ref` JSON pointer + * - `responses`: `operation.id` string + * - `webhook-request`: `operation.id` string + * @returns Selector array + */ + getSelector: ( + type: 'data' | 'import' | 'ref' | 'responses' | 'webhook-request', + value: string, + ) => ReadonlyArray; +}; + +const createRequestValidator: Api['createRequestValidator'] = ({ file, operation, plugin, -}: { - file: GeneratedFile; - operation: IR.OperationObject; - plugin: ValibotPlugin['Instance']; -}): ts.ArrowFunction | undefined => { - const { requests } = plugin.config; - // const f = plugin.gen.ensureFile(plugin.output); - // TODO: replace - const schemaIdentifier = plugin.context.file({ id: valibotId })!.identifier({ - // TODO: refactor for better cross-plugin compatibility - $ref: `#/valibot-response/${operation.id}`, - // TODO: refactor to not have to define nameTransformer - nameTransformer: typeof requests === 'object' ? requests.name : undefined, - namespace: 'value', - }); - - if (!schemaIdentifier.name) { - return; - } +}) => { + const symbol = plugin.gen.selectSymbolFirst( + plugin.api.getSelector('data', operation.id), + ); + if (!symbol) return; - file.import({ - module: file.relativePathToFile({ - context: plugin.context, - id: valibotId, - }), - name: schemaIdentifier.name, + file.addImport({ + from: plugin.gen.getFileBySymbol(symbol), + names: [symbol.placeholder], }); - // file.import({ - // module: f.relativePathFromFile({ path: file.nameWithoutExtension() }), - // name: schemaIdentifier.name, - // }); - file.import({ - alias: identifiers.v.text, - module: 'valibot', - name: '*', - }); + const selector = plugin.api.getSelector('import', 'valibot'); + const vSymbol = file.ensureSymbol({ name: 'v', selector }); + file.addImport({ from: 'valibot', namespaceImport: vSymbol.placeholder }); const dataParameterName = 'data'; @@ -62,11 +66,11 @@ const createRequestValidator = ({ expression: tsc.awaitExpression({ expression: tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.async.parseAsync, }), parameters: [ - tsc.identifier({ text: schemaIdentifier.name }), + tsc.identifier({ text: symbol.placeholder }), tsc.identifier({ text: dataParameterName }), ], }), @@ -76,47 +80,24 @@ const createRequestValidator = ({ }); }; -const createResponseValidator = ({ +const createResponseValidator: Api['createResponseValidator'] = ({ file, operation, plugin, -}: { - file: GeneratedFile; - operation: IR.OperationObject; - plugin: ValibotPlugin['Instance']; -}): ts.ArrowFunction | undefined => { - const { responses } = plugin.config; - // const f = plugin.gen.ensureFile(plugin.output); - // TODO: replace - const schemaIdentifier = plugin.context.file({ id: valibotId })!.identifier({ - // TODO: refactor for better cross-plugin compatibility - $ref: `#/valibot-response/${operation.id}`, - // TODO: refactor to not have to define nameTransformer - nameTransformer: typeof responses === 'object' ? responses.name : undefined, - namespace: 'value', - }); - - if (!schemaIdentifier.name) { - return; - } +}) => { + const symbol = plugin.gen.selectSymbolFirst( + plugin.api.getSelector('responses', operation.id), + ); + if (!symbol) return; - file.import({ - module: file.relativePathToFile({ - context: plugin.context, - id: valibotId, - }), - name: schemaIdentifier.name, + file.addImport({ + from: plugin.gen.getFileBySymbol(symbol), + names: [symbol.placeholder], }); - // file.import({ - // module: f.relativePathFromFile({ path: file.nameWithoutExtension() }), - // name: schemaIdentifier.name, - // }); - file.import({ - alias: identifiers.v.text, - module: 'valibot', - name: '*', - }); + const selector = plugin.api.getSelector('import', 'valibot'); + const vSymbol = file.ensureSymbol({ name: 'v', selector }); + file.addImport({ from: 'valibot', namespaceImport: vSymbol.placeholder }); const dataParameterName = 'data'; @@ -132,11 +113,11 @@ const createResponseValidator = ({ expression: tsc.awaitExpression({ expression: tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.async.parseAsync, }), parameters: [ - tsc.identifier({ text: schemaIdentifier.name }), + tsc.identifier({ text: symbol.placeholder }), tsc.identifier({ text: dataParameterName }), ], }), @@ -146,20 +127,10 @@ const createResponseValidator = ({ }); }; -export type Api = { - createRequestValidator: (args: { - file: GeneratedFile; - operation: IR.OperationObject; - plugin: ValibotPlugin['Instance']; - }) => ts.ArrowFunction | undefined; - createResponseValidator: (args: { - file: GeneratedFile; - operation: IR.OperationObject; - plugin: ValibotPlugin['Instance']; - }) => ts.ArrowFunction | undefined; -}; +const getSelector: Api['getSelector'] = (...args) => ['valibot', ...args]; export const api: Api = { createRequestValidator, createResponseValidator, + getSelector, }; diff --git a/packages/openapi-ts/src/plugins/valibot/constants.ts b/packages/openapi-ts/src/plugins/valibot/constants.ts index 69b6a6c7b..5f1790715 100644 --- a/packages/openapi-ts/src/plugins/valibot/constants.ts +++ b/packages/openapi-ts/src/plugins/valibot/constants.ts @@ -263,7 +263,4 @@ export const identifiers = { utils: { // TODO: implement if necessary }, - v: tsc.identifier({ text: 'v' }), }; - -export const valibotId = 'valibot'; diff --git a/packages/openapi-ts/src/plugins/valibot/operation.ts b/packages/openapi-ts/src/plugins/valibot/operation.ts index d4c249bbc..033ec060e 100644 --- a/packages/openapi-ts/src/plugins/valibot/operation.ts +++ b/packages/openapi-ts/src/plugins/valibot/operation.ts @@ -1,6 +1,6 @@ import { operationResponsesMap } from '../../ir/operation'; import type { IR } from '../../ir/types'; -import { valibotId } from './constants'; +import { buildName } from '../../openApi/shared/utils/name'; import { schemaToValibotSchema, type State } from './plugin'; import type { ValibotPlugin } from './types'; @@ -13,8 +13,6 @@ export const operationToValibotSchema = ({ plugin: ValibotPlugin['Instance']; state: State; }) => { - const file = plugin.context.file({ id: valibotId })!; - if (plugin.config.requests.enabled) { const requiredProperties = new Set(); @@ -113,21 +111,21 @@ export const operationToValibotSchema = ({ schemaData.required = [...requiredProperties]; - const identifierData = file.identifier({ - // TODO: refactor for better cross-plugin compatibility - $ref: `#/valibot-data/${operation.id}`, - case: plugin.config.requests.case, - create: true, - nameTransformer: plugin.config.requests.name, - namespace: 'value', + const f = plugin.gen.ensureFile(plugin.output); + const symbol = f.addSymbol({ + name: buildName({ + config: plugin.config.requests, + name: operation.id, + }), + selector: plugin.api.getSelector('data', operation.id), }); schemaToValibotSchema({ // TODO: refactor for better cross-plugin compatibility $ref: `#/valibot-data/${operation.id}`, - identifier: identifierData, plugin, schema: schemaData, state, + symbol, }); } @@ -136,21 +134,21 @@ export const operationToValibotSchema = ({ const { response } = operationResponsesMap(operation); if (response) { - const identifierResponse = file.identifier({ - // TODO: refactor for better cross-plugin compatibility - $ref: `#/valibot-response/${operation.id}`, - case: plugin.config.responses.case, - create: true, - nameTransformer: plugin.config.responses.name, - namespace: 'value', + const f = plugin.gen.ensureFile(plugin.output); + const symbol = f.addSymbol({ + name: buildName({ + config: plugin.config.responses, + name: operation.id, + }), + selector: plugin.api.getSelector('responses', operation.id), }); schemaToValibotSchema({ // TODO: refactor for better cross-plugin compatibility $ref: `#/valibot-response/${operation.id}`, - identifier: identifierResponse, plugin, schema: response, state, + symbol, }); } } diff --git a/packages/openapi-ts/src/plugins/valibot/plugin.ts b/packages/openapi-ts/src/plugins/valibot/plugin.ts index 0b1c1d48d..013fbaa05 100644 --- a/packages/openapi-ts/src/plugins/valibot/plugin.ts +++ b/packages/openapi-ts/src/plugins/valibot/plugin.ts @@ -1,14 +1,16 @@ +import type { ICodegenSymbolOut } from '@hey-api/codegen-core'; import ts from 'typescript'; -import type { Identifier } from '../../generate/file/types'; -// import { TypeScriptRenderer } from '../../generate/renderer'; +import { TypeScriptRenderer } from '../../generate/renderer'; import { deduplicateSchema } from '../../ir/schema'; import type { IR } from '../../ir/types'; +import { buildName } from '../../openApi/shared/utils/name'; import { tsc } from '../../tsc'; import type { StringCase, StringName } from '../../types/case'; +import { refToName } from '../../utils/ref'; import { numberRegExp } from '../../utils/regexp'; import { createSchemaComment } from '../shared/utils/schema'; -import { identifiers, valibotId } from './constants'; +import { identifiers } from './constants'; import { INTEGER_FORMATS, isIntegerFormat, @@ -31,14 +33,23 @@ export interface State { nameTransformer: StringName; } -const pipesToExpression = (pipes: Array) => { +const pipesToExpression = ({ + pipes, + plugin, +}: { + pipes: Array; + plugin: ValibotPlugin['Instance']; +}) => { if (pipes.length === 1) { return pipes[0]!; } + const vSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'valibot'), + ); const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.methods.pipe, }), parameters: pipes, @@ -55,8 +66,11 @@ const arrayTypeToValibotSchema = ({ schema: SchemaWithType<'array'>; state: State; }): ts.Expression => { + const vSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'valibot'), + ); const functionName = tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.array, }); @@ -67,6 +81,7 @@ const arrayTypeToValibotSchema = ({ functionName, parameters: [ unknownTypeToValibotSchema({ + plugin, schema: { type: 'unknown', }, @@ -84,7 +99,7 @@ const arrayTypeToValibotSchema = ({ schema: item, state, }); - return pipesToExpression(schemaPipes); + return pipesToExpression({ pipes: schemaPipes, plugin }); }); if (itemExpressions.length === 1) { @@ -108,6 +123,7 @@ const arrayTypeToValibotSchema = ({ functionName, parameters: [ unknownTypeToValibotSchema({ + plugin, schema: { type: 'unknown', }, @@ -121,7 +137,7 @@ const arrayTypeToValibotSchema = ({ if (schema.minItems === schema.maxItems && schema.minItems !== undefined) { const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.length, }), parameters: [tsc.valueToExpression({ value: schema.minItems })], @@ -131,7 +147,7 @@ const arrayTypeToValibotSchema = ({ if (schema.minItems !== undefined) { const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.minLength, }), parameters: [tsc.valueToExpression({ value: schema.minItems })], @@ -142,7 +158,7 @@ const arrayTypeToValibotSchema = ({ if (schema.maxItems !== undefined) { const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.maxLength, }), parameters: [tsc.valueToExpression({ value: schema.maxItems })], @@ -151,18 +167,24 @@ const arrayTypeToValibotSchema = ({ } } - return pipesToExpression(pipes); + return pipesToExpression({ pipes, plugin }); }; const booleanTypeToValibotSchema = ({ + plugin, schema, }: { + plugin: ValibotPlugin['Instance']; schema: SchemaWithType<'boolean'>; }) => { + const vSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'valibot'), + ); + if (typeof schema.const === 'boolean') { const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.literal, }), parameters: [tsc.ots.boolean(schema.const)], @@ -172,7 +194,7 @@ const booleanTypeToValibotSchema = ({ const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.boolean, }), }); @@ -180,8 +202,10 @@ const booleanTypeToValibotSchema = ({ }; const enumTypeToValibotSchema = ({ + plugin, schema, }: { + plugin: ValibotPlugin['Instance']; schema: SchemaWithType<'enum'>; }): ts.CallExpression => { const enumMembers: Array = []; @@ -203,15 +227,20 @@ const enumTypeToValibotSchema = ({ if (!enumMembers.length) { return unknownTypeToValibotSchema({ + plugin, schema: { type: 'unknown', }, }); } + const vSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'valibot'), + ); + let resultExpression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.picklist, }), parameters: [ @@ -225,7 +254,7 @@ const enumTypeToValibotSchema = ({ if (isNullable) { resultExpression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.nullable, }), parameters: [resultExpression], @@ -235,26 +264,36 @@ const enumTypeToValibotSchema = ({ return resultExpression; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const neverTypeToValibotSchema = (_props: { +const neverTypeToValibotSchema = ({ + plugin, +}: { + plugin: ValibotPlugin['Instance']; schema: SchemaWithType<'never'>; }) => { + const vSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'valibot'), + ); const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.never, }), }); return expression; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const nullTypeToValibotSchema = (_props: { +const nullTypeToValibotSchema = ({ + plugin, +}: { + plugin: ValibotPlugin['Instance']; schema: SchemaWithType<'null'>; }) => { + const vSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'valibot'), + ); const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.null, }), }); @@ -262,8 +301,10 @@ const nullTypeToValibotSchema = (_props: { }; const numberTypeToValibotSchema = ({ + plugin, schema, }: { + plugin: ValibotPlugin['Instance']; schema: SchemaWithType<'integer' | 'number'>; }) => { const format = schema.format; @@ -271,6 +312,10 @@ const numberTypeToValibotSchema = ({ const isBigInt = needsBigIntForFormat(format); const formatInfo = isIntegerFormat(format) ? INTEGER_FORMATS[format] : null; + const vSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'valibot'), + ); + // Return early if const is defined since we can create a literal type directly without additional validation if (schema.const !== undefined && schema.const !== null) { const constValue = schema.const; @@ -323,7 +368,7 @@ const numberTypeToValibotSchema = ({ return tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.literal, }), parameters: [literalValue], @@ -336,7 +381,7 @@ const numberTypeToValibotSchema = ({ if (isBigInt) { const unionExpression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.union, }), parameters: [ @@ -344,19 +389,19 @@ const numberTypeToValibotSchema = ({ elements: [ tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.number, }), }), tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.string, }), }), tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.bigInt, }), }), @@ -370,7 +415,7 @@ const numberTypeToValibotSchema = ({ // Add transform to convert to BigInt const transformExpression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.transform, }), parameters: [ @@ -388,7 +433,7 @@ const numberTypeToValibotSchema = ({ // For regular number formats, use number schema const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.number, }), }); @@ -399,7 +444,7 @@ const numberTypeToValibotSchema = ({ if (!isBigInt && isInteger) { const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.integer, }), }); @@ -416,7 +461,7 @@ const numberTypeToValibotSchema = ({ // Add minimum value validation const minExpression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.minValue, }), parameters: [ @@ -434,7 +479,7 @@ const numberTypeToValibotSchema = ({ // Add maximum value validation const maxExpression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.maxValue, }), parameters: [ @@ -453,7 +498,7 @@ const numberTypeToValibotSchema = ({ if (schema.exclusiveMinimum !== undefined) { const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.gtValue, }), parameters: [ @@ -464,7 +509,7 @@ const numberTypeToValibotSchema = ({ } else if (schema.minimum !== undefined) { const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.minValue, }), parameters: [numberParameter({ isBigInt, value: schema.minimum })], @@ -475,7 +520,7 @@ const numberTypeToValibotSchema = ({ if (schema.exclusiveMaximum !== undefined) { const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.ltValue, }), parameters: [ @@ -486,7 +531,7 @@ const numberTypeToValibotSchema = ({ } else if (schema.maximum !== undefined) { const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.maxValue, }), parameters: [numberParameter({ isBigInt, value: schema.maximum })], @@ -494,7 +539,7 @@ const numberTypeToValibotSchema = ({ pipes.push(expression); } - return pipesToExpression(pipes); + return pipesToExpression({ pipes, plugin }); }; const objectTypeToValibotSchema = ({ @@ -546,12 +591,16 @@ const objectTypeToValibotSchema = ({ } properties.push( tsc.propertyAssignment({ - initializer: pipesToExpression(schemaPipes), + initializer: pipesToExpression({ pipes: schemaPipes, plugin }), name: propertyName, }), ); } + const vSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'valibot'), + ); + if ( schema.additionalProperties && schema.additionalProperties.type === 'object' && @@ -564,18 +613,18 @@ const objectTypeToValibotSchema = ({ }); const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.record, }), parameters: [ tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.string, }), parameters: [], }), - pipesToExpression(pipes), + pipesToExpression({ pipes, plugin }), ], }); return { @@ -586,7 +635,7 @@ const objectTypeToValibotSchema = ({ const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.object, }), parameters: [ts.factory.createObjectLiteralExpression(properties, true)], @@ -599,14 +648,20 @@ const objectTypeToValibotSchema = ({ }; const stringTypeToValibotSchema = ({ + plugin, schema, }: { + plugin: ValibotPlugin['Instance']; schema: SchemaWithType<'string'>; }) => { + const vSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'valibot'), + ); + if (typeof schema.const === 'string') { const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.literal, }), parameters: [tsc.ots.string(schema.const)], @@ -618,7 +673,7 @@ const stringTypeToValibotSchema = ({ const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.string, }), }); @@ -630,7 +685,7 @@ const stringTypeToValibotSchema = ({ pipes.push( tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.isoDate, }), }), @@ -640,7 +695,7 @@ const stringTypeToValibotSchema = ({ pipes.push( tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.isoTimestamp, }), }), @@ -651,7 +706,7 @@ const stringTypeToValibotSchema = ({ pipes.push( tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.ip, }), }), @@ -661,7 +716,7 @@ const stringTypeToValibotSchema = ({ pipes.push( tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.url, }), }), @@ -673,7 +728,7 @@ const stringTypeToValibotSchema = ({ pipes.push( tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: tsc.identifier({ text: schema.format }), }), }), @@ -685,7 +740,7 @@ const stringTypeToValibotSchema = ({ if (schema.minLength === schema.maxLength && schema.minLength !== undefined) { const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.length, }), parameters: [tsc.valueToExpression({ value: schema.minLength })], @@ -695,7 +750,7 @@ const stringTypeToValibotSchema = ({ if (schema.minLength !== undefined) { const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.minLength, }), parameters: [tsc.valueToExpression({ value: schema.minLength })], @@ -706,7 +761,7 @@ const stringTypeToValibotSchema = ({ if (schema.maxLength !== undefined) { const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.maxLength, }), parameters: [tsc.valueToExpression({ value: schema.maxLength })], @@ -718,7 +773,7 @@ const stringTypeToValibotSchema = ({ if (schema.pattern) { const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.regex, }), parameters: [tsc.regularExpressionLiteral({ text: schema.pattern })], @@ -726,7 +781,7 @@ const stringTypeToValibotSchema = ({ pipes.push(expression); } - return pipesToExpression(pipes); + return pipesToExpression({ pipes, plugin }); }; const tupleTypeToValibotSchema = ({ @@ -738,11 +793,15 @@ const tupleTypeToValibotSchema = ({ schema: SchemaWithType<'tuple'>; state: State; }) => { + const vSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'valibot'), + ); + if (schema.const && Array.isArray(schema.const)) { const tupleElements = schema.const.map((value) => tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.literal, }), parameters: [tsc.valueToExpression({ value })], @@ -750,7 +809,7 @@ const tupleTypeToValibotSchema = ({ ); const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.tuple, }), parameters: [ @@ -769,11 +828,11 @@ const tupleTypeToValibotSchema = ({ schema: item, state, }); - return pipesToExpression(schemaPipes); + return pipesToExpression({ pipes: schemaPipes, plugin }); }); const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.tuple, }), parameters: [ @@ -786,45 +845,64 @@ const tupleTypeToValibotSchema = ({ } return unknownTypeToValibotSchema({ + plugin, schema: { type: 'unknown', }, }); }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const undefinedTypeToValibotSchema = (_props: { +const undefinedTypeToValibotSchema = ({ + plugin, +}: { + plugin: ValibotPlugin['Instance']; schema: SchemaWithType<'undefined'>; }) => { + const vSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'valibot'), + ); + const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.undefined, }), }); return expression; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const unknownTypeToValibotSchema = (_props: { +const unknownTypeToValibotSchema = ({ + plugin, +}: { + plugin: ValibotPlugin['Instance']; schema: SchemaWithType<'unknown'>; }) => { + const vSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'valibot'), + ); + const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.unknown, }), }); return expression; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const voidTypeToValibotSchema = (_props: { +const voidTypeToValibotSchema = ({ + plugin, +}: { + plugin: ValibotPlugin['Instance']; schema: SchemaWithType<'void'>; }) => { + const vSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'valibot'), + ); + const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.void, }), }); @@ -855,12 +933,14 @@ const schemaTypeToValibotSchema = ({ case 'boolean': return { expression: booleanTypeToValibotSchema({ + plugin, schema: schema as SchemaWithType<'boolean'>, }), }; case 'enum': return { expression: enumTypeToValibotSchema({ + plugin, schema: schema as SchemaWithType<'enum'>, }), }; @@ -868,18 +948,21 @@ const schemaTypeToValibotSchema = ({ case 'number': return { expression: numberTypeToValibotSchema({ + plugin, schema: schema as SchemaWithType<'integer' | 'number'>, }), }; case 'never': return { expression: neverTypeToValibotSchema({ + plugin, schema: schema as SchemaWithType<'never'>, }), }; case 'null': return { expression: nullTypeToValibotSchema({ + plugin, schema: schema as SchemaWithType<'null'>, }), }; @@ -894,12 +977,14 @@ const schemaTypeToValibotSchema = ({ if (schema.format === 'int64' || schema.format === 'uint64') { return { expression: numberTypeToValibotSchema({ + plugin, schema: schema as SchemaWithType<'integer' | 'number'>, }), }; } return { expression: stringTypeToValibotSchema({ + plugin, schema: schema as SchemaWithType<'string'>, }), }; @@ -914,18 +999,21 @@ const schemaTypeToValibotSchema = ({ case 'undefined': return { expression: undefinedTypeToValibotSchema({ + plugin, schema: schema as SchemaWithType<'undefined'>, }), }; case 'unknown': return { expression: unknownTypeToValibotSchema({ + plugin, schema: schema as SchemaWithType<'unknown'>, }), }; case 'void': return { expression: voidTypeToValibotSchema({ + plugin, schema: schema as SchemaWithType<'void'>, }), }; @@ -934,17 +1022,16 @@ const schemaTypeToValibotSchema = ({ export const schemaToValibotSchema = ({ $ref, - identifier: _identifier, optional, plugin, schema, state, + symbol, }: { /** * When $ref is supplied, a node will be emitted to the file. */ $ref?: string; - identifier?: Identifier; /** * Accept `optional` to handle optional object properties. We can't handle * this inside the object function because `.optional()` must come before @@ -954,45 +1041,46 @@ export const schemaToValibotSchema = ({ plugin: ValibotPlugin['Instance']; schema: IR.SchemaObject; state: State; + symbol?: ICodegenSymbolOut; }): Array => { - // TODO: replace - const file = plugin.context.file({ id: valibotId })!; - // const f = plugin.gen.ensureFile(plugin.output); + const f = plugin.gen.ensureFile(plugin.output); let anyType: string | undefined; - let identifier: ReturnType | undefined = _identifier; let pipes: Array = []; if ($ref) { state.circularReferenceTracker.add($ref); - if (!identifier) { - identifier = file.identifier({ - $ref, - case: state.nameCase, - create: true, - nameTransformer: state.nameTransformer, - namespace: 'value', - }); - // TODO: claim unique name - // f.addSymbol({ name: '' }); + if (!symbol) { + const selector = plugin.api.getSelector('ref', $ref); + if (!plugin.gen.selectSymbolFirst(selector)) { + symbol = f.ensureSymbol({ + name: buildName({ + config: { + case: state.nameCase, + name: state.nameTransformer, + }, + name: refToName($ref), + }), + selector, + }); + } } } + const vSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'valibot'), + ); + if (schema.$ref) { const isCircularReference = state.circularReferenceTracker.has(schema.$ref); // if $ref hasn't been processed yet, inline it to avoid the // "Block-scoped variable used before its declaration." error // this could be (maybe?) fixed by reshuffling the generation order - let identifierRef = file.identifier({ - $ref: schema.$ref, - case: state.nameCase, - nameTransformer: state.nameTransformer, - namespace: 'value', - }); - - if (!identifierRef.name) { + const selector = plugin.api.getSelector('ref', schema.$ref); + let refSymbol = plugin.gen.selectSymbolFirst(selector); + if (!refSymbol) { const ref = plugin.context.resolveIrRef(schema.$ref); const schemaPipes = schemaToValibotSchema({ $ref: schema.$ref, @@ -1002,21 +1090,15 @@ export const schemaToValibotSchema = ({ }); pipes.push(...schemaPipes); - identifierRef = file.identifier({ - $ref: schema.$ref, - case: state.nameCase, - nameTransformer: state.nameTransformer, - namespace: 'value', - }); + refSymbol = plugin.gen.selectSymbolFirst(selector); } - // if `identifierRef.name` is falsy, we already set expression above - if (identifierRef.name) { - const refIdentifier = tsc.identifier({ text: identifierRef.name }); + if (refSymbol) { + const refIdentifier = tsc.identifier({ text: refSymbol.placeholder }); if (isCircularReference) { const lazyExpression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.lazy, }), parameters: [ @@ -1043,7 +1125,7 @@ export const schemaToValibotSchema = ({ if (plugin.config.metadata && schema.description) { const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.metadata, }), parameters: [ @@ -1069,13 +1151,13 @@ export const schemaToValibotSchema = ({ schema: item, state, }); - return pipesToExpression(schemaPipes); + return pipesToExpression({ pipes: schemaPipes, plugin }); }); if (schema.logicalOperator === 'and') { const intersectExpression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.intersect, }), parameters: [ @@ -1088,7 +1170,7 @@ export const schemaToValibotSchema = ({ } else { const unionExpression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.union, }), parameters: [ @@ -1128,7 +1210,7 @@ export const schemaToValibotSchema = ({ if (schema.accessScope === 'read') { const readonlyExpression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.actions.readonly, }), }); @@ -1146,10 +1228,10 @@ export const schemaToValibotSchema = ({ pipes = [ tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.optional, }), - parameters: [pipesToExpression(pipes), callParameter], + parameters: [pipesToExpression({ pipes, plugin }), callParameter], }), ]; } @@ -1159,38 +1241,31 @@ export const schemaToValibotSchema = ({ pipes = [ tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: identifiers.schemas.optional, }), - parameters: [pipesToExpression(pipes)], + parameters: [pipesToExpression({ pipes, plugin })], }), ]; } } - // emit nodes only if $ref points to a reusable component - if (identifier && identifier.name && identifier.created) { + if (symbol) { const statement = tsc.constVariable({ comment: plugin.config.comments ? createSchemaComment({ schema }) : undefined, exportConst: true, - expression: pipesToExpression(pipes), - name: identifier.name, + expression: pipesToExpression({ pipes, plugin }), + name: symbol.placeholder, typeName: state.hasCircularReference ? (tsc.propertyAccessExpression({ - expression: identifiers.v, + expression: vSymbol.placeholder, name: anyType || identifiers.types.GenericSchema.text, }) as unknown as ts.TypeNode) : undefined, }); - file.add(statement); - // TODO: update claimed name - // f.addSymbol({ - // name: identifier.name, - // value: statement, - // }); - + f.patchSymbol(symbol.id, { value: statement }); return []; } @@ -1198,23 +1273,17 @@ export const schemaToValibotSchema = ({ }; export const handler: ValibotPlugin['Handler'] = ({ plugin }) => { - const file = plugin.createFile({ - case: plugin.config.case, - id: valibotId, - path: plugin.output, + const f = plugin.gen.createFile(plugin.output, { + extension: '.ts', + path: '{{path}}.gen', + renderer: new TypeScriptRenderer(), }); - // const f = plugin.gen.createFile(plugin.output, { - // extension: '.ts', - // path: '{{path}}.gen', - // renderer: new TypeScriptRenderer(), - // }); - - file.import({ - alias: identifiers.v.text, - module: 'valibot', - name: '*', + + const vSymbol = f.ensureSymbol({ + name: 'v', + selector: plugin.api.getSelector('import', 'valibot'), }); - // f.addImport({ from: 'valibot', namespaceImport: identifiers.v.text }); + f.addImport({ from: 'valibot', namespaceImport: vSymbol.placeholder }); plugin.forEach( 'operation', @@ -1230,45 +1299,51 @@ export const handler: ValibotPlugin['Handler'] = ({ plugin }) => { nameTransformer: plugin.config.definitions.name, }; - if (event.type === 'operation') { - operationToValibotSchema({ - operation: event.operation, - plugin, - state, - }); - } else if (event.type === 'parameter') { - schemaToValibotSchema({ - $ref: event.$ref, - plugin, - schema: event.parameter.schema, - state, - }); - } else if (event.type === 'requestBody') { - schemaToValibotSchema({ - $ref: event.$ref, - plugin, - schema: event.requestBody.schema, - state, - }); - } else if (event.type === 'schema') { - schemaToValibotSchema({ - $ref: event.$ref, - plugin, - schema: event.schema, - state, - }); - } else if (event.type === 'webhook') { - webhookToValibotSchema({ - operation: event.operation, - plugin, - state, - }); + switch (event.type) { + case 'operation': + operationToValibotSchema({ + operation: event.operation, + plugin, + state, + }); + break; + case 'parameter': + schemaToValibotSchema({ + $ref: event.$ref, + plugin, + schema: event.parameter.schema, + state, + }); + break; + case 'requestBody': + schemaToValibotSchema({ + $ref: event.$ref, + plugin, + schema: event.requestBody.schema, + state, + }); + break; + case 'schema': + schemaToValibotSchema({ + $ref: event.$ref, + plugin, + schema: event.schema, + state, + }); + break; + case 'webhook': + webhookToValibotSchema({ + operation: event.operation, + plugin, + state, + }); + break; } }, ); - // if (plugin.config.exportFromIndex && f.hasContent()) { - // const index = plugin.gen.ensureFile('index'); - // index.addExport({ from: f, namespaceImport: true }); - // } + if (plugin.config.exportFromIndex && f.hasContent()) { + const index = plugin.gen.ensureFile('index'); + index.addExport({ from: f, namespaceImport: true }); + } }; diff --git a/packages/openapi-ts/src/plugins/valibot/webhook.ts b/packages/openapi-ts/src/plugins/valibot/webhook.ts index 954805ebf..0aae05ee4 100644 --- a/packages/openapi-ts/src/plugins/valibot/webhook.ts +++ b/packages/openapi-ts/src/plugins/valibot/webhook.ts @@ -1,5 +1,5 @@ import type { IR } from '../../ir/types'; -import { valibotId } from './constants'; +import { buildName } from '../../openApi/shared/utils/name'; import { schemaToValibotSchema, type State } from './plugin'; import type { ValibotPlugin } from './types'; @@ -12,8 +12,6 @@ export const webhookToValibotSchema = ({ plugin: ValibotPlugin['Instance']; state: State; }) => { - const file = plugin.context.file({ id: valibotId })!; - if (plugin.config.webhooks.enabled) { const requiredProperties = new Set(); @@ -112,21 +110,20 @@ export const webhookToValibotSchema = ({ schemaData.required = [...requiredProperties]; - const identifierData = file.identifier({ - // TODO: refactor for better cross-plugin compatibility - $ref: `#/valibot-webhook/${operation.id}`, - case: plugin.config.webhooks.case, - create: true, - nameTransformer: plugin.config.webhooks.name, - namespace: 'value', + const selector = plugin.api.getSelector('webhook-request', operation.id); + const name = buildName({ + config: plugin.config.webhooks, + name: operation.id, }); + const f = plugin.gen.ensureFile(plugin.output); + const symbol = f.addSymbol({ name, selector }); schemaToValibotSchema({ // TODO: refactor for better cross-plugin compatibility $ref: `#/valibot-webhook/${operation.id}`, - identifier: identifierData, plugin, schema: schemaData, state, + symbol, }); } }; diff --git a/packages/openapi-ts/src/plugins/zod/api.ts b/packages/openapi-ts/src/plugins/zod/api.ts index 7b47b78e0..054a2fbba 100644 --- a/packages/openapi-ts/src/plugins/zod/api.ts +++ b/packages/openapi-ts/src/plugins/zod/api.ts @@ -1,30 +1,64 @@ +import type { ICodegenFile } from '@hey-api/codegen-core'; import type ts from 'typescript'; -import type { GeneratedFile } from '../../generate/file'; import type { IR } from '../../ir/types'; import { tsc } from '../../tsc'; -import { identifiers, zodId } from './constants'; +import { identifiers } from './constants'; import type { ZodPlugin } from './types'; -const createRequestValidator = ({ +export type Api = { + createRequestValidator: (args: { + file: ICodegenFile; + operation: IR.OperationObject; + plugin: ZodPlugin['Instance']; + }) => ts.ArrowFunction | undefined; + createResponseValidator: (args: { + file: ICodegenFile; + operation: IR.OperationObject; + plugin: ZodPlugin['Instance']; + }) => ts.ArrowFunction | undefined; + /** + * @param type Selector type. + * @param value Depends on `type`: + * - `data`: `operation.id` string + * - `import`: headless symbols representing module imports + * - `ref`: `$ref` JSON pointer + * - `responses`: `operation.id` string + * - `type-infer-data`: `operation.id` string + * - `type-infer-ref`: `$ref` JSON pointer + * - `type-infer-responses`: `operation.id` string + * - `type-infer-webhook-request`: `operation.id` string + * - `webhook-request`: `operation.id` string + * @returns Selector array + */ + getSelector: ( + type: + | 'data' + | 'import' + | 'ref' + | 'responses' + | 'type-infer-data' + | 'type-infer-ref' + | 'type-infer-responses' + | 'type-infer-webhook-request' + | 'webhook-request', + value: string, + ) => ReadonlyArray; +}; + +const createRequestValidator: Api['createRequestValidator'] = ({ file, operation, plugin, -}: { - file: GeneratedFile; - operation: IR.OperationObject; - plugin: ZodPlugin['Instance']; -}): ts.ArrowFunction | undefined => { - const zodFile = plugin.context.file({ id: zodId })!; - const name = zodFile.getName(plugin.api.getId({ operation, type: 'data' })); - if (!name) return; +}) => { + const symbol = plugin.gen.selectSymbolFirst( + plugin.api.getSelector('data', operation.id), + ); + if (!symbol) return; - file.import({ - module: file.relativePathToFile({ - context: plugin.context, - id: zodId, - }), - name, + file.addImport({ + from: plugin.gen.getFileBySymbol(symbol), + names: [symbol.placeholder], }); const dataParameterName = 'data'; @@ -41,7 +75,7 @@ const createRequestValidator = ({ expression: tsc.awaitExpression({ expression: tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: tsc.identifier({ text: name }), + expression: symbol.placeholder, name: identifiers.parseAsync, }), parameters: [tsc.identifier({ text: dataParameterName })], @@ -52,27 +86,19 @@ const createRequestValidator = ({ }); }; -const createResponseValidator = ({ +const createResponseValidator: Api['createResponseValidator'] = ({ file, operation, plugin, -}: { - file: GeneratedFile; - operation: IR.OperationObject; - plugin: ZodPlugin['Instance']; -}): ts.ArrowFunction | undefined => { - const zodFile = plugin.context.file({ id: zodId })!; - const name = zodFile.getName( - plugin.api.getId({ operation, type: 'responses' }), +}) => { + const symbol = plugin.gen.selectSymbolFirst( + plugin.api.getSelector('responses', operation.id), ); - if (!name) return; + if (!symbol) return; - file.import({ - module: file.relativePathToFile({ - context: plugin.context, - id: zodId, - }), - name, + file.addImport({ + from: plugin.gen.getFileBySymbol(symbol), + names: [symbol.placeholder], }); const dataParameterName = 'data'; @@ -89,7 +115,7 @@ const createResponseValidator = ({ expression: tsc.awaitExpression({ expression: tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: tsc.identifier({ text: name }), + expression: symbol.placeholder, name: identifiers.parseAsync, }), parameters: [tsc.identifier({ text: dataParameterName })], @@ -100,54 +126,10 @@ const createResponseValidator = ({ }); }; -type GetIdArgs = - | { - operation: IR.OperationObject; - type: - | 'data' - | 'responses' - | 'type-infer-data' - | 'type-infer-responses' - | 'type-infer-webhook-request' - | 'webhook-request'; - } - | { - type: 'ref' | 'type-infer-ref'; - value: string; - }; - -const getId = (args: GetIdArgs): string => { - switch (args.type) { - case 'data': - case 'responses': - case 'type-infer-data': - case 'type-infer-responses': - case 'type-infer-webhook-request': - case 'webhook-request': - return `${args.operation.id}-${args.type}`; - case 'ref': - case 'type-infer-ref': - default: - return `${args.type}-${args.value}`; - } -}; - -export type Api = { - createRequestValidator: (args: { - file: GeneratedFile; - operation: IR.OperationObject; - plugin: ZodPlugin['Instance']; - }) => ts.ArrowFunction | undefined; - createResponseValidator: (args: { - file: GeneratedFile; - operation: IR.OperationObject; - plugin: ZodPlugin['Instance']; - }) => ts.ArrowFunction | undefined; - getId: (args: GetIdArgs) => string; -}; +const getSelector: Api['getSelector'] = (...args) => ['zod', ...args]; export const api: Api = { createRequestValidator, createResponseValidator, - getId, + getSelector, }; diff --git a/packages/openapi-ts/src/plugins/zod/constants.ts b/packages/openapi-ts/src/plugins/zod/constants.ts index f46558180..5fbf81c8e 100644 --- a/packages/openapi-ts/src/plugins/zod/constants.ts +++ b/packages/openapi-ts/src/plugins/zod/constants.ts @@ -55,7 +55,4 @@ export const identifiers = { url: tsc.identifier({ text: 'url' }), uuid: tsc.identifier({ text: 'uuid' }), void: tsc.identifier({ text: 'void' }), - z: tsc.identifier({ text: 'z' }), }; - -export const zodId = 'zod'; diff --git a/packages/openapi-ts/src/plugins/zod/export.ts b/packages/openapi-ts/src/plugins/zod/export.ts index 4cf43bd4d..dc14ce8eb 100644 --- a/packages/openapi-ts/src/plugins/zod/export.ts +++ b/packages/openapi-ts/src/plugins/zod/export.ts @@ -1,67 +1,64 @@ +import type { ICodegenSymbolOut } from '@hey-api/codegen-core'; import type ts from 'typescript'; import type { IR } from '../../ir/types'; import { tsc } from '../../tsc'; import { createSchemaComment } from '../shared/utils/schema'; -import { identifiers, zodId } from './constants'; +import { identifiers } from './constants'; import type { ZodSchema } from './shared/types'; import type { ZodPlugin } from './types'; export const exportZodSchema = ({ plugin, schema, - schemaId, - typeInferId, + symbol, + typeInferSymbol, zodSchema, }: { plugin: ZodPlugin['Instance']; schema: IR.SchemaObject; - schemaId: string; - typeInferId: string | undefined; + symbol: ICodegenSymbolOut; + typeInferSymbol: ICodegenSymbolOut | undefined; zodSchema: ZodSchema; }) => { - const file = plugin.context.file({ id: zodId })!; - const node = file.addNodeReference(schemaId, { - factory: (typeName) => tsc.typeReferenceNode({ typeName }), - }); + const f = plugin.gen.ensureFile(plugin.output); + + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + const statement = tsc.constVariable({ comment: plugin.config.comments ? createSchemaComment({ schema }) : undefined, exportConst: true, expression: zodSchema.expression, - name: node, + name: symbol.placeholder, typeName: zodSchema.typeName ? (tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: zodSchema.typeName, }) as unknown as ts.TypeNode) : undefined, }); - file.add(statement); + f.patchSymbol(symbol.id, { value: statement }); - if (typeInferId) { - const inferNode = file.addNodeReference(typeInferId, { - factory: (typeName) => tsc.typeReferenceNode({ typeName }), - }); - const nodeIdentifier = file.addNodeReference(schemaId, { - factory: (text) => tsc.identifier({ text }), - }); + if (typeInferSymbol) { const inferType = tsc.typeAliasDeclaration({ exportType: true, - name: inferNode, + name: typeInferSymbol.placeholder, type: tsc.typeReferenceNode({ typeArguments: [ tsc.typeOfExpression({ - text: nodeIdentifier, + text: symbol.placeholder, }) as unknown as ts.TypeNode, ], typeName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.infer, }) as unknown as string, }), }); - file.add(inferType); + f.patchSymbol(typeInferSymbol.id, { value: inferType }); } }; diff --git a/packages/openapi-ts/src/plugins/zod/mini/plugin.ts b/packages/openapi-ts/src/plugins/zod/mini/plugin.ts index df8a91a4b..ca5ad2546 100644 --- a/packages/openapi-ts/src/plugins/zod/mini/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/mini/plugin.ts @@ -1,12 +1,13 @@ import ts from 'typescript'; +import { TypeScriptRenderer } from '../../../generate/renderer'; import { deduplicateSchema } from '../../../ir/schema'; import type { IR } from '../../../ir/types'; import { buildName } from '../../../openApi/shared/utils/name'; import { tsc } from '../../../tsc'; import { refToName } from '../../../utils/ref'; import { numberRegExp } from '../../../utils/regexp'; -import { identifiers, zodId } from '../constants'; +import { identifiers } from '../constants'; import { exportZodSchema } from '../export'; import { getZodModule } from '../shared/module'; import { operationToZodSchema } from '../shared/operation'; @@ -23,10 +24,14 @@ const arrayTypeToZodSchema = ({ schema: SchemaWithType<'array'>; state: State; }): Omit => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + const result: Partial> = {}; const functionName = tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.array, }); @@ -35,6 +40,7 @@ const arrayTypeToZodSchema = ({ functionName, parameters: [ unknownTypeToZodSchema({ + plugin, schema: { type: 'unknown', }, @@ -72,13 +78,13 @@ const arrayTypeToZodSchema = ({ result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.array, }), parameters: [ tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.union, }), parameters: [ @@ -98,7 +104,7 @@ const arrayTypeToZodSchema = ({ checks.push( tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.length, }), parameters: [tsc.valueToExpression({ value: schema.minItems })], @@ -109,7 +115,7 @@ const arrayTypeToZodSchema = ({ checks.push( tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.minLength, }), parameters: [tsc.valueToExpression({ value: schema.minItems })], @@ -121,7 +127,7 @@ const arrayTypeToZodSchema = ({ checks.push( tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.maxLength, }), parameters: [tsc.valueToExpression({ value: schema.maxItems })], @@ -144,16 +150,22 @@ const arrayTypeToZodSchema = ({ }; const booleanTypeToZodSchema = ({ + plugin, schema, }: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'boolean'>; }): Omit => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + const result: Partial> = {}; if (typeof schema.const === 'boolean') { result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.literal, }), parameters: [tsc.ots.boolean(schema.const)], @@ -163,7 +175,7 @@ const booleanTypeToZodSchema = ({ result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.boolean, }), }); @@ -171,10 +183,16 @@ const booleanTypeToZodSchema = ({ }; const enumTypeToZodSchema = ({ + plugin, schema, }: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'enum'>; }): Omit => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + const result: Partial> = {}; const enumMembers: Array = []; @@ -196,6 +214,7 @@ const enumTypeToZodSchema = ({ if (!enumMembers.length) { return unknownTypeToZodSchema({ + plugin, schema: { type: 'unknown', }, @@ -204,7 +223,7 @@ const enumTypeToZodSchema = ({ result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.enum, }), parameters: [ @@ -218,7 +237,7 @@ const enumTypeToZodSchema = ({ if (isNullable) { result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.nullable, }), parameters: [result.expression], @@ -228,28 +247,38 @@ const enumTypeToZodSchema = ({ return result as Omit; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const neverTypeToZodSchema = (_props: { +const neverTypeToZodSchema = ({ + plugin, +}: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'never'>; }): Omit => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); const result: Partial> = {}; result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.never, }), }); return result as Omit; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const nullTypeToZodSchema = (_props: { +const nullTypeToZodSchema = ({ + plugin, +}: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'null'>; }): Omit => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); const result: Partial> = {}; result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.null, }), }); @@ -282,10 +311,16 @@ const numberParameter = ({ }; const numberTypeToZodSchema = ({ + plugin, schema, }: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'integer' | 'number'>; }): Omit => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + const result: Partial> = {}; const isBigInt = schema.type === 'integer' && schema.format === 'int64'; @@ -294,7 +329,7 @@ const numberTypeToZodSchema = ({ // TODO: parser - handle bigint constants result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.literal, }), parameters: [tsc.ots.number(schema.const)], @@ -306,13 +341,13 @@ const numberTypeToZodSchema = ({ functionName: isBigInt ? tsc.propertyAccessExpression({ expression: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.coerce, }), name: identifiers.bigint, }) : tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.number, }), }); @@ -320,7 +355,7 @@ const numberTypeToZodSchema = ({ if (!isBigInt && schema.type === 'integer') { result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.int, }), }); @@ -332,7 +367,7 @@ const numberTypeToZodSchema = ({ checks.push( tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.gt, }), parameters: [ @@ -344,7 +379,7 @@ const numberTypeToZodSchema = ({ checks.push( tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.gte, }), parameters: [numberParameter({ isBigInt, value: schema.minimum })], @@ -356,7 +391,7 @@ const numberTypeToZodSchema = ({ checks.push( tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.lt, }), parameters: [ @@ -368,7 +403,7 @@ const numberTypeToZodSchema = ({ checks.push( tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.lte, }), parameters: [numberParameter({ isBigInt, value: schema.maximum })], @@ -398,6 +433,10 @@ const objectTypeToZodSchema = ({ schema: SchemaWithType<'object'>; state: State; }): Omit => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + const result: Partial> = {}; // TODO: parser - handle constants @@ -447,7 +486,7 @@ const objectTypeToZodSchema = ({ // @ts-expect-error returnType: propertySchema.typeName ? tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: propertySchema.typeName, }) : undefined, @@ -479,13 +518,13 @@ const objectTypeToZodSchema = ({ }); result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.record, }), parameters: [ tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.string, }), parameters: [], @@ -501,7 +540,7 @@ const objectTypeToZodSchema = ({ result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.object, }), parameters: [ts.factory.createObjectLiteralExpression(properties, true)], @@ -517,12 +556,16 @@ const stringTypeToZodSchema = ({ plugin: ZodPlugin['Instance']; schema: SchemaWithType<'string'>; }): Omit => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + const result: Partial> = {}; if (typeof schema.const === 'string') { result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.literal, }), parameters: [tsc.ots.string(schema.const)], @@ -532,7 +575,7 @@ const stringTypeToZodSchema = ({ result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.string, }), }); @@ -552,7 +595,7 @@ const stringTypeToZodSchema = ({ result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ expression: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.iso, }), name: identifiers.date, @@ -563,7 +606,7 @@ const stringTypeToZodSchema = ({ result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ expression: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.iso, }), name: identifiers.datetime, @@ -581,7 +624,7 @@ const stringTypeToZodSchema = ({ case 'email': result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.email, }), }); @@ -589,7 +632,7 @@ const stringTypeToZodSchema = ({ case 'ipv4': result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.ipv4, }), }); @@ -597,7 +640,7 @@ const stringTypeToZodSchema = ({ case 'ipv6': result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.ipv6, }), }); @@ -606,7 +649,7 @@ const stringTypeToZodSchema = ({ result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ expression: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.iso, }), name: identifiers.time, @@ -616,7 +659,7 @@ const stringTypeToZodSchema = ({ case 'uri': result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.url, }), }); @@ -624,7 +667,7 @@ const stringTypeToZodSchema = ({ case 'uuid': result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.uuid, }), }); @@ -638,7 +681,7 @@ const stringTypeToZodSchema = ({ checks.push( tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.length, }), parameters: [tsc.valueToExpression({ value: schema.minLength })], @@ -649,7 +692,7 @@ const stringTypeToZodSchema = ({ checks.push( tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.minLength, }), parameters: [tsc.valueToExpression({ value: schema.minLength })], @@ -661,7 +704,7 @@ const stringTypeToZodSchema = ({ checks.push( tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.maxLength, }), parameters: [tsc.valueToExpression({ value: schema.maxLength })], @@ -674,7 +717,7 @@ const stringTypeToZodSchema = ({ checks.push( tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.regex, }), parameters: [tsc.regularExpressionLiteral({ text: schema.pattern })], @@ -704,13 +747,17 @@ const tupleTypeToZodSchema = ({ schema: SchemaWithType<'tuple'>; state: State; }): Omit => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + const result: Partial> = {}; if (schema.const && Array.isArray(schema.const)) { const tupleElements = schema.const.map((value) => tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.literal, }), parameters: [tsc.valueToExpression({ value })], @@ -718,7 +765,7 @@ const tupleTypeToZodSchema = ({ ); result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.tuple, }), parameters: [ @@ -747,7 +794,7 @@ const tupleTypeToZodSchema = ({ result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.tuple, }), parameters: [ @@ -760,42 +807,57 @@ const tupleTypeToZodSchema = ({ return result as Omit; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const undefinedTypeToZodSchema = (_props: { +const undefinedTypeToZodSchema = ({ + plugin, +}: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'undefined'>; }): Omit => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); const result: Partial> = {}; result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.undefined, }), }); return result as Omit; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const unknownTypeToZodSchema = (_props: { +const unknownTypeToZodSchema = ({ + plugin, +}: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'unknown'>; }): Omit => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); const result: Partial> = {}; result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.unknown, }), }); return result as Omit; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const voidTypeToZodSchema = (_props: { +const voidTypeToZodSchema = ({ + plugin, +}: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'void'>; }): Omit => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); const result: Partial> = {}; result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.void, }), }); @@ -820,23 +882,28 @@ const schemaTypeToZodSchema = ({ }); case 'boolean': return booleanTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'boolean'>, }); case 'enum': return enumTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'enum'>, }); case 'integer': case 'number': return numberTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'integer' | 'number'>, }); case 'never': return neverTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'never'>, }); case 'null': return nullTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'null'>, }); case 'object': @@ -858,14 +925,17 @@ const schemaTypeToZodSchema = ({ }); case 'undefined': return undefinedTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'undefined'>, }); case 'unknown': return unknownTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'unknown'>, }); case 'void': return voidTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'void'>, }); } @@ -887,10 +957,14 @@ const schemaToZodSchema = ({ schema: IR.SchemaObject; state: State; }): ZodSchema => { - const file = plugin.context.file({ id: zodId })!; + const f = plugin.gen.ensureFile(plugin.output); let zodSchema: Partial = {}; + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + if (schema.$ref) { const isCircularReference = state.circularReferenceTracker.includes( schema.$ref, @@ -899,30 +973,36 @@ const schemaToZodSchema = ({ state.circularReferenceTracker.push(schema.$ref); state.currentReferenceTracker.push(schema.$ref); - const id = plugin.api.getId({ type: 'ref', value: schema.$ref }); + const selector = plugin.api.getSelector('ref', schema.$ref); + let symbol = plugin.gen.selectSymbolFirst(selector); if (isCircularReference) { - const expression = file.addNodeReference(id, { - factory: (text) => tsc.identifier({ text }), - }); + if (!symbol) { + symbol = f.ensureSymbol({ selector }); + } + if (isSelfReference) { zodSchema.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.lazy, }), parameters: [ tsc.arrowFunction({ returnType: tsc.keywordTypeNode({ keyword: 'any' }), - statements: [tsc.returnStatement({ expression })], + statements: [ + tsc.returnStatement({ + expression: tsc.identifier({ text: symbol.placeholder }), + }), + ], }), ], }); } else { - zodSchema.expression = expression; + zodSchema.expression = tsc.identifier({ text: symbol.placeholder }); } zodSchema.hasCircularReference = true; - } else if (!file.getName(id)) { + } else if (!symbol) { // if $ref hasn't been processed yet, inline it to avoid the // "Block-scoped variable used before its declaration." error // this could be (maybe?) fixed by reshuffling the generation order @@ -936,10 +1016,8 @@ const schemaToZodSchema = ({ } if (!isCircularReference) { - const expression = file.addNodeReference(id, { - factory: (text) => tsc.identifier({ text }), - }); - zodSchema.expression = expression; + const symbol = plugin.gen.selectSymbolFirstOrThrow(selector); + zodSchema.expression = tsc.identifier({ text: symbol.placeholder }); } state.circularReferenceTracker.pop(); @@ -957,7 +1035,7 @@ const schemaToZodSchema = ({ }), parameters: [ tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.globalRegistry, }), tsc.objectExpression({ @@ -995,7 +1073,7 @@ const schemaToZodSchema = ({ ) { zodSchema.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.intersection, }), parameters: itemTypes, @@ -1005,7 +1083,7 @@ const schemaToZodSchema = ({ itemTypes.slice(1).forEach((item) => { zodSchema.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.intersection, }), parameters: [zodSchema.expression, item], @@ -1015,7 +1093,7 @@ const schemaToZodSchema = ({ } else { zodSchema.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.union, }), parameters: [ @@ -1044,7 +1122,7 @@ const schemaToZodSchema = ({ if (schema.accessScope === 'read') { zodSchema.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.readonly, }), parameters: [zodSchema.expression], @@ -1054,7 +1132,7 @@ const schemaToZodSchema = ({ if (optional) { zodSchema.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.optional, }), parameters: [zodSchema.expression], @@ -1071,7 +1149,7 @@ const schemaToZodSchema = ({ if (callParameter) { zodSchema.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers._default, }), parameters: [zodSchema.expression, callParameter], @@ -1101,52 +1179,55 @@ const handleComponent = ({ currentReferenceTracker: [id], }; - const file = plugin.context.file({ id: zodId })!; - const schemaId = plugin.api.getId({ type: 'ref', value: id }); - - if (file.getName(schemaId)) return; + const selector = plugin.api.getSelector('ref', id); + let symbol = plugin.gen.selectSymbolFirst(selector); + if (symbol && !symbol.headless) return; const zodSchema = schemaToZodSchema({ plugin, schema, state }); - const typeInferId = plugin.config.definitions.types.infer.enabled - ? plugin.api.getId({ type: 'type-infer-ref', value: id }) + const f = plugin.gen.ensureFile(plugin.output); + const baseName = refToName(id); + symbol = f.ensureSymbol({ selector }); + symbol = f.patchSymbol(symbol.id, { + name: buildName({ + config: plugin.config.definitions, + name: baseName, + }), + }); + const typeInferSymbol = plugin.config.definitions.types.infer.enabled + ? f.addSymbol({ + name: buildName({ + config: plugin.config.definitions.types.infer, + name: baseName, + }), + selector: plugin.api.getSelector('type-infer-ref', id), + }) : undefined; exportZodSchema({ plugin, schema, - schemaId, - typeInferId, + symbol, + typeInferSymbol, zodSchema, }); - const baseName = refToName(id); - file.updateNodeReferences( - schemaId, - buildName({ - config: plugin.config.definitions, - name: baseName, - }), - ); - if (typeInferId) { - file.updateNodeReferences( - typeInferId, - buildName({ - config: plugin.config.definitions.types.infer, - name: baseName, - }), - ); - } }; export const handlerMini: ZodPlugin['Handler'] = ({ plugin }) => { - const file = plugin.createFile({ - case: plugin.config.case, - id: zodId, - path: plugin.output, + const f = plugin.gen.createFile(plugin.output, { + extension: '.ts', + path: '{{path}}.gen', + renderer: new TypeScriptRenderer(), }); - file.import({ - alias: identifiers.z.text, - module: getZodModule({ plugin }), - name: '*', + const zSymbol = f.ensureSymbol({ + name: 'z', + selector: plugin.api.getSelector('import', 'zod'), + }); + f.addImport({ + aliases: { + z: zSymbol.placeholder, + }, + from: getZodModule({ plugin }), + names: ['z'], }); plugin.forEach( @@ -1156,51 +1237,62 @@ export const handlerMini: ZodPlugin['Handler'] = ({ plugin }) => { 'schema', 'webhook', (event) => { - if (event.type === 'operation') { - operationToZodSchema({ - getZodSchema: (schema) => { - const state: State = { - circularReferenceTracker: [], - currentReferenceTracker: [], - hasCircularReference: false, - }; - return schemaToZodSchema({ plugin, schema, state }); - }, - operation: event.operation, - plugin, - }); - } else if (event.type === 'parameter') { - handleComponent({ - id: event.$ref, - plugin, - schema: event.parameter.schema, - }); - } else if (event.type === 'requestBody') { - handleComponent({ - id: event.$ref, - plugin, - schema: event.requestBody.schema, - }); - } else if (event.type === 'schema') { - handleComponent({ - id: event.$ref, - plugin, - schema: event.schema, - }); - } else if (event.type === 'webhook') { - webhookToZodSchema({ - getZodSchema: (schema) => { - const state: State = { - circularReferenceTracker: [], - currentReferenceTracker: [], - hasCircularReference: false, - }; - return schemaToZodSchema({ plugin, schema, state }); - }, - operation: event.operation, - plugin, - }); + switch (event.type) { + case 'operation': + operationToZodSchema({ + getZodSchema: (schema) => { + const state: State = { + circularReferenceTracker: [], + currentReferenceTracker: [], + hasCircularReference: false, + }; + return schemaToZodSchema({ plugin, schema, state }); + }, + operation: event.operation, + plugin, + }); + break; + case 'parameter': + handleComponent({ + id: event.$ref, + plugin, + schema: event.parameter.schema, + }); + break; + case 'requestBody': + handleComponent({ + id: event.$ref, + plugin, + schema: event.requestBody.schema, + }); + break; + case 'schema': + handleComponent({ + id: event.$ref, + plugin, + schema: event.schema, + }); + break; + case 'webhook': + webhookToZodSchema({ + getZodSchema: (schema) => { + const state: State = { + circularReferenceTracker: [], + currentReferenceTracker: [], + hasCircularReference: false, + }; + return schemaToZodSchema({ plugin, schema, state }); + }, + operation: event.operation, + plugin, + }); + break; } }, ); + + if (plugin.config.exportFromIndex && f.hasContent()) { + const index = plugin.gen.ensureFile('index'); + index.addExport({ from: f, namespaceImport: true }); + } }; diff --git a/packages/openapi-ts/src/plugins/zod/shared/operation.ts b/packages/openapi-ts/src/plugins/zod/shared/operation.ts index 5bed8b75a..89215872d 100644 --- a/packages/openapi-ts/src/plugins/zod/shared/operation.ts +++ b/packages/openapi-ts/src/plugins/zod/shared/operation.ts @@ -1,7 +1,6 @@ import { operationResponsesMap } from '../../../ir/operation'; import type { IR } from '../../../ir/types'; import { buildName } from '../../../openApi/shared/utils/name'; -import { zodId } from '../constants'; import { exportZodSchema } from '../export'; import type { ZodPlugin } from '../types'; import type { ZodSchema } from './types'; @@ -15,7 +14,7 @@ export const operationToZodSchema = ({ operation: IR.OperationObject; plugin: ZodPlugin['Instance']; }) => { - const file = plugin.context.file({ id: zodId })!; + const f = plugin.gen.ensureFile(plugin.output); if (plugin.config.requests.enabled) { const requiredProperties = new Set(); @@ -116,33 +115,29 @@ export const operationToZodSchema = ({ schemaData.required = [...requiredProperties]; const zodSchema = getZodSchema(schemaData); - const schemaId = plugin.api.getId({ operation, type: 'data' }); - const typeInferId = plugin.config.requests.types.infer.enabled - ? plugin.api.getId({ operation, type: 'type-infer-data' }) + const symbol = f.addSymbol({ + name: buildName({ + config: plugin.config.requests, + name: operation.id, + }), + selector: plugin.api.getSelector('data', operation.id), + }); + const typeInferSymbol = plugin.config.requests.types.infer.enabled + ? f.addSymbol({ + name: buildName({ + config: plugin.config.requests.types.infer, + name: operation.id, + }), + selector: plugin.api.getSelector('type-infer-data', operation.id), + }) : undefined; exportZodSchema({ plugin, schema: schemaData, - schemaId, - typeInferId, + symbol, + typeInferSymbol, zodSchema, }); - file.updateNodeReferences( - schemaId, - buildName({ - config: plugin.config.requests, - name: operation.id, - }), - ); - if (typeInferId) { - file.updateNodeReferences( - typeInferId, - buildName({ - config: plugin.config.requests.types.infer, - name: operation.id, - }), - ); - } } if (plugin.config.responses.enabled) { @@ -151,33 +146,32 @@ export const operationToZodSchema = ({ if (response) { const zodSchema = getZodSchema(response); - const schemaId = plugin.api.getId({ operation, type: 'responses' }); - const typeInferId = plugin.config.responses.types.infer.enabled - ? plugin.api.getId({ operation, type: 'type-infer-responses' }) + const symbol = f.addSymbol({ + name: buildName({ + config: plugin.config.responses, + name: operation.id, + }), + selector: plugin.api.getSelector('responses', operation.id), + }); + const typeInferSymbol = plugin.config.responses.types.infer.enabled + ? f.addSymbol({ + name: buildName({ + config: plugin.config.responses.types.infer, + name: operation.id, + }), + selector: plugin.api.getSelector( + 'type-infer-responses', + operation.id, + ), + }) : undefined; exportZodSchema({ plugin, schema: response, - schemaId, - typeInferId, + symbol, + typeInferSymbol, zodSchema, }); - file.updateNodeReferences( - schemaId, - buildName({ - config: plugin.config.responses, - name: operation.id, - }), - ); - if (typeInferId) { - file.updateNodeReferences( - typeInferId, - buildName({ - config: plugin.config.responses.types.infer, - name: operation.id, - }), - ); - } } } } diff --git a/packages/openapi-ts/src/plugins/zod/shared/webhook.ts b/packages/openapi-ts/src/plugins/zod/shared/webhook.ts index d014899dd..5e8374943 100644 --- a/packages/openapi-ts/src/plugins/zod/shared/webhook.ts +++ b/packages/openapi-ts/src/plugins/zod/shared/webhook.ts @@ -1,6 +1,5 @@ import type { IR } from '../../../ir/types'; import { buildName } from '../../../openApi/shared/utils/name'; -import { zodId } from '../constants'; import { exportZodSchema } from '../export'; import type { ZodPlugin } from '../types'; import type { ZodSchema } from './types'; @@ -14,7 +13,7 @@ export const webhookToZodSchema = ({ operation: IR.OperationObject; plugin: ZodPlugin['Instance']; }) => { - const file = plugin.context.file({ id: zodId })!; + const f = plugin.gen.ensureFile(plugin.output); if (plugin.config.webhooks.enabled) { const requiredProperties = new Set(); @@ -115,32 +114,31 @@ export const webhookToZodSchema = ({ schemaData.required = [...requiredProperties]; const zodSchema = getZodSchema(schemaData); - const schemaId = plugin.api.getId({ operation, type: 'webhook-request' }); - const typeInferId = plugin.config.webhooks.types.infer.enabled - ? plugin.api.getId({ operation, type: 'type-infer-webhook-request' }) + const symbol = f.addSymbol({ + name: buildName({ + config: plugin.config.webhooks, + name: operation.id, + }), + selector: plugin.api.getSelector('webhook-request', operation.id), + }); + const typeInferSymbol = plugin.config.webhooks.types.infer.enabled + ? f.addSymbol({ + name: buildName({ + config: plugin.config.webhooks.types.infer, + name: operation.id, + }), + selector: plugin.api.getSelector( + 'type-infer-webhook-request', + operation.id, + ), + }) : undefined; exportZodSchema({ plugin, schema: schemaData, - schemaId, - typeInferId, + symbol, + typeInferSymbol, zodSchema, }); - file.updateNodeReferences( - schemaId, - buildName({ - config: plugin.config.webhooks, - name: operation.id, - }), - ); - if (typeInferId) { - file.updateNodeReferences( - typeInferId, - buildName({ - config: plugin.config.webhooks.types.infer, - name: operation.id, - }), - ); - } } }; diff --git a/packages/openapi-ts/src/plugins/zod/v3/plugin.ts b/packages/openapi-ts/src/plugins/zod/v3/plugin.ts index fa265b87e..afe606487 100644 --- a/packages/openapi-ts/src/plugins/zod/v3/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/v3/plugin.ts @@ -1,12 +1,13 @@ import ts from 'typescript'; +import { TypeScriptRenderer } from '../../../generate/renderer'; import { deduplicateSchema } from '../../../ir/schema'; import type { IR } from '../../../ir/types'; import { buildName } from '../../../openApi/shared/utils/name'; import { tsc } from '../../../tsc'; import { refToName } from '../../../utils/ref'; import { numberRegExp } from '../../../utils/regexp'; -import { identifiers, zodId } from '../constants'; +import { identifiers } from '../constants'; import { exportZodSchema } from '../export'; import { getZodModule } from '../shared/module'; import { operationToZodSchema } from '../shared/operation'; @@ -23,8 +24,12 @@ const arrayTypeToZodSchema = ({ schema: SchemaWithType<'array'>; state: State; }): ts.CallExpression => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + const functionName = tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.array, }); @@ -35,6 +40,7 @@ const arrayTypeToZodSchema = ({ functionName, parameters: [ unknownTypeToZodSchema({ + plugin, schema: { type: 'unknown', }, @@ -69,13 +75,13 @@ const arrayTypeToZodSchema = ({ arrayExpression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.array, }), parameters: [ tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.union, }), parameters: [ @@ -123,14 +129,20 @@ const arrayTypeToZodSchema = ({ }; const booleanTypeToZodSchema = ({ + plugin, schema, }: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'boolean'>; }) => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + if (typeof schema.const === 'boolean') { const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.literal, }), parameters: [tsc.ots.boolean(schema.const)], @@ -140,7 +152,7 @@ const booleanTypeToZodSchema = ({ const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.boolean, }), }); @@ -148,10 +160,16 @@ const booleanTypeToZodSchema = ({ }; const enumTypeToZodSchema = ({ + plugin, schema, }: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'enum'>; }): ts.CallExpression => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + const enumMembers: Array = []; let isNullable = false; @@ -171,6 +189,7 @@ const enumTypeToZodSchema = ({ if (!enumMembers.length) { return unknownTypeToZodSchema({ + plugin, schema: { type: 'unknown', }, @@ -179,7 +198,7 @@ const enumTypeToZodSchema = ({ let enumExpression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.enum, }), parameters: [ @@ -202,22 +221,36 @@ const enumTypeToZodSchema = ({ return enumExpression; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const neverTypeToZodSchema = (_props: { schema: SchemaWithType<'never'> }) => { +const neverTypeToZodSchema = ({ + plugin, +}: { + plugin: ZodPlugin['Instance']; + schema: SchemaWithType<'never'>; +}) => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.never, }), }); return expression; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const nullTypeToZodSchema = (_props: { schema: SchemaWithType<'null'> }) => { +const nullTypeToZodSchema = ({ + plugin, +}: { + plugin: ZodPlugin['Instance']; + schema: SchemaWithType<'null'>; +}) => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.null, }), }); @@ -250,17 +283,23 @@ const numberParameter = ({ }; const numberTypeToZodSchema = ({ + plugin, schema, }: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'integer' | 'number'>; }) => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + const isBigInt = schema.type === 'integer' && schema.format === 'int64'; if (typeof schema.const === 'number') { // TODO: parser - handle bigint constants const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.literal, }), parameters: [tsc.ots.number(schema.const)], @@ -272,13 +311,13 @@ const numberTypeToZodSchema = ({ functionName: isBigInt ? tsc.propertyAccessExpression({ expression: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.coerce, }), name: identifiers.bigint, }) : tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.number, }), }); @@ -347,6 +386,10 @@ const objectTypeToZodSchema = ({ anyType: string; expression: ts.CallExpression; } => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + // TODO: parser - handle constants const properties: Array = []; @@ -401,7 +444,7 @@ const objectTypeToZodSchema = ({ }).expression; const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.record, }), parameters: [zodSchema], @@ -414,7 +457,7 @@ const objectTypeToZodSchema = ({ const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.object, }), parameters: [ts.factory.createObjectLiteralExpression(properties, true)], @@ -432,10 +475,14 @@ const stringTypeToZodSchema = ({ plugin: ZodPlugin['Instance']; schema: SchemaWithType<'string'>; }) => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + if (typeof schema.const === 'string') { const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.literal, }), parameters: [tsc.ots.string(schema.const)], @@ -445,7 +492,7 @@ const stringTypeToZodSchema = ({ let stringExpression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.string, }), }); @@ -581,11 +628,15 @@ const tupleTypeToZodSchema = ({ schema: SchemaWithType<'tuple'>; state: State; }) => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + if (schema.const && Array.isArray(schema.const)) { const tupleElements = schema.const.map((value) => tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.literal, }), parameters: [tsc.valueToExpression({ value })], @@ -593,7 +644,7 @@ const tupleTypeToZodSchema = ({ ); const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.tuple, }), parameters: [ @@ -619,7 +670,7 @@ const tupleTypeToZodSchema = ({ const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.tuple, }), parameters: [ @@ -631,37 +682,54 @@ const tupleTypeToZodSchema = ({ return expression; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const undefinedTypeToZodSchema = (_props: { +const undefinedTypeToZodSchema = ({ + plugin, +}: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'undefined'>; }) => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.undefined, }), }); return expression; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const unknownTypeToZodSchema = (_props: { +const unknownTypeToZodSchema = ({ + plugin, +}: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'unknown'>; }) => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.unknown, }), }); return expression; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const voidTypeToZodSchema = (_props: { schema: SchemaWithType<'void'> }) => { +const voidTypeToZodSchema = ({ + plugin, +}: { + plugin: ZodPlugin['Instance']; + schema: SchemaWithType<'void'>; +}) => { + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); const expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.void, }), }); @@ -692,12 +760,14 @@ const schemaTypeToZodSchema = ({ case 'boolean': return { expression: booleanTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'boolean'>, }), }; case 'enum': return { expression: enumTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'enum'>, }), }; @@ -705,18 +775,21 @@ const schemaTypeToZodSchema = ({ case 'number': return { expression: numberTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'integer' | 'number'>, }), }; case 'never': return { expression: neverTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'never'>, }), }; case 'null': return { expression: nullTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'null'>, }), }; @@ -744,18 +817,21 @@ const schemaTypeToZodSchema = ({ case 'undefined': return { expression: undefinedTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'undefined'>, }), }; case 'unknown': return { expression: unknownTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'unknown'>, }), }; case 'void': return { expression: voidTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'void'>, }), }; @@ -778,10 +854,14 @@ const schemaToZodSchema = ({ schema: IR.SchemaObject; state: State; }): ZodSchema => { - const file = plugin.context.file({ id: zodId })!; + const f = plugin.gen.ensureFile(plugin.output); let zodSchema: Partial = {}; + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + if (schema.$ref) { const isCircularReference = state.circularReferenceTracker.includes( schema.$ref, @@ -789,25 +869,31 @@ const schemaToZodSchema = ({ state.circularReferenceTracker.push(schema.$ref); state.currentReferenceTracker.push(schema.$ref); - const id = plugin.api.getId({ type: 'ref', value: schema.$ref }); + const selector = plugin.api.getSelector('ref', schema.$ref); + let symbol = plugin.gen.selectSymbolFirst(selector); if (isCircularReference) { - const expression = file.addNodeReference(id, { - factory: (text) => tsc.identifier({ text }), - }); + if (!symbol) { + symbol = f.ensureSymbol({ selector }); + } + zodSchema.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.lazy, }), parameters: [ tsc.arrowFunction({ - statements: [tsc.returnStatement({ expression })], + statements: [ + tsc.returnStatement({ + expression: tsc.identifier({ text: symbol.placeholder }), + }), + ], }), ], }); state.hasCircularReference = true; - } else if (!file.getName(id)) { + } else if (!symbol) { // if $ref hasn't been processed yet, inline it to avoid the // "Block-scoped variable used before its declaration." error // this could be (maybe?) fixed by reshuffling the generation order @@ -821,10 +907,8 @@ const schemaToZodSchema = ({ } if (!isCircularReference) { - const expression = file.addNodeReference(id, { - factory: (text) => tsc.identifier({ text }), - }); - zodSchema.expression = expression; + const symbol = plugin.gen.selectSymbolFirstOrThrow(selector); + zodSchema.expression = tsc.identifier({ text: symbol.placeholder }); } state.circularReferenceTracker.pop(); @@ -867,7 +951,7 @@ const schemaToZodSchema = ({ ) { zodSchema.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.intersection, }), parameters: itemTypes, @@ -887,7 +971,7 @@ const schemaToZodSchema = ({ } else { zodSchema.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.union, }), parameters: [ @@ -980,51 +1064,55 @@ const handleComponent = ({ }; } - const file = plugin.context.file({ id: zodId })!; - const schemaId = plugin.api.getId({ type: 'ref', value: id }); - - if (file.getName(schemaId)) return; + const selector = plugin.api.getSelector('ref', id); + let symbol = plugin.gen.selectSymbolFirst(selector); + if (symbol && !symbol.headless) return; const zodSchema = schemaToZodSchema({ plugin, schema, state }); - const typeInferId = plugin.config.definitions.types.infer.enabled - ? plugin.api.getId({ type: 'type-infer-ref', value: id }) + const f = plugin.gen.ensureFile(plugin.output); + const baseName = refToName(id); + symbol = f.ensureSymbol({ selector }); + symbol = f.patchSymbol(symbol.id, { + name: buildName({ + config: plugin.config.definitions, + name: baseName, + }), + }); + const typeInferSymbol = plugin.config.definitions.types.infer.enabled + ? f.addSymbol({ + name: buildName({ + config: plugin.config.definitions.types.infer, + name: baseName, + }), + selector: plugin.api.getSelector('type-infer-ref', id), + }) : undefined; exportZodSchema({ plugin, schema, - schemaId, - typeInferId, + symbol, + typeInferSymbol, zodSchema, }); - const baseName = refToName(id); - file.updateNodeReferences( - schemaId, - buildName({ - config: plugin.config.definitions, - name: baseName, - }), - ); - if (typeInferId) { - file.updateNodeReferences( - typeInferId, - buildName({ - config: plugin.config.definitions.types.infer, - name: baseName, - }), - ); - } }; export const handlerV3: ZodPlugin['Handler'] = ({ plugin }) => { - const file = plugin.createFile({ - case: plugin.config.case, - id: zodId, - path: plugin.output, + const f = plugin.gen.createFile(plugin.output, { + extension: '.ts', + path: '{{path}}.gen', + renderer: new TypeScriptRenderer(), }); - file.import({ - module: getZodModule({ plugin }), - name: identifiers.z.text, + const zSymbol = f.ensureSymbol({ + name: 'z', + selector: plugin.api.getSelector('import', 'zod'), + }); + f.addImport({ + aliases: { + z: zSymbol.placeholder, + }, + from: getZodModule({ plugin }), + names: ['z'], }); plugin.forEach( @@ -1034,51 +1122,62 @@ export const handlerV3: ZodPlugin['Handler'] = ({ plugin }) => { 'schema', 'webhook', (event) => { - if (event.type === 'operation') { - operationToZodSchema({ - getZodSchema: (schema) => { - const state: State = { - circularReferenceTracker: [], - currentReferenceTracker: [], - hasCircularReference: false, - }; - return schemaToZodSchema({ plugin, schema, state }); - }, - operation: event.operation, - plugin, - }); - } else if (event.type === 'parameter') { - handleComponent({ - id: event.$ref, - plugin, - schema: event.parameter.schema, - }); - } else if (event.type === 'requestBody') { - handleComponent({ - id: event.$ref, - plugin, - schema: event.requestBody.schema, - }); - } else if (event.type === 'schema') { - handleComponent({ - id: event.$ref, - plugin, - schema: event.schema, - }); - } else if (event.type === 'webhook') { - webhookToZodSchema({ - getZodSchema: (schema) => { - const state: State = { - circularReferenceTracker: [], - currentReferenceTracker: [], - hasCircularReference: false, - }; - return schemaToZodSchema({ plugin, schema, state }); - }, - operation: event.operation, - plugin, - }); + switch (event.type) { + case 'operation': + operationToZodSchema({ + getZodSchema: (schema) => { + const state: State = { + circularReferenceTracker: [], + currentReferenceTracker: [], + hasCircularReference: false, + }; + return schemaToZodSchema({ plugin, schema, state }); + }, + operation: event.operation, + plugin, + }); + break; + case 'parameter': + handleComponent({ + id: event.$ref, + plugin, + schema: event.parameter.schema, + }); + break; + case 'requestBody': + handleComponent({ + id: event.$ref, + plugin, + schema: event.requestBody.schema, + }); + break; + case 'schema': + handleComponent({ + id: event.$ref, + plugin, + schema: event.schema, + }); + break; + case 'webhook': + webhookToZodSchema({ + getZodSchema: (schema) => { + const state: State = { + circularReferenceTracker: [], + currentReferenceTracker: [], + hasCircularReference: false, + }; + return schemaToZodSchema({ plugin, schema, state }); + }, + operation: event.operation, + plugin, + }); + break; } }, ); + + if (plugin.config.exportFromIndex && f.hasContent()) { + const index = plugin.gen.ensureFile('index'); + index.addExport({ from: f, namespaceImport: true }); + } }; diff --git a/packages/openapi-ts/src/plugins/zod/v4/plugin.ts b/packages/openapi-ts/src/plugins/zod/v4/plugin.ts index f113ad1af..417058453 100644 --- a/packages/openapi-ts/src/plugins/zod/v4/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/v4/plugin.ts @@ -1,13 +1,13 @@ import ts from 'typescript'; -// import { TypeScriptRenderer } from '../../../generate/renderer'; +import { TypeScriptRenderer } from '../../../generate/renderer'; import { deduplicateSchema } from '../../../ir/schema'; import type { IR } from '../../../ir/types'; import { buildName } from '../../../openApi/shared/utils/name'; import { tsc } from '../../../tsc'; import { refToName } from '../../../utils/ref'; import { numberRegExp } from '../../../utils/regexp'; -import { identifiers, zodId } from '../constants'; +import { identifiers } from '../constants'; import { exportZodSchema } from '../export'; import { getZodModule } from '../shared/module'; import { operationToZodSchema } from '../shared/operation'; @@ -26,8 +26,12 @@ const arrayTypeToZodSchema = ({ }): Omit => { const result: Partial> = {}; + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + const functionName = tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.array, }); @@ -36,6 +40,7 @@ const arrayTypeToZodSchema = ({ functionName, parameters: [ unknownTypeToZodSchema({ + plugin, schema: { type: 'unknown', }, @@ -73,13 +78,13 @@ const arrayTypeToZodSchema = ({ result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.array, }), parameters: [ tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.union, }), parameters: [ @@ -127,16 +132,22 @@ const arrayTypeToZodSchema = ({ }; const booleanTypeToZodSchema = ({ + plugin, schema, }: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'boolean'>; }): Omit => { const result: Partial> = {}; + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + if (typeof schema.const === 'boolean') { result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.literal, }), parameters: [tsc.ots.boolean(schema.const)], @@ -146,7 +157,7 @@ const booleanTypeToZodSchema = ({ result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.boolean, }), }); @@ -154,8 +165,10 @@ const booleanTypeToZodSchema = ({ }; const enumTypeToZodSchema = ({ + plugin, schema, }: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'enum'>; }): Omit => { const result: Partial> = {}; @@ -179,15 +192,20 @@ const enumTypeToZodSchema = ({ if (!enumMembers.length) { return unknownTypeToZodSchema({ + plugin, schema: { type: 'unknown', }, }); } + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.enum, }), parameters: [ @@ -201,7 +219,7 @@ const enumTypeToZodSchema = ({ if (isNullable) { result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.nullable, }), parameters: [result.expression], @@ -211,28 +229,38 @@ const enumTypeToZodSchema = ({ return result as Omit; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const neverTypeToZodSchema = (_props: { +const neverTypeToZodSchema = ({ + plugin, +}: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'never'>; }): Omit => { const result: Partial> = {}; + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.never, }), }); return result as Omit; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const nullTypeToZodSchema = (_props: { +const nullTypeToZodSchema = ({ + plugin, +}: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'null'>; }): Omit => { const result: Partial> = {}; + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.null, }), }); @@ -265,19 +293,25 @@ const numberParameter = ({ }; const numberTypeToZodSchema = ({ + plugin, schema, }: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'integer' | 'number'>; }): Omit => { const result: Partial> = {}; const isBigInt = schema.type === 'integer' && schema.format === 'int64'; + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + if (typeof schema.const === 'number') { // TODO: parser - handle bigint constants result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.literal, }), parameters: [tsc.ots.number(schema.const)], @@ -289,13 +323,13 @@ const numberTypeToZodSchema = ({ functionName: isBigInt ? tsc.propertyAccessExpression({ expression: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.coerce, }), name: identifiers.bigint, }) : tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.number, }), }); @@ -303,7 +337,7 @@ const numberTypeToZodSchema = ({ if (!isBigInt && schema.type === 'integer') { result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.int, }), }); @@ -369,6 +403,10 @@ const objectTypeToZodSchema = ({ const required = schema.required ?? []; + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + for (const name in schema.properties) { const property = schema.properties[name]!; const isRequired = required.includes(name); @@ -410,7 +448,7 @@ const objectTypeToZodSchema = ({ // @ts-expect-error returnType: propertySchema.typeName ? tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: propertySchema.typeName, }) : undefined, @@ -442,13 +480,13 @@ const objectTypeToZodSchema = ({ }); result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.record, }), parameters: [ tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.string, }), parameters: [], @@ -473,7 +511,7 @@ const objectTypeToZodSchema = ({ result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.object, }), parameters: [ts.factory.createObjectLiteralExpression(properties, true)], @@ -499,10 +537,14 @@ const stringTypeToZodSchema = ({ }): Omit => { const result: Partial> = {}; + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + if (typeof schema.const === 'string') { result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.literal, }), parameters: [tsc.ots.string(schema.const)], @@ -512,7 +554,7 @@ const stringTypeToZodSchema = ({ result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.string, }), }); @@ -532,7 +574,7 @@ const stringTypeToZodSchema = ({ result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ expression: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.iso, }), name: identifiers.date, @@ -543,7 +585,7 @@ const stringTypeToZodSchema = ({ result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ expression: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.iso, }), name: identifiers.datetime, @@ -561,7 +603,7 @@ const stringTypeToZodSchema = ({ case 'email': result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.email, }), }); @@ -569,7 +611,7 @@ const stringTypeToZodSchema = ({ case 'ipv4': result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.ipv4, }), }); @@ -577,7 +619,7 @@ const stringTypeToZodSchema = ({ case 'ipv6': result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.ipv6, }), }); @@ -586,7 +628,7 @@ const stringTypeToZodSchema = ({ result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ expression: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.iso, }), name: identifiers.time, @@ -596,7 +638,7 @@ const stringTypeToZodSchema = ({ case 'uri': result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.url, }), }); @@ -604,7 +646,7 @@ const stringTypeToZodSchema = ({ case 'uuid': result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.uuid, }), }); @@ -666,11 +708,15 @@ const tupleTypeToZodSchema = ({ }): Omit => { const result: Partial> = {}; + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + if (schema.const && Array.isArray(schema.const)) { const tupleElements = schema.const.map((value) => tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.literal, }), parameters: [tsc.valueToExpression({ value })], @@ -678,7 +724,7 @@ const tupleTypeToZodSchema = ({ ); result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.tuple, }), parameters: [ @@ -707,7 +753,7 @@ const tupleTypeToZodSchema = ({ result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.tuple, }), parameters: [ @@ -720,42 +766,57 @@ const tupleTypeToZodSchema = ({ return result as Omit; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const undefinedTypeToZodSchema = (_props: { +const undefinedTypeToZodSchema = ({ + plugin, +}: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'undefined'>; }): Omit => { const result: Partial> = {}; + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.undefined, }), }); return result as Omit; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const unknownTypeToZodSchema = (_props: { +const unknownTypeToZodSchema = ({ + plugin, +}: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'unknown'>; }): Omit => { const result: Partial> = {}; + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.unknown, }), }); return result as Omit; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const voidTypeToZodSchema = (_props: { +const voidTypeToZodSchema = ({ + plugin, +}: { + plugin: ZodPlugin['Instance']; schema: SchemaWithType<'void'>; }): Omit => { const result: Partial> = {}; + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); result.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.void, }), }); @@ -780,23 +841,28 @@ const schemaTypeToZodSchema = ({ }); case 'boolean': return booleanTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'boolean'>, }); case 'enum': return enumTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'enum'>, }); case 'integer': case 'number': return numberTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'integer' | 'number'>, }); case 'never': return neverTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'never'>, }); case 'null': return nullTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'null'>, }); case 'object': @@ -818,14 +884,17 @@ const schemaTypeToZodSchema = ({ }); case 'undefined': return undefinedTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'undefined'>, }); case 'unknown': return unknownTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'unknown'>, }); case 'void': return voidTypeToZodSchema({ + plugin, schema: schema as SchemaWithType<'void'>, }); } @@ -847,10 +916,14 @@ const schemaToZodSchema = ({ schema: IR.SchemaObject; state: State; }): ZodSchema => { - const file = plugin.context.file({ id: zodId })!; + const f = plugin.gen.ensureFile(plugin.output); let zodSchema: Partial = {}; + const zSymbol = plugin.gen.selectSymbolFirstOrThrow( + plugin.api.getSelector('import', 'zod'), + ); + if (schema.$ref) { const isCircularReference = state.circularReferenceTracker.includes( schema.$ref, @@ -859,30 +932,36 @@ const schemaToZodSchema = ({ state.circularReferenceTracker.push(schema.$ref); state.currentReferenceTracker.push(schema.$ref); - const id = plugin.api.getId({ type: 'ref', value: schema.$ref }); + const selector = plugin.api.getSelector('ref', schema.$ref); + let symbol = plugin.gen.selectSymbolFirst(selector); if (isCircularReference) { - const expression = file.addNodeReference(id, { - factory: (text) => tsc.identifier({ text }), - }); + if (!symbol) { + symbol = f.ensureSymbol({ selector }); + } + if (isSelfReference) { zodSchema.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.lazy, }), parameters: [ tsc.arrowFunction({ returnType: tsc.keywordTypeNode({ keyword: 'any' }), - statements: [tsc.returnStatement({ expression })], + statements: [ + tsc.returnStatement({ + expression: tsc.identifier({ text: symbol.placeholder }), + }), + ], }), ], }); } else { - zodSchema.expression = expression; + zodSchema.expression = tsc.identifier({ text: symbol.placeholder }); } zodSchema.hasCircularReference = true; - } else if (!file.getName(id)) { + } else if (!symbol) { // if $ref hasn't been processed yet, inline it to avoid the // "Block-scoped variable used before its declaration." error // this could be (maybe?) fixed by reshuffling the generation order @@ -896,10 +975,8 @@ const schemaToZodSchema = ({ } if (!isCircularReference) { - const expression = file.addNodeReference(id, { - factory: (text) => tsc.identifier({ text }), - }); - zodSchema.expression = expression; + const symbol = plugin.gen.selectSymbolFirstOrThrow(selector); + zodSchema.expression = tsc.identifier({ text: symbol.placeholder }); } state.circularReferenceTracker.pop(); @@ -917,7 +994,7 @@ const schemaToZodSchema = ({ }), parameters: [ tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.globalRegistry, }), tsc.objectExpression({ @@ -955,7 +1032,7 @@ const schemaToZodSchema = ({ ) { zodSchema.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.intersection, }), parameters: itemTypes, @@ -975,7 +1052,7 @@ const schemaToZodSchema = ({ } else { zodSchema.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.union, }), parameters: [ @@ -1013,7 +1090,7 @@ const schemaToZodSchema = ({ if (optional) { zodSchema.expression = tsc.callExpression({ functionName: tsc.propertyAccessExpression({ - expression: identifiers.z, + expression: zSymbol.placeholder, name: identifiers.optional, }), parameters: [zodSchema.expression], @@ -1060,58 +1137,56 @@ const handleComponent = ({ currentReferenceTracker: [id], }; - const file = plugin.context.file({ id: zodId })!; - const schemaId = plugin.api.getId({ type: 'ref', value: id }); - - if (file.getName(schemaId)) return; + const selector = plugin.api.getSelector('ref', id); + let symbol = plugin.gen.selectSymbolFirst(selector); + if (symbol && !symbol.headless) return; const zodSchema = schemaToZodSchema({ plugin, schema, state }); - const typeInferId = plugin.config.definitions.types.infer.enabled - ? plugin.api.getId({ type: 'type-infer-ref', value: id }) + const f = plugin.gen.ensureFile(plugin.output); + const baseName = refToName(id); + symbol = f.ensureSymbol({ selector }); + symbol = f.patchSymbol(symbol.id, { + name: buildName({ + config: plugin.config.definitions, + name: baseName, + }), + }); + const typeInferSymbol = plugin.config.definitions.types.infer.enabled + ? f.addSymbol({ + name: buildName({ + config: plugin.config.definitions.types.infer, + name: baseName, + }), + selector: plugin.api.getSelector('type-infer-ref', id), + }) : undefined; exportZodSchema({ plugin, schema, - schemaId, - typeInferId, + symbol, + typeInferSymbol, zodSchema, }); - const baseName = refToName(id); - file.updateNodeReferences( - schemaId, - buildName({ - config: plugin.config.definitions, - name: baseName, - }), - ); - if (typeInferId) { - file.updateNodeReferences( - typeInferId, - buildName({ - config: plugin.config.definitions.types.infer, - name: baseName, - }), - ); - } }; export const handlerV4: ZodPlugin['Handler'] = ({ plugin }) => { - const file = plugin.createFile({ - case: plugin.config.case, - id: zodId, - path: plugin.output, + const f = plugin.gen.createFile(plugin.output, { + extension: '.ts', + path: '{{path}}.gen', + renderer: new TypeScriptRenderer(), }); - // const f = plugin.gen.createFile(plugin.output, { - // extension: '.ts', - // path: '{{path}}.gen', - // renderer: new TypeScriptRenderer(), - // }); - - file.import({ - module: getZodModule({ plugin }), - name: identifiers.z.text, + + const zSymbol = f.ensureSymbol({ + name: 'z', + selector: plugin.api.getSelector('import', 'zod'), + }); + f.addImport({ + aliases: { + z: zSymbol.placeholder, + }, + from: getZodModule({ plugin }), + names: ['z'], }); - // f.addImport({ from: getZodModule({ plugin }), names: [identifiers.z.text] }); plugin.forEach( 'operation', @@ -1174,8 +1249,8 @@ export const handlerV4: ZodPlugin['Handler'] = ({ plugin }) => { }, ); - // if (plugin.config.exportFromIndex && f.hasContent()) { - // const index = plugin.gen.ensureFile('index'); - // index.addExport({ from: f, namespaceImport: true }); - // } + if (plugin.config.exportFromIndex && f.hasContent()) { + const index = plugin.gen.ensureFile('index'); + index.addExport({ from: f, namespaceImport: true }); + } };