From 18f1ae188a2871140dd332723d4ec1ac62f08a95 Mon Sep 17 00:00:00 2001 From: Wongkraiwich Chuenchomphu Date: Fri, 21 Aug 2026 00:50:55 +0700 Subject: [PATCH 1/6] feat(config): support split output directories --- README.md | 11 ++- src/config/__tests__/load.test.ts | 4 + src/config/load.ts | 10 +++ src/config/types.ts | 10 +++ src/emitters/functions/__tests__/emit.test.ts | 1 + src/emitters/functions/index.ts | 86 ++++++++----------- src/generate/index.ts | 20 ++++- src/utils/__tests__/tsconfig-paths.test.ts | 39 +++++++++ src/utils/tsconfig-paths.ts | 29 +++++-- test/integration/generate.test.ts | 49 +++++++++++ 10 files changed, 196 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index d2ac696..7bc376e 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,8 @@ import { defineSourceConfig } from "@openmirai/openapi-codegen"; export default defineSourceConfig({ spec: "./specs/acme.json", + functionsDir: "packages/utils/src/api/routes/atlas", + typesDir: "packages/types/src/api/atlas", pathPrefix: "/api/acme/v3", stripApiPrefix: true, routeEnumName: "RouteTargets", @@ -135,6 +137,8 @@ Re-exported types from the package root: | Field | Meaning | | --- | --- | | `spec` | Project-relative spec path (used when no `--spec` / env override) | +| `functionsDir` | Project-relative function output directory (defaults to the source's `generated/functions`) | +| `typesDir` | Project-relative type output directory (defaults to the source's `generated/types`; a generated `base.ts` is colocated here when customized) | | `pathPrefix` | Only generate operations under this prefix (e.g. `/api/acme/v3`) | | `ignorePaths` | Extra paths to skip | | `stripApiPrefix` | Strip a leading `/api` segment from route enum member names | @@ -176,7 +180,7 @@ Adapters implement `HTTPFetch` from `@openmirai/openapi-codegen/http` (or the ax Function files import types and `runtime` using: 1. `importBase` in `source.ts`, if set -2. Else `compilerOptions.paths` from the nearest `tsconfig.json` +2. Else `compilerOptions.paths` from the nearest ancestor `tsconfig.json` with path aliases, starting at `functionsDir` 3. Else relative paths (`../../runtime`) ## Where files go @@ -203,6 +207,11 @@ src/api/atlas/generated/… **Packages layout** (`--layout packages`): typical placement is `packages/utils/src/api//`. +Set `functionsDir` and `typesDir` when callers and declarations belong in +different packages. Relative imports continue to work without aliases; when a +nearby `tsconfig.json` maps both output roots, deep generated imports use those +aliases automatically. + ## Zod (optional) ```ts diff --git a/src/config/__tests__/load.test.ts b/src/config/__tests__/load.test.ts index 5a131cc..d0d093f 100644 --- a/src/config/__tests__/load.test.ts +++ b/src/config/__tests__/load.test.ts @@ -105,6 +105,8 @@ describe("config/load", () => { export default defineSourceConfig({ spec: "./specs/acme.json", + functionsDir: "packages/utils/src/api/routes/atlas", + typesDir: "packages/types/src/api/atlas", pathPrefix: "/api/acme/v3", stripApiPrefix: true, generationMode: "merge", @@ -124,6 +126,8 @@ export default defineSourceConfig({ const config = loadSourceConfig(cwd, "src/api", "atlas"); expect(config.spec).toBe("./specs/acme.json"); + expect(config.functionsDir).toBe("packages/utils/src/api/routes/atlas"); + expect(config.typesDir).toBe("packages/types/src/api/atlas"); expect(config.pathPrefix).toBe("/api/acme/v3"); expect(config.stripApiPrefix).toBe(true); expect(config.generationMode).toBe("merge"); diff --git a/src/config/load.ts b/src/config/load.ts index bd5ae8e..93b3231 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -71,6 +71,16 @@ function parseSourceConfigContent(content: string): SourceConfig { config.pathPrefix = pathPrefix[1]; } + const functionsDir = normalized.match(/functionsDir:\s*["'`]([^"'`]+)["'`]/); + if (functionsDir?.[1] !== undefined) { + config.functionsDir = functionsDir[1]; + } + + const typesDir = normalized.match(/typesDir:\s*["'`]([^"'`]+)["'`]/); + if (typesDir?.[1] !== undefined) { + config.typesDir = typesDir[1]; + } + const ignoreMatch = normalized.match(/ignorePaths:\s*\[([\s\S]*?)\]/); if (ignoreMatch?.[1] !== undefined) { const paths = [...ignoreMatch[1].matchAll(/["'`]([^"'`]+)["'`]/g)] diff --git a/src/config/types.ts b/src/config/types.ts index eb3d72c..1507378 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -18,6 +18,16 @@ export interface QueryExtendsConfig { } export interface SourceConfig { + /** + * Project-relative directory for generated API function files. + * Defaults to `//generated/functions`. + */ + functionsDir?: string; + /** + * Project-relative directory for generated API type files. + * Defaults to `//generated/types`. + */ + typesDir?: string; pathPrefix?: string; ignorePaths?: Array; stripApiPrefix?: boolean; diff --git a/src/emitters/functions/__tests__/emit.test.ts b/src/emitters/functions/__tests__/emit.test.ts index 5dea2c9..9f292cb 100644 --- a/src/emitters/functions/__tests__/emit.test.ts +++ b/src/emitters/functions/__tests__/emit.test.ts @@ -14,6 +14,7 @@ function baseOptions(paths: ReturnType["paths"]) { const root = "/project/src/api/atlas/generated"; return { functionsDir: `${root}/functions`, + generatedDir: root, hasQueryScope: false, httpMode: "singleton" as const, paths, diff --git a/src/emitters/functions/index.ts b/src/emitters/functions/index.ts index 928f7cf..06a7e89 100644 --- a/src/emitters/functions/index.ts +++ b/src/emitters/functions/index.ts @@ -1,4 +1,4 @@ -import { dirname, join } from "node:path"; +import { join } from "node:path"; import type { HttpMethod, @@ -9,7 +9,6 @@ import type { import type { TsconfigPathsConfig } from "../../utils/tsconfig-paths"; import { functionFileAbsPath, - relativeImportFromFunctionFile, resolveAliasAwareImport, } from "../../utils/imports"; import { pathToEnumName, pathToFunctionName } from "../../utils/naming"; @@ -28,6 +27,7 @@ export interface FunctionsEmitterOptions { hasQueryScope: boolean; routeEnumName: string; functionsDir: string; + generatedDir: string; typesDir: string; /** * Explicit import base for function→generated imports (overrides auto-resolution). @@ -54,36 +54,23 @@ function getTypeImportPath( .filter(Boolean) .join("/"); - if (options.importBase !== undefined || options.tsconfigPaths !== undefined) { - const generatedDir = dirname(options.functionsDir); - const fromAbs = functionFileAbsPath( - options.functionsDir, - cleanPath, - method.toUpperCase() - ); - const toAbs = join( - generatedDir, - "types", - normalizedPath, - method.toUpperCase() - ); - return resolveAliasAwareImport({ - fromAbsolutePath: fromAbs, - generatedDir, - toAbsolutePath: toAbs, - ...(options.importBase !== undefined - ? { importBase: options.importBase } - : {}), - ...(options.tsconfigPaths !== undefined - ? { tsconfigPaths: options.tsconfigPaths } - : {}), - }); - } - - return relativeImportFromFunctionFile( + const fromAbs = functionFileAbsPath( + options.functionsDir, cleanPath, - `types/${normalizedPath}/${method.toUpperCase()}` + method.toUpperCase() ); + const toAbs = join(options.typesDir, normalizedPath, method.toUpperCase()); + return resolveAliasAwareImport({ + fromAbsolutePath: fromAbs, + generatedDir: options.generatedDir, + toAbsolutePath: toAbs, + ...(options.importBase !== undefined + ? { importBase: options.importBase } + : {}), + ...(options.tsconfigPaths !== undefined + ? { tsconfigPaths: options.tsconfigPaths } + : {}), + }); } function getRuntimeImportPath( @@ -91,28 +78,23 @@ function getRuntimeImportPath( options: FunctionsEmitterOptions, method: HttpMethod ): string { - if (options.importBase !== undefined || options.tsconfigPaths !== undefined) { - const generatedDir = dirname(options.functionsDir); - const fromAbs = functionFileAbsPath( - options.functionsDir, - cleanPath, - method.toUpperCase() - ); - const toAbs = join(generatedDir, "runtime"); - return resolveAliasAwareImport({ - fromAbsolutePath: fromAbs, - generatedDir, - toAbsolutePath: toAbs, - ...(options.importBase !== undefined - ? { importBase: options.importBase } - : {}), - ...(options.tsconfigPaths !== undefined - ? { tsconfigPaths: options.tsconfigPaths } - : {}), - }); - } - - return relativeImportFromFunctionFile(cleanPath, "runtime"); + const fromAbs = functionFileAbsPath( + options.functionsDir, + cleanPath, + method.toUpperCase() + ); + const toAbs = join(options.generatedDir, "runtime"); + return resolveAliasAwareImport({ + fromAbsolutePath: fromAbs, + generatedDir: options.generatedDir, + toAbsolutePath: toAbs, + ...(options.importBase !== undefined + ? { importBase: options.importBase } + : {}), + ...(options.tsconfigPaths !== undefined + ? { tsconfigPaths: options.tsconfigPaths } + : {}), + }); } function renderOperationPathParamType( diff --git a/src/generate/index.ts b/src/generate/index.ts index 5ed5e4f..48303b2 100644 --- a/src/generate/index.ts +++ b/src/generate/index.ts @@ -72,12 +72,23 @@ export function buildGenerateContext( const sourceConfig = loadSourceConfig(cwd, apiRoot, sourceKey); const sourceDir = resolve(cwd, apiRoot, sourceKey); const generatedDir = join(sourceDir, "generated"); + const functionsDir = + sourceConfig.functionsDir === undefined + ? join(generatedDir, "functions") + : resolve(cwd, sourceConfig.functionsDir); + const typesDir = + sourceConfig.typesDir === undefined + ? join(generatedDir, "types") + : resolve(cwd, sourceConfig.typesDir); return { apiRoot, - baseFile: join(generatedDir, "base.ts"), + baseFile: + sourceConfig.typesDir === undefined + ? join(generatedDir, "base.ts") + : join(typesDir, "base.ts"), cwd, - functionsDir: join(generatedDir, "functions"), + functionsDir, generatedDir, hasQueryScope: sourceConfig.tanstackQuery === true && hasQueryScopeFile(cwd, apiRoot), @@ -87,7 +98,7 @@ export function buildGenerateContext( sourceConfig, sourceDir, sourceKey, - typesDir: join(generatedDir, "types"), + typesDir, }; } @@ -293,9 +304,10 @@ export async function generateForSource( path: join(context.generatedDir, "runtime.ts"), }); - const tsconfigPaths = loadTsconfigPaths(cwd); + const tsconfigPaths = loadTsconfigPaths(context.functionsDir); const functionEmitterOptions: Parameters[0] = { functionsDir: context.functionsDir, + generatedDir: context.generatedDir, hasQueryScope: context.hasQueryScope, httpMode: context.httpMode, paths: source.paths, diff --git a/src/utils/__tests__/tsconfig-paths.test.ts b/src/utils/__tests__/tsconfig-paths.test.ts index a934fe2..756dc12 100644 --- a/src/utils/__tests__/tsconfig-paths.test.ts +++ b/src/utils/__tests__/tsconfig-paths.test.ts @@ -109,6 +109,45 @@ describe("loadTsconfigPaths", () => { expect(result?.resolvedBaseUrl).toBe(resolve(tmpDir, ".")); }); + it("finds the nearest tsconfig from a nested output directory", () => { + const packageDir = join(tmpDir, "packages/utils"); + const outputDir = join(packageDir, "src/api/routes/core/v2"); + mkdirSync(outputDir, { recursive: true }); + writeFileSync( + join(packageDir, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { paths: { "@mirai/utils/*": ["src/*"] } }, + }), + "utf8" + ); + + const result = loadTsconfigPaths(outputDir); + expect(result?.baseDir).toBe(packageDir); + expect(result?.resolvedBaseUrl).toBe(packageDir); + }); + + it("continues upward when a nearer tsconfig has no path aliases", () => { + const packageDir = join(tmpDir, "packages/utils"); + const outputDir = join(packageDir, "src/api/routes"); + mkdirSync(outputDir, { recursive: true }); + writeFileSync( + join(tmpDir, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { paths: { "@workspace/*": ["packages/*"] } }, + }), + "utf8" + ); + writeFileSync( + join(packageDir, "tsconfig.json"), + JSON.stringify({ compilerOptions: { strict: true } }), + "utf8" + ); + + const result = loadTsconfigPaths(outputDir); + expect(result?.baseDir).toBe(tmpDir); + expect(result?.paths).toEqual({ "@workspace/*": ["packages/*"] }); + }); + it("returns undefined for malformed JSON", () => { writeFileSync(join(tmpDir, "tsconfig.json"), "{ not valid json }", "utf8"); expect(loadTsconfigPaths(tmpDir)).toBeUndefined(); diff --git a/src/utils/tsconfig-paths.ts b/src/utils/tsconfig-paths.ts index 7373574..6b1f134 100644 --- a/src/utils/tsconfig-paths.ts +++ b/src/utils/tsconfig-paths.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { dirname, parse, resolve } from "node:path"; export interface TsconfigPathsConfig { /** Absolute directory containing tsconfig.json. */ @@ -73,12 +73,29 @@ function stripJsonComments(text: string): string { /** * Load `compilerOptions.paths` and `baseUrl` from the nearest `tsconfig.json` - * found in `cwd`. Returns `undefined` when no tsconfig exists or has no paths. + * found at or above `startDir`. Returns `undefined` when no tsconfig has paths. */ export function loadTsconfigPaths( - cwd: string + startDir: string ): TsconfigPathsConfig | undefined { - const tsconfigPath = resolve(cwd, "tsconfig.json"); + let currentDir = resolve(startDir); + const rootDir = parse(currentDir).root; + + while (true) { + const config = readTsconfigPaths(currentDir); + if (config !== undefined) { + return config; + } + + if (currentDir === rootDir) { + return undefined; + } + currentDir = dirname(currentDir); + } +} + +function readTsconfigPaths(configDir: string): TsconfigPathsConfig | undefined { + const tsconfigPath = resolve(configDir, "tsconfig.json"); if (!existsSync(tsconfigPath)) { return undefined; } @@ -125,9 +142,9 @@ export function loadTsconfigPaths( } return { - baseDir: resolve(cwd), + baseDir: configDir, paths: normalizedPaths, - resolvedBaseUrl: resolve(cwd, baseUrl), + resolvedBaseUrl: resolve(configDir, baseUrl), }; } catch { return undefined; diff --git a/test/integration/generate.test.ts b/test/integration/generate.test.ts index 3a1af28..9e464f3 100644 --- a/test/integration/generate.test.ts +++ b/test/integration/generate.test.ts @@ -72,6 +72,55 @@ describe("integration: monolith generate", () => { expect(existsSync(join(generatedDir, "base.ts"))).toBe(false); }); + it("supports split function and type output directories with tsconfig aliases", async () => { + const root = join(fixtureRoot, "layouts", `split-${Date.now()}`); + tempRoots.push(root); + const functionsDir = join(root, "packages/utils/src/api/routes/core/v2"); + const typesDir = join(root, "packages/types/src/api/core/v2"); + createMonolithProject({ + apiRoot: "packages/utils/src/api", + root, + sourceConfig: `export default { + functionsDir: "packages/utils/src/api/routes/core/v2", + typesDir: "packages/types/src/api/core/v2", + pathPrefix: "/api/acme/v3", + stripApiPrefix: true, + generationMode: "authoritative" as const, +};`, + sourceKey: "core", + specContent: singleEndpoint, + }); + mkdirSync(join(root, "packages/utils"), { recursive: true }); + writeFileSync( + join(root, "packages/utils/tsconfig.json"), + JSON.stringify({ + compilerOptions: { + paths: { + "@mirai/utils/src/*": ["./src/*"], + "@mirai/*": ["../*"], + }, + }, + }), + "utf8" + ); + + await generateForSource({ cwd: root, sourceKey: "core" }); + + expect(existsSync(join(typesDir, "base.ts"))).toBe(true); + expect(existsSync(join(typesDir, "api/acme/v3/widgets/GET.d.ts"))).toBe( + true + ); + const fn = readFileSync( + join(functionsDir, "api/acme/v3/widgets/GET.ts"), + "utf8" + ); + expect(fn).toContain( + 'from "@mirai/types/src/api/core/v2/api/acme/v3/widgets/GET"' + ); + expect(fn).toContain('from "@mirai/utils/src/api/core/generated/runtime"'); + expect(fn).not.toContain("../../../../"); + }); + it("generates mixed envelope specs with per-operation wrapping", async () => { const root = join(fixtureRoot, "layouts", `mixed-${Date.now()}`); tempRoots.push(root); From f6bb09f8e5b1930bffbdd61cf5c504311dd6998f Mon Sep 17 00:00:00 2001 From: Wongkraiwich Chuenchomphu Date: Fri, 21 Aug 2026 00:57:15 +0700 Subject: [PATCH 2/6] fix(generate): preserve native request contracts --- src/adapters/axios/index.ts | 28 +++------- src/adapters/fetch/index.ts | 39 +++++-------- .../__tests__/schema-renderer.test.ts | 55 ++++++++++++++++++ src/emitters/functions/__tests__/emit.test.ts | 56 ++++++++++++++++++- src/emitters/functions/index.ts | 43 +++++++++----- src/emitters/schema-renderer.ts | 21 +++++-- src/emitters/types/__tests__/emit.test.ts | 1 + src/emitters/types/index.ts | 8 +-- src/http/types.ts | 12 ++-- test/fixtures/templates/typed-http.ts | 12 ++-- 10 files changed, 193 insertions(+), 82 deletions(-) diff --git a/src/adapters/axios/index.ts b/src/adapters/axios/index.ts index 2ff453d..23a6bd3 100644 --- a/src/adapters/axios/index.ts +++ b/src/adapters/axios/index.ts @@ -4,7 +4,7 @@ import { coerceResponseData } from "../../http/validate"; import type { HTTPFetch, HTTPFetchConfig } from "../../http/types"; import type { QueryParams } from "../../json/types"; -function toAxiosConfig( +function toAxiosConfig( config?: HTTPFetchConfig ): AxiosRequestConfig | undefined { if (config === undefined) { @@ -24,9 +24,9 @@ function toAxiosConfig( return axiosConfig; } -function mapResponse( +function mapResponse( data: unknown, - config?: HTTPFetchConfig + config?: HTTPFetchConfig ): { data: TResponse } { return { data: coerceResponseData(data, config?.validateResponse), @@ -35,25 +35,21 @@ function mapResponse( export function createAxiosAdapter(instance: AxiosInstance): HTTPFetch { return { - delete: ( + delete: ( route: string, config?: HTTPFetchConfig ) => instance .delete(route, toAxiosConfig(config)) .then((response) => mapResponse(response.data, config)), - get: ( + get: ( route: string, config?: HTTPFetchConfig ) => instance .get(route, toAxiosConfig(config)) .then((response) => mapResponse(response.data, config)), - patch: < - TResponse, - TBody = unknown, - TParams extends QueryParams = QueryParams, - >( + patch: ( route: string, body: TBody, config?: HTTPFetchConfig @@ -61,11 +57,7 @@ export function createAxiosAdapter(instance: AxiosInstance): HTTPFetch { instance .patch(route, body, toAxiosConfig(config)) .then((response) => mapResponse(response.data, config)), - post: < - TResponse, - TBody = unknown, - TParams extends QueryParams = QueryParams, - >( + post: ( route: string, body: TBody, config?: HTTPFetchConfig @@ -73,11 +65,7 @@ export function createAxiosAdapter(instance: AxiosInstance): HTTPFetch { instance .post(route, body, toAxiosConfig(config)) .then((response) => mapResponse(response.data, config)), - put: < - TResponse, - TBody = unknown, - TParams extends QueryParams = QueryParams, - >( + put: ( route: string, body: TBody, config?: HTTPFetchConfig diff --git a/src/adapters/fetch/index.ts b/src/adapters/fetch/index.ts index 03fc5ec..959d7f7 100644 --- a/src/adapters/fetch/index.ts +++ b/src/adapters/fetch/index.ts @@ -9,7 +9,7 @@ export interface FetchAdapterOptions { fetch?: typeof fetch; } -function appendQuery(url: string, params?: QueryParams): string { +function appendQuery(url: string, params?: object): string { if (params === undefined || Object.keys(params).length === 0) { return url; } @@ -34,12 +34,12 @@ function readResponseBody(text: string): JsonValue | undefined { return parseJson(text); } -async function request( +async function request( method: string, route: string, options: FetchAdapterOptions, body?: unknown, - config?: HTTPFetchConfig + config?: HTTPFetchConfig ): Promise<{ data: TResponse }> { const fetchImpl = options.fetch ?? globalThis.fetch; const baseURL = options.baseURL ?? ""; @@ -79,41 +79,30 @@ export function createFetchAdapter( options: FetchAdapterOptions = {} ): HTTPFetch { return { - delete: ( + delete: ( route: string, config?: HTTPFetchConfig - ) => request("DELETE", route, options, undefined, config), - get: ( + ) => + request("DELETE", route, options, undefined, config), + get: ( route: string, config?: HTTPFetchConfig - ) => request("GET", route, options, undefined, config), - patch: < - TResponse, - TBody = unknown, - TParams extends QueryParams = QueryParams, - >( + ) => request("GET", route, options, undefined, config), + patch: ( route: string, body: TBody, config?: HTTPFetchConfig - ) => request("PATCH", route, options, body, config), - post: < - TResponse, - TBody = unknown, - TParams extends QueryParams = QueryParams, - >( + ) => request("PATCH", route, options, body, config), + post: ( route: string, body: TBody, config?: HTTPFetchConfig - ) => request("POST", route, options, body, config), - put: < - TResponse, - TBody = unknown, - TParams extends QueryParams = QueryParams, - >( + ) => request("POST", route, options, body, config), + put: ( route: string, body: TBody, config?: HTTPFetchConfig - ) => request("PUT", route, options, body, config), + ) => request("PUT", route, options, body, config), }; } diff --git a/src/emitters/__tests__/schema-renderer.test.ts b/src/emitters/__tests__/schema-renderer.test.ts index 59e829e..12cb116 100644 --- a/src/emitters/__tests__/schema-renderer.test.ts +++ b/src/emitters/__tests__/schema-renderer.test.ts @@ -25,6 +25,61 @@ const blobOverrideRule: KnownTypeRule = { }; describe("schema-renderer", () => { + const baseContext = { + components: {}, + schemaPath: "test", + sourceKey: "test", + }; + + it("parenthesizes union array items", () => { + expect( + renderSchemaType( + { + items: { + anyOf: [{ kind: "string" }, { kind: "number" }], + kind: "anyOf", + }, + kind: "array", + }, + baseContext + ) + ).toBe("(string | number)[]"); + }); + + it("does not wrap known Tiptap documents in another array", () => { + expect( + renderSchemaType( + { items: { kind: "string" }, kind: "array" }, + { + ...baseContext, + knownTypes: [ + { + importPath: null, + matcher: (schema) => schema.kind === "string", + name: "Tiptap", + typeName: "TiptapDocument", + }, + ], + } + ) + ).toBe("TiptapDocument"); + }); + + it("drops a generic record when anyOf has a specific variant", () => { + expect( + renderSchemaType( + { + anyOf: [ + { additionalProperties: true, kind: "object" }, + { kind: "string" }, + ], + kind: "anyOf", + }, + baseContext + ) + ).toBe("string"); + }); + it("errors on recursive schema references", () => { const raw = JSON.parse( readFileSync(join(fixtureRoot, "specs/recursive-node.json"), "utf8") diff --git a/src/emitters/functions/__tests__/emit.test.ts b/src/emitters/functions/__tests__/emit.test.ts index 9f292cb..51f085b 100644 --- a/src/emitters/functions/__tests__/emit.test.ts +++ b/src/emitters/functions/__tests__/emit.test.ts @@ -35,9 +35,10 @@ describe("emitFunctionFiles", () => { expect(file?.content).toContain("import { httpFetch, Routes }"); expect(file?.content).toContain("params?: GETApiAcmeV3WidgetsParams"); expect(file?.content).toContain( - "httpFetch.get(Routes.API_ACME_V3_WIDGETS, { ...config, params, signal });" + "httpFetch.get(Routes.API_ACME_V3_WIDGETS, { ...config, params, signal });" ); expect(file?.content).toContain("return data;"); + expect(file?.content).not.toContain("Response | undefined"); expect(file?.content).not.toMatch(/\bas\s+/); }); @@ -60,6 +61,50 @@ describe("emitFunctionFiles", () => { expect(del?.content).toContain("params?: DELETEApiAcmeV3WidgetsSlugParams"); }); + it("requires params when the operation has a required query parameter", () => { + const source = parseSpec( + JSON.parse( + readFileSync(join(fixtureRoot, "specs/envelope-list.json"), "utf8") + ), + { pathPrefix: "/api/acme/v3" } + ); + const operation = source.paths[0]?.operations[0]; + if (operation?.queryParams[0] !== undefined) { + operation.queryParams[0].required = true; + } + + const [file] = emitFunctionFiles(baseOptions(source.paths)); + expect(file?.content).toContain("params: GETApiAcmeV3WidgetsParams"); + expect(file?.content).not.toContain("params?: GETApiAcmeV3WidgetsParams"); + }); + + it("passes request bodies through DELETE config", () => { + const source = parseSpec( + JSON.parse( + readFileSync(join(fixtureRoot, "specs/post-body.json"), "utf8") + ), + { pathPrefix: "/api/acme/v3" } + ); + const operation = source.paths[1]?.operations[0]; + if (operation !== undefined) { + operation.requestBody = { + required: true, + schema: { + kind: "object", + properties: { + force: { required: true, schema: { kind: "boolean" } }, + }, + }, + }; + } + + const file = emitFunctionFiles(baseOptions(source.paths)).find((entry) => + entry.relativePath.endsWith("DELETE.ts") + ); + expect(file?.content).toContain("body: DELETEApiAcmeV3WidgetsSlugBody"); + expect(file?.content).toContain("data: body, params, signal"); + }); + it("uses injected http prop when httpMode is injected", () => { const source = parseSpec( JSON.parse( @@ -73,7 +118,7 @@ describe("emitFunctionFiles", () => { }); expect(file?.content).toContain("http: HTTPFetch"); expect(file?.content).toContain( - "props.http.get(Routes.API_ACME_V3_WIDGETS" + "props.http.get(Routes.API_ACME_V3_WIDGETS" ); expect(file?.content).not.toContain("import { httpFetch }"); }); @@ -127,6 +172,13 @@ describe("emitFunctionFiles", () => { expect(file?.content).toContain("QueryOptions"); expect(file?.content).not.toContain("@tanstack/react-query"); expect(file?.content).toContain('from "../../../../../runtime"'); + + const itemFile = emitFunctionFiles({ + ...baseOptions(source.paths), + hasQueryScope: true, + }).find((entry) => entry.relativePath.includes("[slug]")); + expect(itemFile?.content).toContain("const { queryScope } = props;"); + expect(itemFile?.content).not.toContain(", params]"); }); it("defaults missing path params to string", () => { diff --git a/src/emitters/functions/index.ts b/src/emitters/functions/index.ts index 06a7e89..4d73379 100644 --- a/src/emitters/functions/index.ts +++ b/src/emitters/functions/index.ts @@ -202,7 +202,10 @@ function renderFunctionFile( ); } if (queryParamsPresent) { - lines.push(` params?: ${typeName}Params;`); + const optional = operation.queryParams.some((param) => param.required) + ? "" + : "?"; + lines.push(` params${optional}: ${typeName}Params;`); } if (hasRequestBody) { lines.push(` body: ${typeName}Body;`); @@ -230,13 +233,13 @@ function renderFunctionFile( ...(options.httpMode === "injected" ? ["http"] : []), ...pathParams.map((param) => param), ...(queryParamsPresent ? ["params"] : []), - ...(hasRequestBody && method !== "delete" ? ["body"] : []), + ...(hasRequestBody ? ["body"] : []), "config", "signal", ]; lines.push( - `export async function ${functionName}(props: ${propsTypeName}): Promise<${typeName}Response | undefined> {` + `export async function ${functionName}(props: ${propsTypeName}): Promise<${typeName}Response> {` ); if (destructuredProps.length > 0) { lines.push(` const { ${destructuredProps.join(", ")} } = props;`); @@ -251,11 +254,14 @@ function renderFunctionFile( const fetchConfig = queryParamsPresent ? `{ ...config, params, signal }` : "{ ...config, signal }"; - const getGeneric = `<${typeName}Response>`; - const mutationGeneric = hasRequestBody - ? `<${typeName}Response, ${typeName}Body>` - : `<${typeName}Response, undefined>`; + const getGeneric = queryParamsPresent + ? `<${typeName}Response, ${typeName}Params>` + : `<${typeName}Response>`; + const mutationGeneric = `<${typeName}Response, ${hasRequestBody ? `${typeName}Body` : "undefined"}${queryParamsPresent ? `, ${typeName}Params` : ""}>`; const mutationBody = hasRequestBody ? "body" : "undefined"; + const mutationConfig = queryParamsPresent + ? "{ ...config, params, signal }" + : "{ ...config, signal }"; switch (method) { case "get": @@ -265,24 +271,31 @@ function renderFunctionFile( break; case "post": lines.push( - ` const { data } = await ${httpClient}.post${mutationGeneric}(${routeCall}, ${mutationBody}, { ...config, signal });` + ` const { data } = await ${httpClient}.post${mutationGeneric}(${routeCall}, ${mutationBody}, ${mutationConfig});` ); break; case "put": lines.push( - ` const { data } = await ${httpClient}.put${mutationGeneric}(${routeCall}, ${mutationBody}, { ...config, signal });` + ` const { data } = await ${httpClient}.put${mutationGeneric}(${routeCall}, ${mutationBody}, ${mutationConfig});` ); break; case "patch": lines.push( - ` const { data } = await ${httpClient}.patch${mutationGeneric}(${routeCall}, ${mutationBody}, { ...config, signal });` + ` const { data } = await ${httpClient}.patch${mutationGeneric}(${routeCall}, ${mutationBody}, ${mutationConfig});` ); break; - case "delete": + case "delete": { + let deleteConfig = fetchConfig; + if (hasRequestBody) { + deleteConfig = queryParamsPresent + ? "{ ...config, data: body, params, signal }" + : "{ ...config, data: body, signal }"; + } lines.push( - ` const { data } = await ${httpClient}.delete${getGeneric}(${routeCall}, ${fetchConfig});` + ` const { data } = await ${httpClient}.delete${getGeneric}(${routeCall}, ${deleteConfig});` ); break; + } } lines.push(" return data;"); @@ -294,10 +307,12 @@ function renderFunctionFile( lines.push( `export function ${queryOptionsName}(props: ${queryOptionsPropsTypeName}) {` ); - lines.push(" const { params, queryScope } = props;"); + lines.push( + ` const { ${queryParamsPresent ? "params, " : ""}queryScope } = props;` + ); lines.push(" return queryOptions({"); lines.push( - ` queryKey: [RouteTargets.${enumName}, ...getQueryScopeKey(queryScope)${pathParams.length > 0 ? `, ${pathParams.map((param) => `props.${param}`).join(", ")}` : ""}, params],` + ` queryKey: [RouteTargets.${enumName}, ...getQueryScopeKey(queryScope)${pathParams.length > 0 ? `, ${pathParams.map((param) => `props.${param}`).join(", ")}` : ""}${queryParamsPresent ? ", params" : ""}],` ); lines.push( ` queryFn: ({ signal }) => ${functionName}({ ...props, signal }).then((data) => data),` diff --git a/src/emitters/schema-renderer.ts b/src/emitters/schema-renderer.ts index ba3a647..68508a5 100644 --- a/src/emitters/schema-renderer.ts +++ b/src/emitters/schema-renderer.ts @@ -219,7 +219,13 @@ export function renderSchemaType( schema.items === undefined ? "unknown" : renderSchemaType(schema.items, childContext(nextCtx, "[]")); - return withNullable(`${itemType}[]`, schema); + if (itemType === "TiptapDocument") { + return withNullable("TiptapDocument", schema); + } + return withNullable( + `${itemType.includes(" | ") ? `(${itemType})` : itemType}[]`, + schema + ); } case "object": { if (schema.properties === undefined) { @@ -263,10 +269,17 @@ export function renderSchemaType( return "unknown"; } + const renderedVariants = variants.map((variant) => + renderSchemaType(variant, nextCtx) + ); + const specificVariants = renderedVariants.filter( + (variant) => variant !== "Record" + ); return withNullable( - variants - .map((variant) => renderSchemaType(variant, nextCtx)) - .join(" | "), + (specificVariants.length > 0 + ? specificVariants + : renderedVariants + ).join(" | "), schema ); } diff --git a/src/emitters/types/__tests__/emit.test.ts b/src/emitters/types/__tests__/emit.test.ts index fb41cd2..042bc19 100644 --- a/src/emitters/types/__tests__/emit.test.ts +++ b/src/emitters/types/__tests__/emit.test.ts @@ -102,6 +102,7 @@ describe("emitTypeFiles", () => { file.relativePath.endsWith("widgets/GET.d.ts") ); expect(listType?.content).toContain('SortParams<"name" | "createdAt">'); + expect(listType?.content).not.toContain("Record { diff --git a/src/emitters/types/index.ts b/src/emitters/types/index.ts index 724cbd9..793b5cc 100644 --- a/src/emitters/types/index.ts +++ b/src/emitters/types/index.ts @@ -148,18 +148,16 @@ function renderParamsInterface( } if (extendsParts.length > 0 && nonCommonParams.length === 0) { - return `export type ${typeName}Params = ${extendsParts.join(" & ")} & Record;`; + return `export type ${typeName}Params = ${extendsParts.join(" & ")};`; } const lines: Array = []; if (extendsParts.length > 0) { lines.push( - `export interface ${typeName}Params extends ${extendsParts.join(", ")}, Record {` + `export interface ${typeName}Params extends ${extendsParts.join(", ")} {` ); } else { - lines.push( - `export interface ${typeName}Params extends Record {` - ); + lines.push(`export interface ${typeName}Params {`); } for (const param of nonCommonParams) { diff --git a/src/http/types.ts b/src/http/types.ts index d905dbe..df7a341 100644 --- a/src/http/types.ts +++ b/src/http/types.ts @@ -6,7 +6,7 @@ export type { ResponseValidator } from "./validate"; export { coerceResponseData, ResponseValidationError } from "./validate"; export interface HTTPFetchConfig< - TParams extends QueryParams = QueryParams, + TParams extends object = QueryParams, TResponse = unknown, > { signal?: AbortSignal; @@ -16,26 +16,26 @@ export interface HTTPFetchConfig< } export interface HTTPFetch { - get( + get( route: string, config?: HTTPFetchConfig ): Promise<{ data: TResponse }>; - post( + post( route: string, body: TBody, config?: HTTPFetchConfig ): Promise<{ data: TResponse }>; - put( + put( route: string, body: TBody, config?: HTTPFetchConfig ): Promise<{ data: TResponse }>; - patch( + patch( route: string, body: TBody, config?: HTTPFetchConfig ): Promise<{ data: TResponse }>; - delete( + delete( route: string, config?: HTTPFetchConfig ): Promise<{ data: TResponse }>; diff --git a/test/fixtures/templates/typed-http.ts b/test/fixtures/templates/typed-http.ts index a9212a3..0209c48 100644 --- a/test/fixtures/templates/typed-http.ts +++ b/test/fixtures/templates/typed-http.ts @@ -4,7 +4,7 @@ export type QueryParams = Record; export type ResponseValidator = (value: unknown) => TResponse; export interface HTTPFetchConfig< - TParams extends QueryParams = QueryParams, + TParams extends object = QueryParams, TResponse = unknown, > { signal?: AbortSignal; @@ -14,26 +14,26 @@ export interface HTTPFetchConfig< } export interface HTTPFetch { - get( + get( route: string, config?: HTTPFetchConfig ): Promise<{ data: TResponse }>; - post( + post( route: string, body: TBody, config?: HTTPFetchConfig ): Promise<{ data: TResponse }>; - put( + put( route: string, body: TBody, config?: HTTPFetchConfig ): Promise<{ data: TResponse }>; - patch( + patch( route: string, body: TBody, config?: HTTPFetchConfig ): Promise<{ data: TResponse }>; - delete( + delete( route: string, config?: HTTPFetchConfig ): Promise<{ data: TResponse }>; From 74b65192f89b1e6dd6855153c9b8ae2d06fd0ef9 Mon Sep 17 00:00:00 2001 From: Wongkraiwich Chuenchomphu Date: Fri, 21 Aug 2026 01:05:38 +0700 Subject: [PATCH 3/6] fix(config): colocate generated base with type root --- README.md | 2 +- src/generate/index.ts | 4 ++-- test/integration/generate.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7bc376e..ffaceeb 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ Re-exported types from the package root: | --- | --- | | `spec` | Project-relative spec path (used when no `--spec` / env override) | | `functionsDir` | Project-relative function output directory (defaults to the source's `generated/functions`) | -| `typesDir` | Project-relative type output directory (defaults to the source's `generated/types`; a generated `base.ts` is colocated here when customized) | +| `typesDir` | Project-relative type output directory (defaults to the source's `generated/types`; a generated `base.ts` is placed beside this directory when customized) | | `pathPrefix` | Only generate operations under this prefix (e.g. `/api/acme/v3`) | | `ignorePaths` | Extra paths to skip | | `stripApiPrefix` | Strip a leading `/api` segment from route enum member names | diff --git a/src/generate/index.ts b/src/generate/index.ts index 48303b2..39e63d2 100644 --- a/src/generate/index.ts +++ b/src/generate/index.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { detectHttpMode, @@ -86,7 +86,7 @@ export function buildGenerateContext( baseFile: sourceConfig.typesDir === undefined ? join(generatedDir, "base.ts") - : join(typesDir, "base.ts"), + : join(dirname(typesDir), "base.ts"), cwd, functionsDir, generatedDir, diff --git a/test/integration/generate.test.ts b/test/integration/generate.test.ts index 9e464f3..642bb72 100644 --- a/test/integration/generate.test.ts +++ b/test/integration/generate.test.ts @@ -106,7 +106,7 @@ describe("integration: monolith generate", () => { await generateForSource({ cwd: root, sourceKey: "core" }); - expect(existsSync(join(typesDir, "base.ts"))).toBe(true); + expect(existsSync(join(typesDir, "../base.ts"))).toBe(true); expect(existsSync(join(typesDir, "api/acme/v3/widgets/GET.d.ts"))).toBe( true ); From 9ffc8f6cb604ca03339522eb0888d90338dfc62f Mon Sep 17 00:00:00 2001 From: Wongkraiwich Chuenchomphu Date: Fri, 21 Aug 2026 01:15:29 +0700 Subject: [PATCH 4/6] fix(types): preserve operation envelope fields --- docs/envelope.md | 1 + src/emitters/types/__tests__/emit.test.ts | 53 +++++++++++++++++++++++ src/emitters/types/index.ts | 6 ++- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/docs/envelope.md b/docs/envelope.md index bfaa82b..40d73b3 100644 --- a/docs/envelope.md +++ b/docs/envelope.md @@ -10,6 +10,7 @@ Every success response shares the same envelope object (for example `{ success, - Writes `generated/base.ts` with `BaseResponse`. - Per-operation response types unwrap `data`: `export type GETApiAcmeV3WidgetsResponse = BaseResponse`. +- Operation-specific fields beside `data` are intersected back into the response, preserving their exact schemas instead of widening them to the shared base field type. - If `apiRoot/models.ts` already exports `BaseResponse` and its fields differ from the spec, generate **fails** until you either update `models.ts` or run `accept-base`. Synthetic fixture: `test/fixtures/specs/envelope-list.json` (`/api/acme/v3/widgets`). diff --git a/src/emitters/types/__tests__/emit.test.ts b/src/emitters/types/__tests__/emit.test.ts index 042bc19..28f78fd 100644 --- a/src/emitters/types/__tests__/emit.test.ts +++ b/src/emitters/types/__tests__/emit.test.ts @@ -35,6 +35,59 @@ describe("emitTypeFiles", () => { file.relativePath.endsWith("widgets/GET.d.ts") ); expect(listType?.content).toContain("BaseResponse<"); + expect(listType?.content).toContain("Omit<{"); + expect(listType?.content).toContain('"data">'); + expect(listType?.content).toContain("success: boolean"); + expect(listType?.content).toContain("timestamp: string"); + }); + + it("preserves operation-specific fields beside envelope data", () => { + const source = parseSpec( + { + openapi: "3.0.0", + info: { title: "t", version: "1" }, + paths: { + "/api/acme/v3/widgets": { + post: { + responses: { + "200": { + content: { + "application/json": { + schema: { + type: "object", + properties: { + data: { type: "string" }, + warnings: { + type: "object", + properties: { + message: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { pathPrefix: "/api/acme/v3" } + ); + const analysis = analyzeEnvelope(source); + const files = emitTypeFiles({ + baseFile: `${generatedRoot}/base.ts`, + envelopeMode: analysis.mode, + ...(analysis.shared === undefined + ? {} + : { sharedEnvelope: analysis.shared }), + source, + typesDir: `${generatedRoot}/types`, + }); + + expect(files[0]?.content).toContain("warnings?: {"); + expect(files[0]?.content).toContain("message?: string"); }); it("emits raw response types without BaseResponse wrapper", () => { diff --git a/src/emitters/types/index.ts b/src/emitters/types/index.ts index 793b5cc..651db08 100644 --- a/src/emitters/types/index.ts +++ b/src/emitters/types/index.ts @@ -243,7 +243,11 @@ function renderResponseType( dataSchema, createRenderContext(options, `${typeName}Response.data`, knownTypeImports) ); - return `export type ${typeName}Response = import("${baseImportPath}").BaseResponse<${dataType}>;`; + const responseType = renderSchemaType( + schema, + createRenderContext(options, `${typeName}Response`, knownTypeImports) + ); + return `export type ${typeName}Response = import("${baseImportPath}").BaseResponse<${dataType}> & Omit<${responseType}, "data">;`; } const responseType = renderSchemaType( From d3bbcf2c33c1c2421ac67920fb1352cec7defb54 Mon Sep 17 00:00:00 2001 From: Wongkraiwich Chuenchomphu Date: Fri, 21 Aug 2026 01:19:37 +0700 Subject: [PATCH 5/6] fix(types): wrap only matching envelopes --- docs/envelope.md | 4 ++-- src/emitters/types/index.ts | 22 +++++++++++++++++++++- src/envelope-guard/index.ts | 9 +++++++++ src/generate/index.ts | 4 ++-- test/fixtures/specs/mixed-envelope.json | 19 +++++++++++++++++++ test/integration/generate.test.ts | 7 +++++++ 6 files changed, 60 insertions(+), 5 deletions(-) diff --git a/docs/envelope.md b/docs/envelope.md index 40d73b3..6b21ab4 100644 --- a/docs/envelope.md +++ b/docs/envelope.md @@ -26,8 +26,8 @@ Synthetic fixture: `test/fixtures/specs/raw-cursor-list.json` (`/api/orbit/v1`). Some operations return an envelope with `data`; others return a plain object. Mixed mode: 1. Writes `generated/base.ts` from the **largest envelope group that includes `data`**. -2. Operations whose success schema has a `data` property use `BaseResponse`. -3. Operations without `data` keep the raw schema (no unwrap). +2. Operations matching that primary envelope use `BaseResponse`. +3. Other operations keep their exact raw schema, including differently shaped objects that also contain `data`. Example from `test/fixtures/specs/mixed-envelope.json`: diff --git a/src/emitters/types/index.ts b/src/emitters/types/index.ts index 651db08..0e03ee7 100644 --- a/src/emitters/types/index.ts +++ b/src/emitters/types/index.ts @@ -1,4 +1,7 @@ -import { buildBaseResponseInterface } from "../../envelope-guard/index"; +import { + buildBaseResponseInterface, + matchesEnvelopeShape, +} from "../../envelope-guard/index"; import type { EnvelopeMode, EnvelopeShape } from "../../envelope-guard/index"; import type { QueryExtendsConfig } from "../../config/types"; import { DEFAULT_MAX_RENDER_DEPTH } from "../../config/types"; @@ -239,6 +242,23 @@ function renderResponseType( ? resolved.properties.data.schema : undefined; if (dataSchema !== undefined) { + const usesBaseResponse = + _envelopeMode === "shared" || + (_envelopeMode === "mixed" && + options.sharedEnvelope !== undefined && + matchesEnvelopeShape( + schema, + options.source.components.schemas, + options.sharedEnvelope + )); + if (!usesBaseResponse) { + const responseType = renderSchemaType( + schema, + createRenderContext(options, `${typeName}Response`, knownTypeImports) + ); + return `export type ${typeName}Response = ${responseType};`; + } + const dataType = renderSchemaType( dataSchema, createRenderContext(options, `${typeName}Response.data`, knownTypeImports) diff --git a/src/envelope-guard/index.ts b/src/envelope-guard/index.ts index 13b899f..4277b73 100644 --- a/src/envelope-guard/index.ts +++ b/src/envelope-guard/index.ts @@ -89,6 +89,15 @@ function fingerprint(shape: EnvelopeShape): string { ); } +export function matchesEnvelopeShape( + schema: IRSchema, + components: Record, + expected: EnvelopeShape +): boolean { + const actual = extractEnvelopeShape(schema, components); + return actual !== undefined && fingerprint(actual) === fingerprint(expected); +} + function looksLikeEnvelope(shape: EnvelopeShape): boolean { const names = new Set(shape.fields.map((field) => field.name)); return names.has("data") || names.has("success") || names.has("message"); diff --git a/src/generate/index.ts b/src/generate/index.ts index 39e63d2..e6049ee 100644 --- a/src/generate/index.ts +++ b/src/generate/index.ts @@ -242,8 +242,8 @@ export async function generateForSource( typeEmitterOptions.resolveMapKeyRefs = context.sourceConfig.resolveMapKeyRefs; } - if (analysis.shared !== undefined) { - typeEmitterOptions.sharedEnvelope = analysis.shared; + if (primaryEnvelope !== undefined) { + typeEmitterOptions.sharedEnvelope = primaryEnvelope; } if (context.sourceConfig.maxRenderDepth !== undefined) { typeEmitterOptions.maxRenderDepth = context.sourceConfig.maxRenderDepth; diff --git a/test/fixtures/specs/mixed-envelope.json b/test/fixtures/specs/mixed-envelope.json index d93cc3c..4b64cdd 100644 --- a/test/fixtures/specs/mixed-envelope.json +++ b/test/fixtures/specs/mixed-envelope.json @@ -53,6 +53,25 @@ } } } + }, + "/api/acme/v3/secondary": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { "type": "string" }, + "cursor": { "type": "string" } + } + } + } + } + } + } + } } } } diff --git a/test/integration/generate.test.ts b/test/integration/generate.test.ts index 642bb72..e40a57e 100644 --- a/test/integration/generate.test.ts +++ b/test/integration/generate.test.ts @@ -145,6 +145,13 @@ describe("integration: monolith generate", () => { ); expect(rawType).not.toContain("BaseResponse<"); expect(rawType).toContain("token"); + + const secondaryEnvelopeType = readFileSync( + join(generatedDir, "types/api/acme/v3/secondary/GET.d.ts"), + "utf8" + ); + expect(secondaryEnvelopeType).not.toContain("BaseResponse<"); + expect(secondaryEnvelopeType).toContain("cursor?: string"); }); it("fails generate for recursive schemas without known-type override", async () => { From b2dde8bb6b5f5f0b41149c9e3fafe0c475662e01 Mon Sep 17 00:00:00 2001 From: Wongkraiwich Chuenchomphu Date: Fri, 21 Aug 2026 01:24:28 +0700 Subject: [PATCH 6/6] fix(types): resolve base imports from tsconfig --- README.md | 5 +++-- src/emitters/types/index.ts | 15 ++++++++++----- src/generate/index.ts | 4 ++++ test/integration/generate.test.ts | 21 +++++++++++++++++++++ 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ffaceeb..60d9ccd 100644 --- a/README.md +++ b/README.md @@ -177,10 +177,11 @@ Adapters implement `HTTPFetch` from `@openmirai/openapi-codegen/http` (or the ax ### 6. Path-alias aware imports -Function files import types and `runtime` using: +Generated function files import types and `runtime`, and generated response +types import `base.ts`, using: 1. `importBase` in `source.ts`, if set -2. Else `compilerOptions.paths` from the nearest ancestor `tsconfig.json` with path aliases, starting at `functionsDir` +2. Else `compilerOptions.paths` from the nearest ancestor `tsconfig.json` with path aliases, starting at the corresponding `functionsDir` or `typesDir` 3. Else relative paths (`../../runtime`) ## Where files go diff --git a/src/emitters/types/index.ts b/src/emitters/types/index.ts index 0e03ee7..862ae64 100644 --- a/src/emitters/types/index.ts +++ b/src/emitters/types/index.ts @@ -8,7 +8,8 @@ import { DEFAULT_MAX_RENDER_DEPTH } from "../../config/types"; import type { JsonObject } from "../../json/types"; import type { KnownTypeRule } from "../../plugins/known-types/index"; import type { IROperation, IRQueryParam, IRSource } from "../../parser/types"; -import { relativeImportPath } from "../../utils/imports"; +import type { TsconfigPathsConfig } from "../../utils/tsconfig-paths"; +import { resolveAliasAwareImport } from "../../utils/imports"; import { renderSchemaType } from "../schema-renderer"; import type { SchemaRenderContext } from "../schema-renderer"; import { resolveRef } from "../resolve-schema"; @@ -30,6 +31,7 @@ export interface TypesEmitterOptions { resolveMapKeyRefs?: boolean; typesDir: string; baseFile: string; + tsconfigPaths?: TsconfigPathsConfig; } function createRenderContext( @@ -323,10 +325,13 @@ export function emitTypeFiles( } const typeFile = `${options.typesDir}/${pathItem.cleanPath}/${operation.method.toUpperCase()}.d.ts`; - const baseImportPath = relativeImportPath( - typeFile, - options.baseFile.replace(/\.ts$/, "") - ); + const baseImportPath = resolveAliasAwareImport({ + fromAbsolutePath: typeFile, + toAbsolutePath: options.baseFile.replace(/\.ts$/, ""), + ...(options.tsconfigPaths === undefined + ? {} + : { tsconfigPaths: options.tsconfigPaths }), + }); blocks.push( renderResponseType( diff --git a/src/generate/index.ts b/src/generate/index.ts index e6049ee..182afe8 100644 --- a/src/generate/index.ts +++ b/src/generate/index.ts @@ -245,6 +245,10 @@ export async function generateForSource( if (primaryEnvelope !== undefined) { typeEmitterOptions.sharedEnvelope = primaryEnvelope; } + const typesTsconfigPaths = loadTsconfigPaths(context.typesDir); + if (typesTsconfigPaths !== undefined) { + typeEmitterOptions.tsconfigPaths = typesTsconfigPaths; + } if (context.sourceConfig.maxRenderDepth !== undefined) { typeEmitterOptions.maxRenderDepth = context.sourceConfig.maxRenderDepth; } diff --git a/test/integration/generate.test.ts b/test/integration/generate.test.ts index e40a57e..0a9cc4c 100644 --- a/test/integration/generate.test.ts +++ b/test/integration/generate.test.ts @@ -103,6 +103,18 @@ describe("integration: monolith generate", () => { }), "utf8" ); + mkdirSync(join(root, "packages/types"), { recursive: true }); + writeFileSync( + join(root, "packages/types/tsconfig.json"), + JSON.stringify({ + compilerOptions: { + paths: { + "@mirai/types/src/*": ["./src/*"], + }, + }, + }), + "utf8" + ); await generateForSource({ cwd: root, sourceKey: "core" }); @@ -119,6 +131,15 @@ describe("integration: monolith generate", () => { ); expect(fn).toContain('from "@mirai/utils/src/api/core/generated/runtime"'); expect(fn).not.toContain("../../../../"); + + const responseType = readFileSync( + join(typesDir, "api/acme/v3/widgets/GET.d.ts"), + "utf8" + ); + expect(responseType).toContain( + 'import("@mirai/types/src/api/core/base").BaseResponse' + ); + expect(responseType).not.toContain("../../../../"); }); it("generates mixed envelope specs with per-operation wrapping", async () => {