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
7 changes: 2 additions & 5 deletions packages/generator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,16 @@
},
"dependencies": {
"@scalar/json-magic": "^0.12.4",
"@scalar/openapi-parser": "^0.25.6"
"@scalar/openapi-parser": "^0.25.6",
"@scalar/openapi-types": "0.6.1"
},
"devDependencies": {
"@scalar/openapi-types": "^0.6.1",
"@tanstack/react-query": "catalog:",
"@tanstack/svelte-query": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"@vitest/coverage-v8": "catalog:",
"bumpp": "^11.0.1",
"vite-plus": "catalog:"
},
"inlinedDependencies": {
"@scalar/openapi-types": "0.6.1"
}
}
40 changes: 17 additions & 23 deletions packages/generator/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { mkdir, writeFile } from 'node:fs/promises';
import { basename, extname, resolve } from 'node:path';
import { resolve } from 'node:path';
import { parseArgs } from 'node:util';

import { bundle } from '@scalar/json-magic/bundle';
Expand All @@ -19,22 +19,16 @@ Usage: openapi-gen <input> [options]
<input> URL or local file path to an OpenAPI 3.1 spec (JSON or YAML)

Options:
-o, --output <dir> Output directory (default: ".")
--types <filename> Types output filename (default: "types.ts")
--client <filename> Client output filename (default: "client.ts")
--query <filename> TanStack Query helpers output filename (default: "query.ts")
--tanstack-query <name> TanStack Query framework: react or svelte
-o, --output <dir> Output directory (default: ".")
--tanstack-query <name> TanStack Query framework: react or svelte
--no-throw-on-http-error Do not throw HTTPError on non-ok responses
-h, --help Show this help message
-h, --help Show this help message
`;

const { values, positionals } = parseArgs({
args: process.argv.slice(2),
options: {
output: { type: 'string', short: 'o', default: '.' },
types: { type: 'string', default: 'types.ts' },
client: { type: 'string', default: 'client.ts' },
query: { type: 'string', default: 'query.ts' },
'tanstack-query': { type: 'string' },
'no-throw-on-http-error': { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h', default: false },
Expand All @@ -49,38 +43,38 @@ if (values.help || positionals.length === 0) {

const input = positionals[0];
const outputDir = resolve(values.output!);
const typesFile = resolve(outputDir, values.types!);
const clientFile = resolve(outputDir, values.client!);
const queryFile = resolve(outputDir, values.query!);

try {
const spec = await bundle(input, {
treeShake: true,
plugins: [fetchUrls(), readFiles(), parseJson(), parseYaml()],
});

const typesImportPath =
'./' + basename(values.types!, extname(values.types!));
const { types, client, query } = await generateFromObject(
const { types, client, query, index } = await generateFromObject(
spec as Record<string, unknown>,
{
tanstackQuery: values['tanstack-query'] as QueryFramework | undefined,
typesImportPath,
throwOnHttpError: !values['no-throw-on-http-error'],
}
);

await mkdir(outputDir, { recursive: true });

const writes: Promise<void>[] = [
writeFile(typesFile, types, 'utf8'),
writeFile(clientFile, client, 'utf8'),
writeFile(resolve(outputDir, 'client.ts'), client, 'utf8'),
writeFile(resolve(outputDir, 'index.ts'), index, 'utf8'),
];
if (query !== null) writes.push(writeFile(queryFile, query, 'utf8'));
if (types)
writes.push(writeFile(resolve(outputDir, 'types.ts'), types, 'utf8'));
if (query !== null)
writes.push(writeFile(resolve(outputDir, 'query.ts'), query, 'utf8'));
await Promise.all(writes);

console.info(`types → ${typesFile}`);
console.info(`client → ${clientFile}`);
if (query !== null) console.info(`query → ${queryFile}`);
if (types) console.info(`types → ${resolve(outputDir, 'types.ts')}`);
console.info(`client → ${resolve(outputDir, 'client.ts')}`);
if (query !== null)
console.info(`query → ${resolve(outputDir, 'query.ts')}`);
console.info(`index → ${resolve(outputDir, 'index.ts')}`);
} catch (err) {
console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
Expand Down
2 changes: 1 addition & 1 deletion packages/generator/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ export function generateClient(
: '';

return (
`/* eslint-disable */\n` +
`/* eslint-disable */\n/* prettier-ignore-start */\n` +
importLine +
preamble +
[
Expand Down
27 changes: 17 additions & 10 deletions packages/generator/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface GenerateResult {
client: string;
/** TanStack Query helper functions generated from paths, or null if no framework was specified */
query: string | null;
/** Barrel index.ts that re-exports from types, client, and query */
index: string;
}

export interface GenerateOptions {
Expand Down Expand Up @@ -58,14 +60,19 @@ export async function generateFromObject(
const schemas: Record<string, OpenAPIV3_1.SchemaObject> =
spec31.components?.schemas ?? {};
const paths: OpenAPIV3_1.PathsObject = spec31.paths ?? {};
return {
types: generateTypes(schemas),
client: generateClient(paths, {
typesImportPath: options?.typesImportPath,
throwOnHttpError: options?.throwOnHttpError,
}),
query: options?.tanstackQuery
? generateQuery(paths, options.tanstackQuery)
: null,
};
const types = generateTypes(schemas);
const client = generateClient(paths, {
typesImportPath: options?.typesImportPath,
throwOnHttpError: options?.throwOnHttpError,
});
const query = options?.tanstackQuery
? generateQuery(paths, options.tanstackQuery)
: null;

const indexLines = ['/* eslint-disable */', '/* prettier-ignore-start */'];
if (types) indexLines.push(`export * from './types';`);
indexLines.push(`export * from './client';`);
if (query !== null) indexLines.push(`export * from './query';`);

return { types, client, query, index: indexLines.join('\n') + '\n' };
}
48 changes: 48 additions & 0 deletions packages/generator/src/node.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';

import { bundle } from '@scalar/json-magic/bundle';
import {
fetchUrls,
Expand All @@ -12,6 +15,11 @@ import {
type GenerateResult,
} from './index.js';

export interface WriteOptions {
/** Output directory (default: `'.'`) */
outDir?: string;
}

/**
* Generate TypeScript types and API client from a local file path, URL,
* or already-parsed object.
Expand All @@ -35,3 +43,43 @@ export async function generateFrom(
});
return generateFromObject(spec as Record<string, unknown>, options);
}

/**
* Generate and write output files to disk.
*
* Writes `client.ts` and `index.ts` always; `types.ts` when schemas exist;
* `query.ts` when a TanStack Query framework is specified.
*
* @returns Resolved paths of the written files.
*/
export async function generateAndWrite(
input: string | Record<string, unknown>,
options?: GenerateOptions & WriteOptions
): Promise<{ types?: string; client: string; query?: string; index: string }> {
const { outDir, ...generateOptions } = options ?? {};
const outputDir = resolve(outDir ?? '.');

const { types, client, query, index } = await generateFrom(
input,
generateOptions
);

await mkdir(outputDir, { recursive: true });

const writes: Promise<void>[] = [
writeFile(resolve(outputDir, 'client.ts'), client, 'utf8'),
writeFile(resolve(outputDir, 'index.ts'), index, 'utf8'),
];
if (types)
writes.push(writeFile(resolve(outputDir, 'types.ts'), types, 'utf8'));
if (query !== null)
writes.push(writeFile(resolve(outputDir, 'query.ts'), query, 'utf8'));
await Promise.all(writes);

return {
...(types ? { types: resolve(outputDir, 'types.ts') } : {}),
client: resolve(outputDir, 'client.ts'),
...(query !== null ? { query: resolve(outputDir, 'query.ts') } : {}),
index: resolve(outputDir, 'index.ts'),
};
}
1 change: 1 addition & 0 deletions packages/generator/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export function generateQuery(

const header = [
`/* eslint-disable */`,
`/* prettier-ignore-start */`,
`import type { apiClient } from './client';`,
`import type { ${queryOptionsType}, ${mutationOptionsType} } from '${pkg}';`,
``,
Expand Down
4 changes: 2 additions & 2 deletions packages/generator/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,13 +174,13 @@ function generateSchemaType(
return `${jsdocLines.join('\n')}\nexport type ${name} = ${schemaToTypeString(s)};`;
}

const ESLINT_DISABLE = '/* eslint-disable */';
const FILE_HEADER = '/* eslint-disable */\n/* prettier-ignore-start */';

export function generateTypes(
schemas: Record<string, OpenAPIV3_1.SchemaObject>
): string {
const body = Object.entries(schemas)
.map(([name, schema]) => generateSchemaType(name, schema))
.join('\n\n');
return body ? `${ESLINT_DISABLE}\n${body}` : '';
return body ? `${FILE_HEADER}\n${body}` : '';
}
13 changes: 13 additions & 0 deletions packages/generator/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ describe('generateClient', () => {
expect(generateClient({ '/users': { get: { responses: {} } } }))
.toMatchInlineSnapshot(`
"/* eslint-disable */
/* prettier-ignore-start */
export class HTTPError extends Error {
readonly status: number;
readonly statusText: string;
Expand Down Expand Up @@ -170,6 +171,7 @@ describe('generateClient', () => {
generateClient({ '/auth/verify-email': { post: { responses: {} } } })
).toMatchInlineSnapshot(`
"/* eslint-disable */
/* prettier-ignore-start */
export class HTTPError extends Error {
readonly status: number;
readonly statusText: string;
Expand Down Expand Up @@ -217,6 +219,7 @@ describe('generateClient', () => {
expect(generateClient({ '/api/_public': { get: { responses: {} } } }))
.toMatchInlineSnapshot(`
"/* eslint-disable */
/* prettier-ignore-start */
export class HTTPError extends Error {
readonly status: number;
readonly statusText: string;
Expand Down Expand Up @@ -276,6 +279,7 @@ describe('generateClient', () => {
})
).toMatchInlineSnapshot(`
"/* eslint-disable */
/* prettier-ignore-start */
export class HTTPError extends Error {
readonly status: number;
readonly statusText: string;
Expand Down Expand Up @@ -333,6 +337,7 @@ describe('generateClient', () => {
})
).toMatchInlineSnapshot(`
"/* eslint-disable */
/* prettier-ignore-start */
export class HTTPError extends Error {
readonly status: number;
readonly statusText: string;
Expand Down Expand Up @@ -380,6 +385,7 @@ describe('generateClient', () => {
expect(generateClient({ '/users/{id}': { get: { responses: {} } } }))
.toMatchInlineSnapshot(`
"/* eslint-disable */
/* prettier-ignore-start */
export class HTTPError extends Error {
readonly status: number;
readonly statusText: string;
Expand Down Expand Up @@ -447,6 +453,7 @@ describe('generateClient', () => {
})
).toMatchInlineSnapshot(`
"/* eslint-disable */
/* prettier-ignore-start */
import type { User } from './types';

export class HTTPError extends Error {
Expand Down Expand Up @@ -496,6 +503,7 @@ describe('generateClient', () => {
expect(generateClient({ '/ping': { get: { responses: {} } } }))
.toMatchInlineSnapshot(`
"/* eslint-disable */
/* prettier-ignore-start */
export class HTTPError extends Error {
readonly status: number;
readonly statusText: string;
Expand Down Expand Up @@ -559,6 +567,7 @@ describe('generateClient', () => {
})
).toMatchInlineSnapshot(`
"/* eslint-disable */
/* prettier-ignore-start */
export class HTTPError extends Error {
readonly status: number;
readonly statusText: string;
Expand Down Expand Up @@ -622,6 +631,7 @@ describe('generateClient', () => {
})
).toMatchInlineSnapshot(`
"/* eslint-disable */
/* prettier-ignore-start */
export class HTTPError extends Error {
readonly status: number;
readonly statusText: string;
Expand Down Expand Up @@ -686,6 +696,7 @@ describe('generateClient', () => {
})
).toMatchInlineSnapshot(`
"/* eslint-disable */
/* prettier-ignore-start */
export class HTTPError extends Error {
readonly status: number;
readonly statusText: string;
Expand Down Expand Up @@ -748,6 +759,7 @@ describe('generateClient', () => {
})
).toMatchInlineSnapshot(`
"/* eslint-disable */
/* prettier-ignore-start */
export class HTTPError extends Error {
readonly status: number;
readonly statusText: string;
Expand Down Expand Up @@ -820,6 +832,7 @@ describe('generateClient', () => {
})
).toMatchInlineSnapshot(`
"/* eslint-disable */
/* prettier-ignore-start */
export class HTTPError extends Error {
readonly status: number;
readonly statusText: string;
Expand Down
2 changes: 2 additions & 0 deletions packages/generator/tests/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ describe('generateFromObject', () => {

expect(result.types).toMatchInlineSnapshot(`
"/* eslint-disable */
/* prettier-ignore-start */
/**
* User
*/
Expand All @@ -138,6 +139,7 @@ describe('generateFromObject', () => {

expect(result.client).toMatchInlineSnapshot(`
"/* eslint-disable */
/* prettier-ignore-start */
import type { User } from './types';

export class HTTPError extends Error {
Expand Down
Loading