From 8540d8b00ecb8ec0e5b839d78a27b37996f023cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tommy=20Jos=C3=A9povic?= Date: Wed, 3 Jul 2024 13:35:09 -0400 Subject: [PATCH 01/11] [IDP-1766] Improve configuration --- packages/create-schemas/package.json | 11 +- packages/create-schemas/src/argsHelper.ts | 52 ----- packages/create-schemas/src/bin.ts | 50 ++--- packages/create-schemas/src/config.ts | 81 +++++++ packages/create-schemas/src/index.ts | 7 + .../src/openapiTypescriptHelper.ts | 30 ++- packages/create-schemas/tsup.build.ts | 2 +- packages/create-schemas/tsup.dev.ts | 2 +- pnpm-lock.yaml | 201 +++++++++++++++++- 9 files changed, 340 insertions(+), 96 deletions(-) delete mode 100644 packages/create-schemas/src/argsHelper.ts create mode 100644 packages/create-schemas/src/config.ts create mode 100644 packages/create-schemas/src/index.ts diff --git a/packages/create-schemas/package.json b/packages/create-schemas/package.json index 3d8fb14..e8695a0 100644 --- a/packages/create-schemas/package.json +++ b/packages/create-schemas/package.json @@ -15,6 +15,12 @@ }, "type": "module", "bin": "./dist/bin.js", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, "scripts": { "dev": "tsup --config tsup.dev.ts", "test": "vitest", @@ -35,9 +41,10 @@ "node": ">=18.0.0" }, "dependencies": { - "@types/yargs-parser": "21.0.3", + "c12": "1.11.1", + "commander": "12.1.0", "openapi-typescript": "7.0.0-rc.0", "typescript": "5.4.5", - "yargs-parser": "21.1.1" + "zod": "3.23.8" } } \ No newline at end of file diff --git a/packages/create-schemas/src/argsHelper.ts b/packages/create-schemas/src/argsHelper.ts deleted file mode 100644 index 6212769..0000000 --- a/packages/create-schemas/src/argsHelper.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { OpenAPITSOptions } from "openapi-typescript"; -import parser from "yargs-parser"; - -export function getOutputPath(args: string[]): string { - const flags = parser(args, { - string: ["output"], - alias: { - output: ["o"] - } - }); - - return (flags.output || "openapi-types.ts").trim(); -} - -export function getOpenApiTsOptionForArgs(args: string[]): OpenAPITSOptions { - const flags = parser(args, { - boolean: [ - "additionalProperties", - "alphabetize", - "arrayLength", - "defaultNonNullable", - "propertiesRequiredByDefault", - "emptyObjectsUnknown", - "enum", - "enumValues", - "excludeDeprecated", - "exportType", - "help", - "immutable", - "pathParamsAsTypes" - ], - alias: { - redocly: ["c"], - exportType: ["t"] - } - }); - - return { - additionalProperties: flags.additionalProperties, - alphabetize: flags.alphabetize, - arrayLength: flags.arrayLength, - propertiesRequiredByDefault: flags.propertiesRequiredByDefault, - defaultNonNullable: flags.defaultNonNullable, - emptyObjectsUnknown: flags.emptyObjectsUnknown, - enum: flags.enum, - enumValues: flags.enumValues, - excludeDeprecated: flags.excludeDeprecated, - exportType: flags.exportType, - immutable: flags.immutable, - pathParamsAsTypes: flags.pathParamsAsTypes - }; -} diff --git a/packages/create-schemas/src/bin.ts b/packages/create-schemas/src/bin.ts index 207faf6..01ee0d4 100644 --- a/packages/create-schemas/src/bin.ts +++ b/packages/create-schemas/src/bin.ts @@ -1,28 +1,28 @@ -import fs from "node:fs"; -import path from "path"; -import { getOpenApiTsOptionForArgs, getOutputPath } from "./argsHelper.ts"; +import { ZodError } from "zod"; +import { parseArgs, resolveConfig } from "./config.ts"; import { generateSchemas } from "./openapiTypescriptHelper.ts"; - -console.log("Received command: ", process.argv); - -// Access command-line arguments -const args = process.argv.slice(2); - -const openApiTsOptions = getOpenApiTsOptionForArgs(args); - -const openApiPath = args[0]; -const outputPath = getOutputPath(args); - -if (!openApiPath || !outputPath) { - throw new Error("Both openApiPath and outputPath must be provided"); +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +try { + // Access command-line arguments + const config = await resolveConfig(parseArgs()); + + const contents = await generateSchemas(config); + + // Write the content to a file + mkdirSync(dirname(config.output), { recursive: true }); + writeFileSync(config.output, contents); +} catch (error) { + if (error instanceof ZodError) { + printConfigurationErrors(error); + } } -console.log("Starting OpenAPI TypeScript types generation..."); -console.log(`\t-openApiPath: ${openApiPath}`); -console.log(`\t-outputPath: ${outputPath}`); - -const contents = await generateSchemas(openApiPath, outputPath, openApiTsOptions); - -// Write the content to a file -fs.mkdirSync(path.dirname(outputPath), { recursive: true }); -fs.writeFileSync(outputPath, contents); +function printConfigurationErrors(error: ZodError) { + console.log("Invalid configuration:"); + error.errors.forEach(issue => { + console.log(` - ${issue.path.join(".")}: ${issue.message}`); + }); + console.log("Use --help to see available options."); +} \ No newline at end of file diff --git a/packages/create-schemas/src/config.ts b/packages/create-schemas/src/config.ts new file mode 100644 index 0000000..bddc3ff --- /dev/null +++ b/packages/create-schemas/src/config.ts @@ -0,0 +1,81 @@ +import { Command } from "commander"; +import { loadConfig } from "c12"; +import * as z from "zod"; +import packageJson from "../package.json" with { type: "json" }; +import type { OpenAPITSOptions as OriginalOpenAPITSOptions } from "openapi-typescript"; + +const CONFIG_FILE_DEFAULT = "create-schemas.config"; +const OUTPUT_FILE_DEFAULT = "openapi-types.ts"; +const ROOT_DEFAULT = "."; + +type OpenApiTsOptions = Omit; + +export interface UserConfig { + root?: string; + input?: string; + output?: string; + openApiTsOptions?: OpenApiTsOptions; +} + +export interface InlineConfig extends UserConfig { + configFile?: string; +} + +const resolvedConfigSchema = z.object({ + configFile: z.string(), + root: z.string(), + input: z.string(), + output: z.string(), + openApiTsOptions: z.custom().optional().default({}) +}); + +export type ResolvedConfig = z.infer; + +export function parseArgs(argv?: string[]): InlineConfig { + const program = new Command(); + + program + .name("create-schemas") + .version(packageJson.version, "-v, --version", "display version number") + .argument("[input]") + .option("-c, --config ", "use specified config file", CONFIG_FILE_DEFAULT) + .option("-i, --input ", "path to the OpenAPI schema file") + .option("-o, --output ", "output file path", OUTPUT_FILE_DEFAULT) + .option("--cwd ", "path to working directory", ROOT_DEFAULT) + .helpOption("-h, --help", "display available CLI options") + .parse(argv); + + const opts = program.opts(); + const args = program.args; + + return { + configFile: opts.config, + root: opts.cwd, + input: opts.input || args[0], + output: opts.output + }; +} + +export async function resolveConfig(inlineConfig: InlineConfig = {}): Promise { + const { configFile = CONFIG_FILE_DEFAULT, root = ROOT_DEFAULT } = inlineConfig; + + const { config } = await loadConfig({ + configFile, + cwd: root, + omit$Keys: true, + defaultConfig: { + configFile: CONFIG_FILE_DEFAULT, + root: ROOT_DEFAULT, + output: OUTPUT_FILE_DEFAULT + }, + overrides: inlineConfig + }); + + const resolvedConfig = resolvedConfigSchema.parse(config); + + return resolvedConfig; +} + +export function defineConfig(config: UserConfig): UserConfig { + return config; +} \ No newline at end of file diff --git a/packages/create-schemas/src/index.ts b/packages/create-schemas/src/index.ts new file mode 100644 index 0000000..186a100 --- /dev/null +++ b/packages/create-schemas/src/index.ts @@ -0,0 +1,7 @@ +export { + defineConfig, + resolveConfig, + type UserConfig, + type InlineConfig, + type ResolvedConfig +} from "./config.ts"; diff --git a/packages/create-schemas/src/openapiTypescriptHelper.ts b/packages/create-schemas/src/openapiTypescriptHelper.ts index 93e9fe3..03f936b 100644 --- a/packages/create-schemas/src/openapiTypescriptHelper.ts +++ b/packages/create-schemas/src/openapiTypescriptHelper.ts @@ -1,11 +1,21 @@ -import openapiTS, { astToString, type OpenAPITSOptions } from "openapi-typescript"; -import { generateExportEndpointsTypeDeclaration, generateExportSchemaTypeDeclaration, getSchemaNames } from "./astHelper.ts"; - -export async function generateSchemas(openApiPath: string, outputPath: string, openApiTsOptions: OpenAPITSOptions): Promise { - const CWD = new URL(`file://${process.cwd()}/`); +import openapiTS, { astToString } from "openapi-typescript"; +import { + generateExportEndpointsTypeDeclaration, + generateExportSchemaTypeDeclaration, + getSchemaNames +} from "./astHelper.ts"; +import type { ResolvedConfig } from "./config.ts"; +import { pathToFileURL } from "url"; + +export async function generateSchemas(config: ResolvedConfig): Promise { + const base = pathToFileURL(config.root); // Create a TypeScript AST from the OpenAPI schema - const ast = await openapiTS(new URL(openApiPath, CWD), openApiTsOptions); + const ast = await openapiTS(new URL(config.input, base), { + ...config.openApiTsOptions, + silent: true, + cwd: config.root + }); // Find the node where all the DTOs are defined, and extract their names const schemaNames = getSchemaNames(ast); @@ -24,9 +34,13 @@ export async function generateSchemas(openApiPath: string, outputPath: string, o contents += `\n${generateExportEndpointsTypeDeclaration()}\n`; if (schemaNames.length === 0) { - console.warn(`⚠️ Suspiciously no schemas where found in the OpenAPI document at ${openApiPath}. It might due to a flag converting interface to type which is not supported at the moment. ⚠️`); + console.warn( + `⚠️ Suspiciously no schemas where found in the OpenAPI document at ${config.input}. It might due to a flag converting interface to type which is not supported at the moment. ⚠️` + ); } else { - console.log(`OpenAPI TypeScript types have been generated successfully at ${outputPath}! 🎉`); + console.log( + `OpenAPI TypeScript types have been generated successfully at ${config.output}! 🎉` + ); } return contents; diff --git a/packages/create-schemas/tsup.build.ts b/packages/create-schemas/tsup.build.ts index 8a2abcb..df14312 100644 --- a/packages/create-schemas/tsup.build.ts +++ b/packages/create-schemas/tsup.build.ts @@ -1,6 +1,6 @@ import { defineBuildConfig } from "@workleap/tsup-configs"; export default defineBuildConfig({ - entry: ["src/bin.ts"], + entry: ["src/bin.ts", "src/index.ts"], platform: "node" }); diff --git a/packages/create-schemas/tsup.dev.ts b/packages/create-schemas/tsup.dev.ts index af82b67..400f1ff 100644 --- a/packages/create-schemas/tsup.dev.ts +++ b/packages/create-schemas/tsup.dev.ts @@ -1,6 +1,6 @@ import { defineDevConfig } from "@workleap/tsup-configs"; export default defineDevConfig({ - entry: ["src/bin.ts"], + entry: ["src/bin.ts", "src/index.ts"], platform: "node" }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index addddfe..fc09acf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,18 +35,21 @@ importers: packages/create-schemas: dependencies: - '@types/yargs-parser': - specifier: 21.0.3 - version: 21.0.3 + c12: + specifier: 1.11.1 + version: 1.11.1 + commander: + specifier: 12.1.0 + version: 12.1.0 openapi-typescript: specifier: 7.0.0-rc.0 version: 7.0.0-rc.0(typescript@5.4.5) typescript: specifier: 5.4.5 version: 5.4.5 - yargs-parser: - specifier: 21.1.1 - version: 21.1.1 + zod: + specifier: 3.23.8 + version: 3.23.8 devDependencies: '@swc/helpers': specifier: 0.5.11 @@ -957,6 +960,14 @@ packages: peerDependencies: esbuild: '>=0.17' + c12@1.11.1: + resolution: {integrity: sha512-KDU0TvSvVdaYcQKQ6iPHATGz/7p/KiVjPg4vQrB6Jg/wX9R0yl5RZxWm9IoZqaIHD2+6PZd81+KMGwRr/lRIUg==} + peerDependencies: + magicast: ^0.3.4 + peerDependenciesMeta: + magicast: + optional: true + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -1015,6 +1026,10 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} @@ -1023,6 +1038,9 @@ packages: resolution: {integrity: sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==} engines: {node: '>=8'} + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -1039,6 +1057,10 @@ packages: colorette@1.4.0: resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1053,6 +1075,10 @@ packages: confbox@0.1.7: resolution: {integrity: sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==} + consola@3.2.3: + resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==} + engines: {node: ^14.18.0 || >=16.10.0} + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -1116,10 +1142,16 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destr@2.0.3: + resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -1155,6 +1187,10 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dotenv@16.4.5: + resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} + engines: {node: '>=12'} + dotenv@8.6.0: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} @@ -1466,6 +1502,10 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -1503,6 +1543,10 @@ packages: resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} engines: {node: '>= 0.4'} + giget@1.2.3: + resolution: {integrity: sha512-8EHPljDvs7qKykr6uw8b+lqLiUc/vUg+KVTI0uND4s63TdsZM2Xus3mflvF0DDG9SiM4RlCkFGL+7aAjRmV7KA==} + hasBin: true + git-hooks-list@1.0.3: resolution: {integrity: sha512-Y7wLWcrLUXwk2noSka166byGCvhMtDRpgHdzCno1UQv/n/Hegp++a2xBWJL1lJarnKD3SWaljD+0z1ztqxuKyQ==} @@ -1802,6 +1846,10 @@ packages: resolution: {integrity: sha512-eXIwN9gutMuB1AMW241gIHSEeaSMafWnxWXb/JGYWqifway4QgqBJLl7nYlmhGrxnHQ3wNc/QYFZ95aDtHHzpA==} engines: {node: '>=14'} + jiti@1.21.6: + resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} + hasBin: true + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -2101,10 +2149,27 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + mlly@1.7.1: resolution: {integrity: sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==} @@ -2129,6 +2194,9 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-fetch-native@1.6.4: + resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -2159,6 +2227,11 @@ packages: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + nypm@0.3.9: + resolution: {integrity: sha512-BI2SdqqTHg2d4wJh8P9A1W+bslg33vOE9IZDY6eR2QC+Pu1iNBVZUqczrd43rJb+fMzHU7ltAYKsEFY/kHMFcw==} + engines: {node: ^14.16.0 || >=16.10.0} + hasBin: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2194,6 +2267,9 @@ packages: resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} engines: {node: '>= 0.4'} + ohash@1.1.3: + resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2312,6 +2388,9 @@ packages: pathval@1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + picocolors@1.0.1: resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} @@ -2392,6 +2471,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -2656,6 +2738,10 @@ packages: resolution: {integrity: sha512-7RnqIMq572L8PeEzKeBINYEJDDxpcH8JEgLwUqBd3TkofhFRbkq4QLR0u+36avGAhCRbk2nnmjcW9SE531hPDg==} engines: {node: ^14.18.0 || >=16.0.0} + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -3011,6 +3097,9 @@ packages: yallist@2.1.2: resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml-ast-parser@0.0.43: resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} @@ -3039,6 +3128,9 @@ packages: resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} engines: {node: '>=12.20'} + zod@3.23.8: + resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -3662,7 +3754,8 @@ snapshots: '@types/unist@3.0.2': {} - '@types/yargs-parser@21.0.3': {} + '@types/yargs-parser@21.0.3': + optional: true '@types/yargs@16.0.9': dependencies: @@ -4036,6 +4129,21 @@ snapshots: esbuild: 0.21.4 load-tsconfig: 0.2.5 + c12@1.11.1: + dependencies: + chokidar: 3.6.0 + confbox: 0.1.7 + defu: 6.1.4 + dotenv: 16.4.5 + giget: 1.2.3 + jiti: 1.21.6 + mlly: 1.7.1 + ohash: 1.1.3 + pathe: 1.1.2 + perfect-debounce: 1.0.0 + pkg-types: 1.1.3 + rc9: 2.1.2 + cac@6.7.14: {} call-bind@1.0.7: @@ -4103,10 +4211,16 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chownr@2.0.0: {} + ci-info@3.9.0: {} ci-info@4.0.0: {} + citty@0.1.6: + dependencies: + consola: 3.2.3 + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -4121,6 +4235,8 @@ snapshots: colorette@1.4.0: {} + commander@12.1.0: {} + commander@4.1.1: {} concat-map@0.0.1: {} @@ -4134,6 +4250,8 @@ snapshots: confbox@0.1.7: {} + consola@3.2.3: {} + create-require@1.1.1: optional: true @@ -4201,8 +4319,12 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.4: {} + dequal@2.0.3: {} + destr@2.0.3: {} + detect-indent@6.1.0: {} detect-newline@3.1.0: {} @@ -4230,6 +4352,8 @@ snapshots: dependencies: esutils: 2.0.3 + dotenv@16.4.5: {} + dotenv@8.6.0: {} eastasianwidth@0.2.0: {} @@ -4749,6 +4873,10 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -4785,6 +4913,17 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.2.4 + giget@1.2.3: + dependencies: + citty: 0.1.6 + consola: 3.2.3 + defu: 6.1.4 + node-fetch-native: 1.6.4 + nypm: 0.3.9 + ohash: 1.1.3 + pathe: 1.1.2 + tar: 6.2.1 + git-hooks-list@1.0.3: {} glob-parent@5.1.2: @@ -5065,6 +5204,8 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jiti@1.21.6: {} + joycon@3.1.1: {} js-levenshtein@1.1.6: {} @@ -5542,8 +5683,21 @@ snapshots: minimist@1.2.8: {} + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + minipass@7.1.2: {} + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp@1.0.4: {} + mlly@1.7.1: dependencies: acorn: 8.11.3 @@ -5567,6 +5721,8 @@ snapshots: natural-compare@1.4.0: {} + node-fetch-native@1.6.4: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -5587,6 +5743,15 @@ snapshots: dependencies: path-key: 4.0.0 + nypm@0.3.9: + dependencies: + citty: 0.1.6 + consola: 3.2.3 + execa: 8.0.1 + pathe: 1.1.2 + pkg-types: 1.1.3 + ufo: 1.5.3 + object-assign@4.1.1: {} object-inspect@1.13.1: {} @@ -5631,6 +5796,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 + ohash@1.1.3: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -5763,6 +5930,8 @@ snapshots: pathval@1.1.1: {} + perfect-debounce@1.0.0: {} + picocolors@1.0.1: {} picomatch@2.3.1: {} @@ -5830,6 +5999,11 @@ snapshots: queue-microtask@1.2.3: {} + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.3 + react-is@16.13.1: {} react-is@18.3.1: {} @@ -6153,6 +6327,15 @@ snapshots: '@pkgr/core': 0.1.1 tslib: 2.6.2 + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + term-size@2.2.1: {} text-table@0.2.0: {} @@ -6593,6 +6776,8 @@ snapshots: yallist@2.1.2: {} + yallist@4.0.0: {} + yaml-ast-parser@0.0.43: {} yaml-eslint-parser@1.2.3: @@ -6612,4 +6797,6 @@ snapshots: yocto-queue@1.1.1: {} + zod@3.23.8: {} + zwitch@2.0.4: {} From 14bdf5f59b6a398441d9654249356b59f909a9d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tommy=20Jos=C3=A9povic?= Date: Wed, 3 Jul 2024 15:35:55 -0400 Subject: [PATCH 02/11] throw error if not zod error --- packages/create-schemas/src/bin.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/create-schemas/src/bin.ts b/packages/create-schemas/src/bin.ts index 01ee0d4..8a24e55 100644 --- a/packages/create-schemas/src/bin.ts +++ b/packages/create-schemas/src/bin.ts @@ -16,6 +16,8 @@ try { } catch (error) { if (error instanceof ZodError) { printConfigurationErrors(error); + } else { + throw error; } } From 13c8abffb098c4cca7e96d0e7323e8ef76369833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tommy=20Jos=C3=A9povic?= Date: Thu, 4 Jul 2024 09:16:24 -0400 Subject: [PATCH 03/11] remove default from cli args --- packages/create-schemas/src/config.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/create-schemas/src/config.ts b/packages/create-schemas/src/config.ts index bddc3ff..ce917e5 100644 --- a/packages/create-schemas/src/config.ts +++ b/packages/create-schemas/src/config.ts @@ -6,7 +6,7 @@ import type { OpenAPITSOptions as OriginalOpenAPITSOptions } from "openapi-types const CONFIG_FILE_DEFAULT = "create-schemas.config"; const OUTPUT_FILE_DEFAULT = "openapi-types.ts"; -const ROOT_DEFAULT = "."; +const ROOT_DEFAULT = process.cwd(); type OpenApiTsOptions = Omit; @@ -38,10 +38,10 @@ export function parseArgs(argv?: string[]): InlineConfig { .name("create-schemas") .version(packageJson.version, "-v, --version", "display version number") .argument("[input]") - .option("-c, --config ", "use specified config file", CONFIG_FILE_DEFAULT) + .option("-c, --config ", "use specified config file") .option("-i, --input ", "path to the OpenAPI schema file") - .option("-o, --output ", "output file path", OUTPUT_FILE_DEFAULT) - .option("--cwd ", "path to working directory", ROOT_DEFAULT) + .option("-o, --output ", "output file path") + .option("--cwd ", "path to working directory") .helpOption("-h, --help", "display available CLI options") .parse(argv); From 695e64896cdfe0e140352fc3cf112250cffc2629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tommy=20Jos=C3=A9povic?= Date: Thu, 4 Jul 2024 11:50:43 -0400 Subject: [PATCH 04/11] Add test and changeset --- .changeset/old-impalas-tie.md | 23 +++++ packages/create-schemas/tests/config.test.ts | 92 ++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 .changeset/old-impalas-tie.md create mode 100644 packages/create-schemas/tests/config.test.ts diff --git a/.changeset/old-impalas-tie.md b/.changeset/old-impalas-tie.md new file mode 100644 index 0000000..941cb70 --- /dev/null +++ b/.changeset/old-impalas-tie.md @@ -0,0 +1,23 @@ +--- +"@workleap/create-schemas": minor +--- + +[BREAKING] Major overhaul to configurations. + +The following command line arguments are no longer supported: + +- `--additionalProperties` +- `--alphabetize` +- `--arrayLength` +- `--defaultNonNullable` +- `--propertiesRequiredByDefault` +- `--emptyObjectsUnknown` +- `--enum` +- `--enumValues` +- `--excludeDeprecated` +- `--exportType` +- `--help` +- `--immutable` +- `--pathParamsAsTypes` + +These options now need to be declared in the `create-schemas.config.ts` file using the `openApiTsOptions` property. \ No newline at end of file diff --git a/packages/create-schemas/tests/config.test.ts b/packages/create-schemas/tests/config.test.ts new file mode 100644 index 0000000..3742c5d --- /dev/null +++ b/packages/create-schemas/tests/config.test.ts @@ -0,0 +1,92 @@ +import { writeFile } from "node:fs/promises"; +import { createTemporaryFolder, dataFolder } from "./fixtures.ts"; +import { describe, test } from "vitest"; +import { join } from "node:path"; +import { parseArgs, resolveConfig, type UserConfig } from "../src/config.ts"; +import { ZodError } from "zod"; + +describe.concurrent("config", () => { + test("parseArgs", ({ expect }) => { + const config = parseArgs([ + "", + "", + "input", + "-o", + "output", + "-c", + "config", + "--cwd", + "cwd", + ]); + expect(config.input).toMatch("input"); + expect(config.output).toMatch("output"); + expect(config.configFile).toMatch("config"); + expect(config.root).toMatch("cwd"); + }); + + describe.concurrent("resolveConfig", () => { + test("inline config only", async ({ expect, onTestFinished }) => { + const tempFolder = await createTemporaryFolder({ onTestFinished }); + + const config = await resolveConfig({ + root: tempFolder, + input: "input", + }); + + expect(config.input).toMatch("input"); + }); + + test("config file only", async ({ expect, onTestFinished }) => { + const tempFolder = await createTemporaryFolder({ onTestFinished }); + + const configFilePath = join(tempFolder, "create-schemas.config.ts"); + const input = join(dataFolder, "petstore.json"); + const output = join(tempFolder, "output.ts"); + + const configFileContent = `export default ${JSON.stringify({ + input, + output, + } satisfies UserConfig)};`; + + await writeFile(configFilePath, configFileContent); + + const config = await resolveConfig({ root: tempFolder }); + + expect(config.input).toMatch(input); + expect(config.output).toMatch(output); + }); + + test("throw on invalid config", async ({ expect, onTestFinished }) => { + const tempFolder = await createTemporaryFolder({ onTestFinished }); + + expect(() => + // Missing required input + resolveConfig({ root: tempFolder }) + ).rejects.toThrowError(ZodError); + }); + + test("inline config takes precedence over config file", async ({ + expect, + onTestFinished, + }) => { + const tempFolder = await createTemporaryFolder({ onTestFinished }); + + const configFilePath = join(tempFolder, "create-schemas.config.ts"); + + const configFileContent = `export default ${JSON.stringify({ + input: "config-file-input", + output: "config-file-output", + } satisfies UserConfig)};`; + + await writeFile(configFilePath, configFileContent); + + const config = await resolveConfig({ + root: tempFolder, + input: "inline-input", + }); + + expect(config.input).toMatch("inline-input"); + expect(config.output).toMatch("config-file-output"); + }); + }); +}); From 88d8c48798ff2796df27d307cfaee9eb1bc4839d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tommy=20Jos=C3=A9povic?= Date: Thu, 4 Jul 2024 11:52:29 -0400 Subject: [PATCH 05/11] lint --- packages/create-schemas/tests/config.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/create-schemas/tests/config.test.ts b/packages/create-schemas/tests/config.test.ts index 3742c5d..53164c6 100644 --- a/packages/create-schemas/tests/config.test.ts +++ b/packages/create-schemas/tests/config.test.ts @@ -16,7 +16,7 @@ describe.concurrent("config", () => { "-c", "config", "--cwd", - "cwd", + "cwd" ]); expect(config.input).toMatch("input"); expect(config.output).toMatch("output"); @@ -30,7 +30,7 @@ describe.concurrent("config", () => { const config = await resolveConfig({ root: tempFolder, - input: "input", + input: "input" }); expect(config.input).toMatch("input"); @@ -45,7 +45,7 @@ describe.concurrent("config", () => { const configFileContent = `export default ${JSON.stringify({ input, - output, + output } satisfies UserConfig)};`; await writeFile(configFilePath, configFileContent); @@ -67,7 +67,7 @@ describe.concurrent("config", () => { test("inline config takes precedence over config file", async ({ expect, - onTestFinished, + onTestFinished }) => { const tempFolder = await createTemporaryFolder({ onTestFinished }); @@ -75,14 +75,14 @@ describe.concurrent("config", () => { const configFileContent = `export default ${JSON.stringify({ input: "config-file-input", - output: "config-file-output", + output: "config-file-output" } satisfies UserConfig)};`; await writeFile(configFilePath, configFileContent); const config = await resolveConfig({ root: tempFolder, - input: "inline-input", + input: "inline-input" }); expect(config.input).toMatch("inline-input"); From 03bf958c85b7fabe14572fe1e8dc62277b90063a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tommy=20Jos=C3=A9povic?= Date: Thu, 4 Jul 2024 13:21:41 -0400 Subject: [PATCH 06/11] remove rc support --- packages/create-schemas/src/config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/create-schemas/src/config.ts b/packages/create-schemas/src/config.ts index ce917e5..b6ab1b3 100644 --- a/packages/create-schemas/src/config.ts +++ b/packages/create-schemas/src/config.ts @@ -62,6 +62,7 @@ export async function resolveConfig(inlineConfig: InlineConfig = {}): Promise({ configFile, cwd: root, + rcFile: false, omit$Keys: true, defaultConfig: { configFile: CONFIG_FILE_DEFAULT, From c8805641f197f7b4123d2c2cfdd2b172602b54a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tommy=20Jos=C3=A9povic?= Date: Thu, 4 Jul 2024 14:22:21 -0400 Subject: [PATCH 07/11] fix config path resolution --- packages/create-schemas/src/openapiTypescriptHelper.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/create-schemas/src/openapiTypescriptHelper.ts b/packages/create-schemas/src/openapiTypescriptHelper.ts index 03f936b..00a78b1 100644 --- a/packages/create-schemas/src/openapiTypescriptHelper.ts +++ b/packages/create-schemas/src/openapiTypescriptHelper.ts @@ -6,12 +6,15 @@ import { } from "./astHelper.ts"; import type { ResolvedConfig } from "./config.ts"; import { pathToFileURL } from "url"; +import { join } from "path"; export async function generateSchemas(config: ResolvedConfig): Promise { - const base = pathToFileURL(config.root); + const url = URL.canParse(config.input) + ? config.input + : pathToFileURL(join(config.root, config.input)); // Create a TypeScript AST from the OpenAPI schema - const ast = await openapiTS(new URL(config.input, base), { + const ast = await openapiTS(url, { ...config.openApiTsOptions, silent: true, cwd: config.root From 2a442e9f4cf28fab3b42e637b95a31d2610ecf0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tommy=20Jos=C3=A9povic?= Date: Fri, 5 Jul 2024 14:34:33 -0400 Subject: [PATCH 08/11] fix file path / URL issues --- packages/create-schemas/src/bin.ts | 9 +- packages/create-schemas/src/config.ts | 9 +- .../src/openapiTypescriptHelper.ts | 11 +- packages/create-schemas/src/utils.ts | 69 + .../tests/__snapshots__/e2e.test.ts.snap | 1180 ++++++++++++++++- packages/create-schemas/tests/config.test.ts | 17 +- packages/create-schemas/tests/e2e.test.ts | 67 +- packages/create-schemas/tests/fixtures.ts | 7 +- packages/create-schemas/tests/utils.test.ts | 128 ++ 9 files changed, 1472 insertions(+), 25 deletions(-) create mode 100644 packages/create-schemas/src/utils.ts create mode 100644 packages/create-schemas/tests/utils.test.ts diff --git a/packages/create-schemas/src/bin.ts b/packages/create-schemas/src/bin.ts index 8a24e55..7b21404 100644 --- a/packages/create-schemas/src/bin.ts +++ b/packages/create-schemas/src/bin.ts @@ -3,6 +3,7 @@ import { parseArgs, resolveConfig } from "./config.ts"; import { generateSchemas } from "./openapiTypescriptHelper.ts"; import { mkdirSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; try { // Access command-line arguments @@ -11,8 +12,8 @@ try { const contents = await generateSchemas(config); // Write the content to a file - mkdirSync(dirname(config.output), { recursive: true }); - writeFileSync(config.output, contents); + mkdirSync(dirname(fileURLToPath(config.output)), { recursive: true }); + writeFileSync(fileURLToPath(config.output), contents); } catch (error) { if (error instanceof ZodError) { printConfigurationErrors(error); @@ -23,8 +24,8 @@ try { function printConfigurationErrors(error: ZodError) { console.log("Invalid configuration:"); - error.errors.forEach(issue => { + error.errors.forEach((issue) => { console.log(` - ${issue.path.join(".")}: ${issue.message}`); }); console.log("Use --help to see available options."); -} \ No newline at end of file +} diff --git a/packages/create-schemas/src/config.ts b/packages/create-schemas/src/config.ts index b6ab1b3..cf93c9f 100644 --- a/packages/create-schemas/src/config.ts +++ b/packages/create-schemas/src/config.ts @@ -3,12 +3,13 @@ import { loadConfig } from "c12"; import * as z from "zod"; import packageJson from "../package.json" with { type: "json" }; import type { OpenAPITSOptions as OriginalOpenAPITSOptions } from "openapi-typescript"; +import { toFullyQualifiedURL } from "./utils.ts"; const CONFIG_FILE_DEFAULT = "create-schemas.config"; const OUTPUT_FILE_DEFAULT = "openapi-types.ts"; const ROOT_DEFAULT = process.cwd(); -type OpenApiTsOptions = Omit; +type OpenApiTsOptions = Omit; export interface UserConfig { root?: string; @@ -26,6 +27,7 @@ const resolvedConfigSchema = z.object({ root: z.string(), input: z.string(), output: z.string(), + watch: z.boolean().optional().default(false), openApiTsOptions: z.custom().optional().default({}) }); @@ -41,6 +43,7 @@ export function parseArgs(argv?: string[]): InlineConfig { .option("-c, --config ", "use specified config file") .option("-i, --input ", "path to the OpenAPI schema file") .option("-o, --output ", "output file path") + .option("--watch", "watch for changes") .option("--cwd ", "path to working directory") .helpOption("-h, --help", "display available CLI options") .parse(argv); @@ -74,6 +77,10 @@ export async function resolveConfig(inlineConfig: InlineConfig = {}): Promise { - const url = URL.canParse(config.input) - ? config.input - : pathToFileURL(join(config.root, config.input)); - // Create a TypeScript AST from the OpenAPI schema - const ast = await openapiTS(url, { + const ast = await openapiTS(new URL(config.input), { ...config.openApiTsOptions, silent: true, - cwd: config.root }); // Find the node where all the DTOs are defined, and extract their names diff --git a/packages/create-schemas/src/utils.ts b/packages/create-schemas/src/utils.ts new file mode 100644 index 0000000..a8ad23e --- /dev/null +++ b/packages/create-schemas/src/utils.ts @@ -0,0 +1,69 @@ +import { isAbsolute, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +function isFileURLAsText(input: string | URL): input is `file://${string}` { + return typeof input === "string" && input.startsWith("file://"); +} + +function isHttpURLAsText( + input: string +): input is `http://${string}` | `https://${string}` { + if (!URL.canParse(input)) { + return false; + } + + const url = new URL(input); + + return url.protocol === "http:" || url.protocol === "https:"; +} + +/** + * Converts a path into a fully qualified path URL. + * + * - `foo` -> `file:///path/to/cwd/foo` + * - `https://example.com/foo` -> `https://example.com/foo` + * + * This format is works for both file path and HTTP URLs, but the result needs + * to be converted to a `URL` instance or into a file path depending on the + * usage. + */ +export function toFullyQualifiedURL( + input: string | URL, + root: string | URL = process.cwd() +): string { + if (input instanceof URL) { + return input.toString(); + } + + if (isHttpURLAsText(input)) { + return input; + } + + if (isFileURLAsText(input)) { + return input; + } + + if (isAbsolute(input)) { + return pathToFileURL(input).toString(); + } + + if (root instanceof URL || isHttpURLAsText(root)) { + const rootAsURL = new URL(root); + + const pathname = join(rootAsURL.pathname, input); + + return new URL(pathname, root).toString(); + } + + if (isFileURLAsText(root)) { + const rootAsPath = fileURLToPath(root); + + return pathToFileURL(join(rootAsPath, input)).toString(); + } + + if (isAbsolute(root)) { + return pathToFileURL(join(root, input)).toString(); + } + + return pathToFileURL(join(process.cwd(), root, input)).toString(); +} diff --git a/packages/create-schemas/tests/__snapshots__/e2e.test.ts.snap b/packages/create-schemas/tests/__snapshots__/e2e.test.ts.snap index c8ffcf9..a0379da 100644 --- a/packages/create-schemas/tests/__snapshots__/e2e.test.ts.snap +++ b/packages/create-schemas/tests/__snapshots__/e2e.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`e2e > officevice.yaml 1`] = ` +exports[`e2e > officevice.yaml / file URLs 1`] = ` "export interface paths { "/good-vibes-points/{userId}": { parameters: { @@ -84,6 +84,1184 @@ export type Endpoints = keyof paths; " `; +exports[`e2e > officevice.yaml / file paths 1`] = ` +"export interface paths { + "/good-vibes-points/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the current number of good vibe for a user */ + get: operations["GetGoodVibesPoint"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + GetGoodVibePointsResult: { + /** Format: int32 */ + point: number; + }; + ProblemDetails: { + type?: string | null; + title?: string | null; + /** Format: int32 */ + status?: number | null; + detail?: string | null; + instance?: string | null; + [key: string]: unknown; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + GetGoodVibesPoint: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetGoodVibePointsResult"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; +} +export type GetGoodVibePointsResult = components["schemas"]["GetGoodVibePointsResult"]; +export type ProblemDetails = components["schemas"]["ProblemDetails"]; + +export type Endpoints = keyof paths; +" +`; + +exports[`e2e > officevice.yaml / relative path 1`] = ` +"export interface paths { + "/good-vibes-points/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the current number of good vibe for a user */ + get: operations["GetGoodVibesPoint"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + GetGoodVibePointsResult: { + /** Format: int32 */ + point: number; + }; + ProblemDetails: { + type?: string | null; + title?: string | null; + /** Format: int32 */ + status?: number | null; + detail?: string | null; + instance?: string | null; + [key: string]: unknown; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + GetGoodVibesPoint: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetGoodVibePointsResult"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; +} +export type GetGoodVibePointsResult = components["schemas"]["GetGoodVibePointsResult"]; +export type ProblemDetails = components["schemas"]["ProblemDetails"]; + +export type Endpoints = keyof paths; +" +`; + +exports[`e2e > petstore.json / remote URL 1`] = ` +"export interface paths { + "/pet": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Update an existing pet + * @description Update an existing pet by Id + */ + put: operations["updatePet"]; + /** + * Add a new pet to the store + * @description Add a new pet to the store + */ + post: operations["addPet"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/pet/findByStatus": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Finds Pets by status + * @description Multiple status values can be provided with comma separated strings + */ + get: operations["findPetsByStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/pet/findByTags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Finds Pets by tags + * @description Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. + */ + get: operations["findPetsByTags"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/pet/{petId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Find pet by ID + * @description Returns a single pet + */ + get: operations["getPetById"]; + put?: never; + /** Updates a pet in the store with form data */ + post: operations["updatePetWithForm"]; + /** Deletes a pet */ + delete: operations["deletePet"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/pet/{petId}/uploadImage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** uploads an image */ + post: operations["uploadFile"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/store/inventory": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Returns pet inventories by status + * @description Returns a map of status codes to quantities + */ + get: operations["getInventory"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/store/order": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Place an order for a pet + * @description Place a new order in the store + */ + post: operations["placeOrder"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/store/order/{orderId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Find purchase order by ID + * @description For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions. + */ + get: operations["getOrderById"]; + put?: never; + post?: never; + /** + * Delete purchase order by ID + * @description For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors + */ + delete: operations["deleteOrder"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/user": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create user + * @description This can only be done by the logged in user. + */ + post: operations["createUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/user/createWithList": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Creates list of users with given input array + * @description Creates list of users with given input array + */ + post: operations["createUsersWithListInput"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/user/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Logs user into the system */ + get: operations["loginUser"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/user/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Logs out current logged in user session */ + get: operations["logoutUser"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/user/{username}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get user by user name */ + get: operations["getUserByName"]; + /** + * Update user + * @description This can only be done by the logged in user. + */ + put: operations["updateUser"]; + post?: never; + /** + * Delete user + * @description This can only be done by the logged in user. + */ + delete: operations["deleteUser"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Order: { + /** + * Format: int64 + * @example 10 + */ + id?: number; + /** + * Format: int64 + * @example 198772 + */ + petId?: number; + /** + * Format: int32 + * @example 7 + */ + quantity?: number; + /** Format: date-time */ + shipDate?: string; + /** + * @description Order Status + * @example approved + * @enum {string} + */ + status?: "placed" | "approved" | "delivered"; + complete?: boolean; + }; + Customer: { + /** + * Format: int64 + * @example 100000 + */ + id?: number; + /** @example fehguy */ + username?: string; + address?: components["schemas"]["Address"][]; + }; + Address: { + /** @example 437 Lytton */ + street?: string; + /** @example Palo Alto */ + city?: string; + /** @example CA */ + state?: string; + /** @example 94301 */ + zip?: string; + }; + Category: { + /** + * Format: int64 + * @example 1 + */ + id?: number; + /** @example Dogs */ + name?: string; + }; + User: { + /** + * Format: int64 + * @example 10 + */ + id?: number; + /** @example theUser */ + username?: string; + /** @example John */ + firstName?: string; + /** @example James */ + lastName?: string; + /** @example john@email.com */ + email?: string; + /** @example 12345 */ + password?: string; + /** @example 12345 */ + phone?: string; + /** + * Format: int32 + * @description User Status + * @example 1 + */ + userStatus?: number; + }; + Tag: { + /** Format: int64 */ + id?: number; + name?: string; + }; + Pet: { + /** + * Format: int64 + * @example 10 + */ + id?: number; + /** @example doggie */ + name: string; + category?: components["schemas"]["Category"]; + photoUrls: string[]; + tags?: components["schemas"]["Tag"][]; + /** + * @description pet status in the store + * @enum {string} + */ + status?: "available" | "pending" | "sold"; + }; + ApiResponse: { + /** Format: int32 */ + code?: number; + type?: string; + message?: string; + }; + }; + responses: never; + parameters: never; + requestBodies: { + /** @description Pet object that needs to be added to the store */ + Pet: { + content: { + "application/json": components["schemas"]["Pet"]; + "application/xml": components["schemas"]["Pet"]; + }; + }; + /** @description List of user object */ + UserArray: { + content: { + "application/json": components["schemas"]["User"][]; + }; + }; + }; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + updatePet: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Update an existent pet in the store */ + requestBody: { + content: { + "application/json": components["schemas"]["Pet"]; + "application/xml": components["schemas"]["Pet"]; + "application/x-www-form-urlencoded": components["schemas"]["Pet"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/xml": components["schemas"]["Pet"]; + "application/json": components["schemas"]["Pet"]; + }; + }; + /** @description Invalid ID supplied */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Pet not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation exception */ + 405: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + addPet: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Create a new pet in the store */ + requestBody: { + content: { + "application/json": components["schemas"]["Pet"]; + "application/xml": components["schemas"]["Pet"]; + "application/x-www-form-urlencoded": components["schemas"]["Pet"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/xml": components["schemas"]["Pet"]; + "application/json": components["schemas"]["Pet"]; + }; + }; + /** @description Invalid input */ + 405: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + findPetsByStatus: { + parameters: { + query?: { + /** @description Status values that need to be considered for filter */ + status?: "available" | "pending" | "sold"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/xml": components["schemas"]["Pet"][]; + "application/json": components["schemas"]["Pet"][]; + }; + }; + /** @description Invalid status value */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + findPetsByTags: { + parameters: { + query?: { + /** @description Tags to filter by */ + tags?: string[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/xml": components["schemas"]["Pet"][]; + "application/json": components["schemas"]["Pet"][]; + }; + }; + /** @description Invalid tag value */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getPetById: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of pet to return */ + petId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/xml": components["schemas"]["Pet"]; + "application/json": components["schemas"]["Pet"]; + }; + }; + /** @description Invalid ID supplied */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Pet not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updatePetWithForm: { + parameters: { + query?: { + /** @description Name of pet that needs to be updated */ + name?: string; + /** @description Status of pet that needs to be updated */ + status?: string; + }; + header?: never; + path: { + /** @description ID of pet that needs to be updated */ + petId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invalid input */ + 405: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + deletePet: { + parameters: { + query?: never; + header?: { + api_key?: string; + }; + path: { + /** @description Pet id to delete */ + petId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invalid pet value */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + uploadFile: { + parameters: { + query?: { + /** @description Additional Metadata */ + additionalMetadata?: string; + }; + header?: never; + path: { + /** @description ID of pet to update */ + petId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/octet-stream": string; + }; + }; + responses: { + /** @description successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponse"]; + }; + }; + }; + }; + getInventory: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: number | undefined; + }; + }; + }; + }; + }; + placeOrder: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["Order"]; + "application/xml": components["schemas"]["Order"]; + "application/x-www-form-urlencoded": components["schemas"]["Order"]; + }; + }; + responses: { + /** @description successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Order"]; + }; + }; + /** @description Invalid input */ + 405: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getOrderById: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of order that needs to be fetched */ + orderId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/xml": components["schemas"]["Order"]; + "application/json": components["schemas"]["Order"]; + }; + }; + /** @description Invalid ID supplied */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Order not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + deleteOrder: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the order that needs to be deleted */ + orderId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invalid ID supplied */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Order not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + createUser: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Created user object */ + requestBody?: { + content: { + "application/json": components["schemas"]["User"]; + "application/xml": components["schemas"]["User"]; + "application/x-www-form-urlencoded": components["schemas"]["User"]; + }; + }; + responses: { + /** @description successful operation */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + "application/xml": components["schemas"]["User"]; + }; + }; + }; + }; + createUsersWithListInput: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["User"][]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/xml": components["schemas"]["User"]; + "application/json": components["schemas"]["User"]; + }; + }; + /** @description successful operation */ + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + loginUser: { + parameters: { + query?: { + /** @description The user name for login */ + username?: string; + /** @description The password for login in clear text */ + password?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description successful operation */ + 200: { + headers: { + /** @description calls per hour allowed by the user */ + "X-Rate-Limit"?: number; + /** @description date in UTC when token expires */ + "X-Expires-After"?: string; + [name: string]: unknown; + }; + content: { + "application/xml": string; + "application/json": string; + }; + }; + /** @description Invalid username/password supplied */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + logoutUser: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description successful operation */ + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getUserByName: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The name that needs to be fetched. Use user1 for testing. */ + username: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description successful operation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/xml": components["schemas"]["User"]; + "application/json": components["schemas"]["User"]; + }; + }; + /** @description Invalid username supplied */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateUser: { + parameters: { + query?: never; + header?: never; + path: { + /** @description name that needs to be updated */ + username: string; + }; + cookie?: never; + }; + /** @description Update an existent user in the store */ + requestBody?: { + content: { + "application/json": components["schemas"]["User"]; + "application/xml": components["schemas"]["User"]; + "application/x-www-form-urlencoded": components["schemas"]["User"]; + }; + }; + responses: { + /** @description successful operation */ + default: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + deleteUser: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The name that needs to be deleted */ + username: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invalid username supplied */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; +} +export type Order = components["schemas"]["Order"]; +export type Customer = components["schemas"]["Customer"]; +export type Address = components["schemas"]["Address"]; +export type Category = components["schemas"]["Category"]; +export type User = components["schemas"]["User"]; +export type Tag = components["schemas"]["Tag"]; +export type Pet = components["schemas"]["Pet"]; +export type ApiResponse = components["schemas"]["ApiResponse"]; + +export type Endpoints = keyof paths; +" +`; + exports[`e2e > petstore.json 1`] = ` "export interface paths { "/pets": { diff --git a/packages/create-schemas/tests/config.test.ts b/packages/create-schemas/tests/config.test.ts index 53164c6..5eb7b7d 100644 --- a/packages/create-schemas/tests/config.test.ts +++ b/packages/create-schemas/tests/config.test.ts @@ -4,6 +4,7 @@ import { describe, test } from "vitest"; import { join } from "node:path"; import { parseArgs, resolveConfig, type UserConfig } from "../src/config.ts"; import { ZodError } from "zod"; +import { pathToFileURL } from "node:url"; describe.concurrent("config", () => { test("parseArgs", ({ expect }) => { @@ -33,7 +34,9 @@ describe.concurrent("config", () => { input: "input" }); - expect(config.input).toMatch("input"); + expect(config.input).toBe( + pathToFileURL(join(tempFolder, "input")).toString() + ); }); test("config file only", async ({ expect, onTestFinished }) => { @@ -52,8 +55,8 @@ describe.concurrent("config", () => { const config = await resolveConfig({ root: tempFolder }); - expect(config.input).toMatch(input); - expect(config.output).toMatch(output); + expect(config.input).toBe(pathToFileURL(input).toString()); + expect(config.output).toBe(pathToFileURL(output).toString()); }); test("throw on invalid config", async ({ expect, onTestFinished }) => { @@ -85,8 +88,12 @@ describe.concurrent("config", () => { input: "inline-input" }); - expect(config.input).toMatch("inline-input"); - expect(config.output).toMatch("config-file-output"); + expect(config.input).toBe( + pathToFileURL(join(tempFolder, "inline-input")).toString() + ); + expect(config.output).toBe( + pathToFileURL(join(tempFolder, "config-file-output")).toString() + ); }); }); }); diff --git a/packages/create-schemas/tests/e2e.test.ts b/packages/create-schemas/tests/e2e.test.ts index 8a16441..b72b489 100644 --- a/packages/create-schemas/tests/e2e.test.ts +++ b/packages/create-schemas/tests/e2e.test.ts @@ -2,18 +2,21 @@ import { readFile } from "node:fs/promises"; import { createTemporaryFolder, dataFolder, - runCompiledBin + runCompiledBin, } from "./fixtures.ts"; import { describe, test } from "vitest"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; const timeout = 30 * 1000; // 30 seconds describe.concurrent("e2e", () => { test( - "officevice.yaml", + "officevice.yaml / file paths", async ({ expect, onTestFinished }) => { - const tempFolder = await createTemporaryFolder({ onTestFinished }); + const tempFolder = await createTemporaryFolder({ + onTestFinished, + }); const source = join(dataFolder, "officevice.yaml"); const output = join(tempFolder, "output.ts"); @@ -27,6 +30,47 @@ describe.concurrent("e2e", () => { timeout ); + test( + "officevice.yaml / file URLs", + async ({ expect, onTestFinished }) => { + const tempFolder = await createTemporaryFolder({ + onTestFinished, + }); + + const source = pathToFileURL(join(dataFolder, "officevice.yaml")); + const output = pathToFileURL(join(tempFolder, "output.ts")); + + await runCompiledBin({ + source: source.toString(), + output: output.toString(), + }); + + const result = await readFile(output, "utf-8"); + + expect(result).toMatchSnapshot(); + }, + timeout + ); + + test( + "officevice.yaml / relative path", + async ({ expect, onTestFinished }) => { + const tempFolder = await createTemporaryFolder({ + onTestFinished, + }); + + const source = "officevice.yaml"; + const output = join(tempFolder, "output.ts"); + + await runCompiledBin({ cwd: dataFolder, source, output }); + + const result = await readFile(output, "utf-8"); + + expect(result).toMatchSnapshot(); + }, + timeout + ); + test( "petstore.json", async ({ expect, onTestFinished }) => { @@ -43,4 +87,21 @@ describe.concurrent("e2e", () => { }, timeout ); + + test( + "petstore.json / remote URL", + async ({ expect, onTestFinished }) => { + const tempFolder = await createTemporaryFolder({ onTestFinished }); + + const source = "https://petstore3.swagger.io/api/v3/openapi.json"; + const output = join(tempFolder, "output.ts"); + + await runCompiledBin({ source, output }); + + const result = await readFile(output, "utf-8"); + + expect(result).toMatchSnapshot(); + }, + timeout + ); }); diff --git a/packages/create-schemas/tests/fixtures.ts b/packages/create-schemas/tests/fixtures.ts index 2803556..9319a5d 100644 --- a/packages/create-schemas/tests/fixtures.ts +++ b/packages/create-schemas/tests/fixtures.ts @@ -14,7 +14,7 @@ interface CreateTemporaryFolderOptions { } export async function createTemporaryFolder({ - onTestFinished + onTestFinished, }: CreateTemporaryFolderOptions): Promise { const id = crypto.randomUUID(); const path = join(tempFolder, id); @@ -28,13 +28,16 @@ export async function createTemporaryFolder({ interface BinOptions { source: string; output: string; + cwd?: string; } export async function runCompiledBin(options: BinOptions): Promise { const binUrl = new URL("../dist/bin.js", import.meta.url); + const cwdArgs = options.cwd ? ["--cwd", options.cwd] : []; + const worker = new Worker(binUrl, { - argv: [options.source, "-o", options.output] + argv: [options.source, "-o", options.output, ...cwdArgs], }); return new Promise((resolve, reject) => { diff --git a/packages/create-schemas/tests/utils.test.ts b/packages/create-schemas/tests/utils.test.ts new file mode 100644 index 0000000..fce3f9b --- /dev/null +++ b/packages/create-schemas/tests/utils.test.ts @@ -0,0 +1,128 @@ +import { describe, test, vi } from "vitest"; +import { toFullyQualifiedURL } from "../src/utils.ts"; + +describe.concurrent("utils", () => { + describe.concurrent(toFullyQualifiedURL.name, () => { + test("input as HTTP URL as URL", ({ expect }) => { + const input = new URL("https://example.com/input"); + + const result = toFullyQualifiedURL(input); + + expect(result).toBe("https://example.com/input"); + }); + + test("input as HTTP URL as text", ({ expect }) => { + const input = "https://example.com/input"; + + const result = toFullyQualifiedURL(input); + + expect(result).toBe("https://example.com/input"); + }); + + test("input as file URL as URL", ({ expect }) => { + const input = new URL("file:///C:/input"); + const result = toFullyQualifiedURL(input); + expect(result).toBe("file:///C:/input"); + }); + + test("input as file URL as text", ({ expect }) => { + const input = "file:///C:/input"; + + const result = toFullyQualifiedURL(input); + + expect(result).toBe("file:///C:/input"); + }); + + test("input as absolute path", ({ expect }) => { + const input = "/input"; + + const result = toFullyQualifiedURL(input); + + expect(result).toBe("file:///C:/input"); + }); + + test("input as relative path", ({ expect, onTestFinished }) => { + vi.spyOn(process, "cwd").mockImplementation(() => "/cwd"); + onTestFinished(() => { + vi.resetAllMocks(); + }); + + // Bare + let input = "input"; + let result = toFullyQualifiedURL(input); + expect(result).toBe("file:///cwd/input"); + + // dot slash + input = "./input"; + result = toFullyQualifiedURL(input); + expect(result).toBe("file:///cwd/input"); + + // parent dir + input = "../input"; + result = toFullyQualifiedURL(input); + expect(result).toBe("file:///input"); + }); + + test("input as relative, root as HTTP URL as URL", ({ expect }) => { + const input = "input"; + const root = new URL("https://example.com/root"); + + const result = toFullyQualifiedURL(input, root); + + expect(result).toBe("https://example.com/root/input"); + }); + + test("input as relative, root as HTTP URL as text", ({ expect }) => { + const input = "input"; + const root = "https://example.com/root"; + + const result = toFullyQualifiedURL(input, root); + + expect(result).toBe("https://example.com/root/input"); + }); + + test("input as relative, root as file URL as URL", ({ expect }) => { + const input = "input"; + const root = new URL("file:///C:/root"); + + const result = toFullyQualifiedURL(input, root); + + expect(result).toBe("file:///C:/root/input"); + }); + + test("input as relative, root as file URL as text", ({ expect }) => { + const input = "input"; + const root = "file:///C:/root"; + + const result = toFullyQualifiedURL(input, root); + + expect(result).toBe("file:///C:/root/input"); + }); + + test("input as relative, root as absolute path", ({ expect }) => { + const input = "input"; + const root = "C:/root"; + + const result = toFullyQualifiedURL(input, root); + + expect(result).toBe("file:///C:/root/input"); + }); + + test("input as relative, root as relative", ({ + expect, + onTestFinished, + }) => { + vi.spyOn(process, "cwd").mockImplementation(() => "C:/cwd"); + onTestFinished(() => { + vi.resetAllMocks(); + }); + + const input = "input"; + const root = "root"; + + const result = toFullyQualifiedURL(input, root); + + expect(result).toBe("file:///C:/cwd/root/input"); + }); + }); +}); From 006873bed28d9236417047f678878d7db9dca560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tommy=20Jos=C3=A9povic?= Date: Fri, 5 Jul 2024 14:48:35 -0400 Subject: [PATCH 09/11] fix test --- packages/create-schemas/tests/utils.test.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/create-schemas/tests/utils.test.ts b/packages/create-schemas/tests/utils.test.ts index fce3f9b..6714f78 100644 --- a/packages/create-schemas/tests/utils.test.ts +++ b/packages/create-schemas/tests/utils.test.ts @@ -99,20 +99,29 @@ describe.concurrent("utils", () => { expect(result).toBe("file:///C:/root/input"); }); - test("input as relative, root as absolute path", ({ expect }) => { + test("input as relative, root as absolute path", ({ + expect, + onTestFinished, + }) => { + vi.spyOn(process, "cwd").mockImplementation(() => "/cwd"); + onTestFinished(() => { + vi.resetAllMocks(); + }); const input = "input"; - const root = "C:/root"; + const root = "/root"; + + console.log(root); const result = toFullyQualifiedURL(input, root); - expect(result).toBe("file:///C:/root/input"); + expect(result).toBe("file:///root/input"); }); test("input as relative, root as relative", ({ expect, onTestFinished, }) => { - vi.spyOn(process, "cwd").mockImplementation(() => "C:/cwd"); + vi.spyOn(process, "cwd").mockImplementation(() => "/cwd"); onTestFinished(() => { vi.resetAllMocks(); }); @@ -122,7 +131,7 @@ describe.concurrent("utils", () => { const result = toFullyQualifiedURL(input, root); - expect(result).toBe("file:///C:/cwd/root/input"); + expect(result).toBe("file:///cwd/root/input"); }); }); }); From 3b635bb6d5fb5c1a46ee1e057fbaaac644d6fec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tommy=20Jos=C3=A9povic?= Date: Fri, 5 Jul 2024 14:50:03 -0400 Subject: [PATCH 10/11] fix it again --- packages/create-schemas/tests/utils.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/create-schemas/tests/utils.test.ts b/packages/create-schemas/tests/utils.test.ts index 6714f78..6a76742 100644 --- a/packages/create-schemas/tests/utils.test.ts +++ b/packages/create-schemas/tests/utils.test.ts @@ -33,12 +33,17 @@ describe.concurrent("utils", () => { expect(result).toBe("file:///C:/input"); }); - test("input as absolute path", ({ expect }) => { + test("input as absolute path", ({ expect, onTestFinished }) => { + vi.spyOn(process, "cwd").mockImplementation(() => "/cwd"); + onTestFinished(() => { + vi.resetAllMocks(); + }); + const input = "/input"; const result = toFullyQualifiedURL(input); - expect(result).toBe("file:///C:/input"); + expect(result).toBe("file:///input"); }); test("input as relative path", ({ expect, onTestFinished }) => { From c8d661327ea4485363a1c58003633664732a89c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tommy=20Jos=C3=A9povic?= Date: Tue, 9 Jul 2024 10:02:35 -0400 Subject: [PATCH 11/11] make test more compact, lint --- packages/create-schemas/src/bin.ts | 2 +- .../src/openapiTypescriptHelper.ts | 4 +- packages/create-schemas/tests/e2e.test.ts | 67 +++++++------------ packages/create-schemas/tests/fixtures.ts | 13 ++-- packages/create-schemas/tests/utils.test.ts | 8 +-- 5 files changed, 39 insertions(+), 55 deletions(-) diff --git a/packages/create-schemas/src/bin.ts b/packages/create-schemas/src/bin.ts index 7b21404..084b40f 100644 --- a/packages/create-schemas/src/bin.ts +++ b/packages/create-schemas/src/bin.ts @@ -24,7 +24,7 @@ try { function printConfigurationErrors(error: ZodError) { console.log("Invalid configuration:"); - error.errors.forEach((issue) => { + error.errors.forEach(issue => { console.log(` - ${issue.path.join(".")}: ${issue.message}`); }); console.log("Use --help to see available options."); diff --git a/packages/create-schemas/src/openapiTypescriptHelper.ts b/packages/create-schemas/src/openapiTypescriptHelper.ts index acff90e..98af989 100644 --- a/packages/create-schemas/src/openapiTypescriptHelper.ts +++ b/packages/create-schemas/src/openapiTypescriptHelper.ts @@ -2,7 +2,7 @@ import openapiTS, { astToString } from "openapi-typescript"; import { generateExportEndpointsTypeDeclaration, generateExportSchemaTypeDeclaration, - getSchemaNames, + getSchemaNames } from "./astHelper.ts"; import type { ResolvedConfig } from "./config.ts"; @@ -10,7 +10,7 @@ export async function generateSchemas(config: ResolvedConfig): Promise { // Create a TypeScript AST from the OpenAPI schema const ast = await openapiTS(new URL(config.input), { ...config.openApiTsOptions, - silent: true, + silent: true }); // Find the node where all the DTOs are defined, and extract their names diff --git a/packages/create-schemas/tests/e2e.test.ts b/packages/create-schemas/tests/e2e.test.ts index b72b489..edd8f13 100644 --- a/packages/create-schemas/tests/e2e.test.ts +++ b/packages/create-schemas/tests/e2e.test.ts @@ -1,8 +1,7 @@ -import { readFile } from "node:fs/promises"; import { createTemporaryFolder, dataFolder, - runCompiledBin, + runCompiledBin } from "./fixtures.ts"; import { describe, test } from "vitest"; import { join } from "node:path"; @@ -14,16 +13,12 @@ describe.concurrent("e2e", () => { test( "officevice.yaml / file paths", async ({ expect, onTestFinished }) => { - const tempFolder = await createTemporaryFolder({ - onTestFinished, - }); - - const source = join(dataFolder, "officevice.yaml"); - const output = join(tempFolder, "output.ts"); - - await runCompiledBin({ source, output }); + const tempFolder = await createTemporaryFolder({ onTestFinished }); - const result = await readFile(output, "utf-8"); + const result = await runCompiledBin({ + source: join(dataFolder, "officevice.yaml"), + output: join(tempFolder, "output.ts") + }); expect(result).toMatchSnapshot(); }, @@ -33,20 +28,13 @@ describe.concurrent("e2e", () => { test( "officevice.yaml / file URLs", async ({ expect, onTestFinished }) => { - const tempFolder = await createTemporaryFolder({ - onTestFinished, - }); - - const source = pathToFileURL(join(dataFolder, "officevice.yaml")); - const output = pathToFileURL(join(tempFolder, "output.ts")); + const tempFolder = await createTemporaryFolder({ onTestFinished }); - await runCompiledBin({ - source: source.toString(), - output: output.toString(), + const result = await runCompiledBin({ + source: pathToFileURL(join(dataFolder, "officevice.yaml")).toString(), + output: pathToFileURL(join(tempFolder, "output.ts")).toString() }); - const result = await readFile(output, "utf-8"); - expect(result).toMatchSnapshot(); }, timeout @@ -55,16 +43,13 @@ describe.concurrent("e2e", () => { test( "officevice.yaml / relative path", async ({ expect, onTestFinished }) => { - const tempFolder = await createTemporaryFolder({ - onTestFinished, - }); - - const source = "officevice.yaml"; - const output = join(tempFolder, "output.ts"); - - await runCompiledBin({ cwd: dataFolder, source, output }); + const tempFolder = await createTemporaryFolder({ onTestFinished }); - const result = await readFile(output, "utf-8"); + const result = await runCompiledBin({ + cwd: dataFolder, + source: "officevice.yaml", + output: join(tempFolder, "output.ts") + }); expect(result).toMatchSnapshot(); }, @@ -76,12 +61,10 @@ describe.concurrent("e2e", () => { async ({ expect, onTestFinished }) => { const tempFolder = await createTemporaryFolder({ onTestFinished }); - const source = join(dataFolder, "petstore.json"); - const output = join(tempFolder, "output.ts"); - - await runCompiledBin({ source, output }); - - const result = await readFile(output, "utf-8"); + const result = await runCompiledBin({ + source: join(dataFolder, "petstore.json"), + output: join(tempFolder, "output.ts") + }); expect(result).toMatchSnapshot(); }, @@ -93,12 +76,10 @@ describe.concurrent("e2e", () => { async ({ expect, onTestFinished }) => { const tempFolder = await createTemporaryFolder({ onTestFinished }); - const source = "https://petstore3.swagger.io/api/v3/openapi.json"; - const output = join(tempFolder, "output.ts"); - - await runCompiledBin({ source, output }); - - const result = await readFile(output, "utf-8"); + const result = await runCompiledBin({ + source: "https://petstore3.swagger.io/api/v3/openapi.json", + output: join(tempFolder, "output.ts") + }); expect(result).toMatchSnapshot(); }, diff --git a/packages/create-schemas/tests/fixtures.ts b/packages/create-schemas/tests/fixtures.ts index 9319a5d..a8ff08d 100644 --- a/packages/create-schemas/tests/fixtures.ts +++ b/packages/create-schemas/tests/fixtures.ts @@ -1,8 +1,9 @@ import { Worker } from "node:worker_threads"; -import { mkdir, rm } from "node:fs/promises"; +import { mkdir, readFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import type { OnTestFailedHandler } from "vitest"; +import { toFullyQualifiedURL } from "../src/utils.ts"; const tempFolder = fileURLToPath( new URL("../node_modules/.tmp", import.meta.url) @@ -14,7 +15,7 @@ interface CreateTemporaryFolderOptions { } export async function createTemporaryFolder({ - onTestFinished, + onTestFinished }: CreateTemporaryFolderOptions): Promise { const id = crypto.randomUUID(); const path = join(tempFolder, id); @@ -31,17 +32,19 @@ interface BinOptions { cwd?: string; } -export async function runCompiledBin(options: BinOptions): Promise { +export async function runCompiledBin(options: BinOptions): Promise { const binUrl = new URL("../dist/bin.js", import.meta.url); const cwdArgs = options.cwd ? ["--cwd", options.cwd] : []; const worker = new Worker(binUrl, { - argv: [options.source, "-o", options.output, ...cwdArgs], + argv: [options.source, "-o", options.output, ...cwdArgs] }); - return new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { worker.on("exit", resolve); worker.on("error", reject); }); + + return readFile(new URL(toFullyQualifiedURL(options.output)), "utf8"); } diff --git a/packages/create-schemas/tests/utils.test.ts b/packages/create-schemas/tests/utils.test.ts index 6a76742..6348361 100644 --- a/packages/create-schemas/tests/utils.test.ts +++ b/packages/create-schemas/tests/utils.test.ts @@ -21,7 +21,9 @@ describe.concurrent("utils", () => { test("input as file URL as URL", ({ expect }) => { const input = new URL("file:///C:/input"); + const result = toFullyQualifiedURL(input); + expect(result).toBe("file:///C:/input"); }); @@ -106,7 +108,7 @@ describe.concurrent("utils", () => { test("input as relative, root as absolute path", ({ expect, - onTestFinished, + onTestFinished }) => { vi.spyOn(process, "cwd").mockImplementation(() => "/cwd"); onTestFinished(() => { @@ -115,8 +117,6 @@ describe.concurrent("utils", () => { const input = "input"; const root = "/root"; - console.log(root); - const result = toFullyQualifiedURL(input, root); expect(result).toBe("file:///root/input"); @@ -124,7 +124,7 @@ describe.concurrent("utils", () => { test("input as relative, root as relative", ({ expect, - onTestFinished, + onTestFinished }) => { vi.spyOn(process, "cwd").mockImplementation(() => "/cwd"); onTestFinished(() => {