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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 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 |
Expand Down Expand Up @@ -173,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 `tsconfig.json`
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
Expand All @@ -203,6 +208,11 @@ src/api/atlas/generated/…

**Packages layout** (`--layout packages`): typical placement is `packages/utils/src/api/<source>/`.

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
Expand Down
5 changes: 3 additions & 2 deletions docs/envelope.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Every success response shares the same envelope object (for example `{ success,

- Writes `generated/base.ts` with `BaseResponse<T>`.
- Per-operation response types unwrap `data`: `export type GETApiAcmeV3WidgetsResponse = BaseResponse<Widget[]>`.
- 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<T>` 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`).
Expand All @@ -25,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<Unwrapped>`.
3. Operations without `data` keep the raw schema (no unwrap).
2. Operations matching that primary envelope use `BaseResponse<Unwrapped>`.
3. Other operations keep their exact raw schema, including differently shaped objects that also contain `data`.

Example from `test/fixtures/specs/mixed-envelope.json`:

Expand Down
28 changes: 8 additions & 20 deletions src/adapters/axios/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { coerceResponseData } from "../../http/validate";
import type { HTTPFetch, HTTPFetchConfig } from "../../http/types";
import type { QueryParams } from "../../json/types";

function toAxiosConfig<TParams extends QueryParams>(
function toAxiosConfig<TParams extends object>(
config?: HTTPFetchConfig<TParams, unknown>
): AxiosRequestConfig | undefined {
if (config === undefined) {
Expand All @@ -24,9 +24,9 @@ function toAxiosConfig<TParams extends QueryParams>(
return axiosConfig;
}

function mapResponse<TResponse>(
function mapResponse<TResponse, TParams extends object>(
data: unknown,
config?: HTTPFetchConfig<QueryParams, TResponse>
config?: HTTPFetchConfig<TParams, TResponse>
): { data: TResponse } {
return {
data: coerceResponseData<TResponse>(data, config?.validateResponse),
Expand All @@ -35,49 +35,37 @@ function mapResponse<TResponse>(

export function createAxiosAdapter(instance: AxiosInstance): HTTPFetch {
return {
delete: <TResponse, TParams extends QueryParams = QueryParams>(
delete: <TResponse, TParams extends object = QueryParams>(
route: string,
config?: HTTPFetchConfig<TParams, TResponse>
) =>
instance
.delete<TResponse>(route, toAxiosConfig(config))
.then((response) => mapResponse(response.data, config)),
get: <TResponse, TParams extends QueryParams = QueryParams>(
get: <TResponse, TParams extends object = QueryParams>(
route: string,
config?: HTTPFetchConfig<TParams, TResponse>
) =>
instance
.get<TResponse>(route, toAxiosConfig(config))
.then((response) => mapResponse(response.data, config)),
patch: <
TResponse,
TBody = unknown,
TParams extends QueryParams = QueryParams,
>(
patch: <TResponse, TBody = unknown, TParams extends object = QueryParams>(
route: string,
body: TBody,
config?: HTTPFetchConfig<TParams, TResponse>
) =>
instance
.patch<TResponse>(route, body, toAxiosConfig(config))
.then((response) => mapResponse(response.data, config)),
post: <
TResponse,
TBody = unknown,
TParams extends QueryParams = QueryParams,
>(
post: <TResponse, TBody = unknown, TParams extends object = QueryParams>(
route: string,
body: TBody,
config?: HTTPFetchConfig<TParams, TResponse>
) =>
instance
.post<TResponse>(route, body, toAxiosConfig(config))
.then((response) => mapResponse(response.data, config)),
put: <
TResponse,
TBody = unknown,
TParams extends QueryParams = QueryParams,
>(
put: <TResponse, TBody = unknown, TParams extends object = QueryParams>(
route: string,
body: TBody,
config?: HTTPFetchConfig<TParams, TResponse>
Expand Down
39 changes: 14 additions & 25 deletions src/adapters/fetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -34,12 +34,12 @@ function readResponseBody(text: string): JsonValue | undefined {
return parseJson(text);
}

async function request<TResponse>(
async function request<TResponse, TParams extends object>(
method: string,
route: string,
options: FetchAdapterOptions,
body?: unknown,
config?: HTTPFetchConfig<QueryParams, TResponse>
config?: HTTPFetchConfig<TParams, TResponse>
): Promise<{ data: TResponse }> {
const fetchImpl = options.fetch ?? globalThis.fetch;
const baseURL = options.baseURL ?? "";
Expand Down Expand Up @@ -79,41 +79,30 @@ export function createFetchAdapter(
options: FetchAdapterOptions = {}
): HTTPFetch {
return {
delete: <TResponse, TParams extends QueryParams = QueryParams>(
delete: <TResponse, TParams extends object = QueryParams>(
route: string,
config?: HTTPFetchConfig<TParams, TResponse>
) => request<TResponse>("DELETE", route, options, undefined, config),
get: <TResponse, TParams extends QueryParams = QueryParams>(
) =>
request<TResponse, TParams>("DELETE", route, options, undefined, config),
get: <TResponse, TParams extends object = QueryParams>(
route: string,
config?: HTTPFetchConfig<TParams, TResponse>
) => request<TResponse>("GET", route, options, undefined, config),
patch: <
TResponse,
TBody = unknown,
TParams extends QueryParams = QueryParams,
>(
) => request<TResponse, TParams>("GET", route, options, undefined, config),
patch: <TResponse, TBody = unknown, TParams extends object = QueryParams>(
route: string,
body: TBody,
config?: HTTPFetchConfig<TParams, TResponse>
) => request<TResponse>("PATCH", route, options, body, config),
post: <
TResponse,
TBody = unknown,
TParams extends QueryParams = QueryParams,
>(
) => request<TResponse, TParams>("PATCH", route, options, body, config),
post: <TResponse, TBody = unknown, TParams extends object = QueryParams>(
route: string,
body: TBody,
config?: HTTPFetchConfig<TParams, TResponse>
) => request<TResponse>("POST", route, options, body, config),
put: <
TResponse,
TBody = unknown,
TParams extends QueryParams = QueryParams,
>(
) => request<TResponse, TParams>("POST", route, options, body, config),
put: <TResponse, TBody = unknown, TParams extends object = QueryParams>(
route: string,
body: TBody,
config?: HTTPFetchConfig<TParams, TResponse>
) => request<TResponse>("PUT", route, options, body, config),
) => request<TResponse, TParams>("PUT", route, options, body, config),
};
}

Expand Down
4 changes: 4 additions & 0 deletions src/config/__tests__/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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");
Expand Down
10 changes: 10 additions & 0 deletions src/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
10 changes: 10 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ export interface QueryExtendsConfig {
}

export interface SourceConfig {
/**
* Project-relative directory for generated API function files.
* Defaults to `<apiRoot>/<source>/generated/functions`.
*/
functionsDir?: string;
/**
* Project-relative directory for generated API type files.
* Defaults to `<apiRoot>/<source>/generated/types`.
*/
typesDir?: string;
pathPrefix?: string;
ignorePaths?: Array<string>;
stripApiPrefix?: boolean;
Expand Down
55 changes: 55 additions & 0 deletions src/emitters/__tests__/schema-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,61 @@
};

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")
Expand All @@ -35,7 +90,7 @@
expect(schema).toBeDefined();

expect(() =>
renderSchemaType(schema!, {

Check warning on line 93 in src/emitters/__tests__/schema-renderer.test.ts

View workflow job for this annotation

GitHub Actions / Check repository

typescript(no-non-null-assertion)

Forbidden non-null assertion.
components: source.components.schemas,
schemaPath: "GET /api/acme/v3/nodes",
sourceKey: "test",
Expand All @@ -52,7 +107,7 @@
const schema = operation?.responses[0]?.schema;
expect(schema).toBeDefined();

const output = renderSchemaType(schema!, {

Check warning on line 110 in src/emitters/__tests__/schema-renderer.test.ts

View workflow job for this annotation

GitHub Actions / Check repository

typescript(no-non-null-assertion)

Forbidden non-null assertion.
components: source.components.schemas,
knownTypeImports: new Map(),
knownTypes: [blobOverrideRule],
Expand All @@ -74,7 +129,7 @@
const schema = operation?.responses[0]?.schema;
expect(schema).toBeDefined();

const output = renderSchemaType(schema!, {

Check warning on line 132 in src/emitters/__tests__/schema-renderer.test.ts

View workflow job for this annotation

GitHub Actions / Check repository

typescript(no-non-null-assertion)

Forbidden non-null assertion.
components: source.components.schemas,
rawSpec: raw,
resolveMapKeyRefs: true,
Expand Down
Loading
Loading