diff --git a/README.md b/README.md index 4fe5330d..36c361b1 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,9 @@ massimo --name myclient --type-extension # Declare components.schemas as named types instead of inlining every $ref massimo --name myclient --named-schemas + +# Declare a map from path template and HTTP method to the operation types +massimo --name myclient --path-map ``` ### Named Schemas @@ -230,6 +233,26 @@ With `--named-schemas`, every `components.schemas` entry is declared once as a n A schema whose name is already taken by a generated type (e.g. `GetMoviesResponseOK`), by another schema, or by a TypeScript built-in is declared with a `Schema` suffix (`Schema2`, `Schema3`, ... if needed), with a comment on the declaration. Names are stable for a given spec but not across its revisions: a new operation or schema can take a name and move a schema to a suffixed one. +### Path Map + +With `--path-map`, the generated types also declare an interface keyed by path template, then by HTTP method. Each entry names the request and responses types of the client method for that operation, so code that only knows the path and the method (a generic fetch wrapper, a router, a mock server) can reach the same types as the client: + +```ts +export interface MyclientPaths { + '/movies/{id}': { + 'get': { 'request': GetMovieRequest; 'responses': GetMovieResponses }; + 'delete': { 'request': DeleteMovieRequest; 'responses': DeleteMovieResponses }; + } +} + +export type MyclientPathRequest

= MyclientPaths[P][M] extends { 'request': infer R } ? R : never +export type MyclientPathResponses

= MyclientPaths[P][M] extends { 'responses': infer R } ? R : never +``` + +The entries use the type names of the client methods, including an operation without `operationId` (named from its path) and one renamed because its `operationId` is repeated. A path or method that is not in the spec is a type error. Use the two aliases when the path and the method are type parameters: `MyclientPaths[P][M]['request']` does not compile there (TS2536), because nothing tells TypeScript that every entry has a `request` member. + +`Myclient` is the client type name derived from `--name`. If an operation already declares `MyclientPathRequest` or `MyclientPathResponses` (an `operationId` of `myclientPath`), the alias gets a numeric suffix (`MyclientPathRequest2`). Like schema names, the alias names are stable for a given spec but not across its revisions: a new operation can take an alias name and move the alias to a suffixed one. `MyclientPaths` itself never gets a suffix. The option works with or without `--named-schemas`; when both are set, a component schema named like the map or an alias is renamed with the `Schema` suffix described above. + ### Module Format Detection Massimo automatically detects and generates the appropriate module format (ESM or CommonJS) for your clients: diff --git a/packages/massimo-cli/help/help.txt b/packages/massimo-cli/help/help.txt index 5c34fdcc..a7a4cff9 100644 --- a/packages/massimo-cli/help/help.txt +++ b/packages/massimo-cli/help/help.txt @@ -78,4 +78,5 @@ Options: * `--retry-timeout-ms` - If passed, the HTTP request to get an Open API schema will be retried (reference: https://undici.nodejs.org/#/docs/api/RetryHandler.md) * `--type-extension` - Force the use of module-specific type extensions (.d.mts for ESM, .d.cts for CJS) instead of the default logic. * `--named-schemas` - Declare every `components.schemas` entry as a named type and print response `$ref`s as that name instead of inlining the schema. Off by default. +* `--path-map` - Also declare `Paths`, an interface keyed by path template and HTTP method whose entries are the request and responses types of each client method, with the lookup aliases `PathRequest` and `PathResponses`. Works with or without `--named-schemas`. Off by default. * `--skip-prefixed-url` - If passed, it will avoid unnecessary extra HTTP call to check if, for instance, the `/documentation/json` Open API URL exists diff --git a/packages/massimo-cli/index.js b/packages/massimo-cli/index.js index 13fe7802..b46f6283 100755 --- a/packages/massimo-cli/index.js +++ b/packages/massimo-cli/index.js @@ -127,7 +127,8 @@ async function writeOpenAPIClient ( moduleFormat, typeExtension, explicitModuleFormat, - namedSchemas + namedSchemas, + pathMap ) { await createDirectory(folder) @@ -160,6 +161,7 @@ async function writeOpenAPIClient ( withCredentials, propsOptional, namedSchemas, + pathMap, typeExt }) await writeFile(join(folder, `${name}-types.${typeExt}`), types) @@ -178,7 +180,8 @@ async function writeOpenAPIClient ( typesComment, propsOptional, moduleFormat, - namedSchemas + namedSchemas, + pathMap }) const typeExt = await determineTypeExtension(folder, moduleFormat, typeExtension, explicitModuleFormat, generateImplementation) const implExt = moduleFormat === 'esm' ? 'mjs' : 'cjs' @@ -250,7 +253,8 @@ async function downloadAndWriteOpenAPI ( moduleFormat, typeExtension, explicitModuleFormat, - namedSchemas + namedSchemas, + pathMap ) { logger.debug(`Trying to download OpenAPI schema from ${url}`) let requestOptions @@ -291,7 +295,8 @@ async function downloadAndWriteOpenAPI ( moduleFormat, typeExtension, explicitModuleFormat, - namedSchemas + namedSchemas, + pathMap ) /* c8 ignore next 3 */ } catch (err) { @@ -366,7 +371,8 @@ async function readFromFileAndWrite ( moduleFormat, typeExtension, explicitModuleFormat, - namedSchemas + namedSchemas, + pathMap ) { logger.info(`Trying to read schema from file ${file}`) const text = await readFile(file, 'utf8') @@ -391,7 +397,8 @@ async function readFromFileAndWrite ( moduleFormat, typeExtension, explicitModuleFormat, - namedSchemas + namedSchemas, + pathMap ) return 'openapi' } catch (err) { @@ -440,7 +447,8 @@ async function downloadAndProcess (options) { typeExtension, explicitModuleFormat, skipPrefixedUrl, - namedSchemas + namedSchemas, + pathMap } = options const generateImplementation = options.generateImplementation @@ -473,7 +481,8 @@ async function downloadAndProcess (options) { moduleFormat, typeExtension, explicitModuleFormat, - namedSchemas + namedSchemas, + pathMap ) ) } @@ -500,7 +509,8 @@ async function downloadAndProcess (options) { moduleFormat, typeExtension, explicitModuleFormat, - namedSchemas + namedSchemas, + pathMap ) ) } else if (options.type === 'graphql') { @@ -558,7 +568,8 @@ async function downloadAndProcess (options) { moduleFormat, typeExtension, explicitModuleFormat, - namedSchemas + namedSchemas, + pathMap ) ) toTry.push( @@ -598,7 +609,8 @@ async function downloadAndProcess (options) { moduleFormat, typeExtension, explicitModuleFormat, - namedSchemas + namedSchemas, + pathMap ) ) toTry.push( @@ -638,7 +650,8 @@ async function downloadAndProcess (options) { moduleFormat, typeExtension, explicitModuleFormat, - namedSchemas + namedSchemas, + pathMap ) ) } @@ -706,7 +719,8 @@ export async function command (argv) { 'props-optional', 'type-extension', 'skip-prefixed-url', - 'named-schemas' + 'named-schemas', + 'path-map' ], default: { typescript: false, @@ -777,6 +791,7 @@ export async function command (argv) { options.typeExtension = options['type-extension'] options.skipPrefixedUrl = options['skip-prefixed-url'] options.namedSchemas = options['named-schemas'] + options.pathMap = options['path-map'] options.explicitModuleFormat = !!options.module await downloadAndProcess({ url, ...options, logger }) logger.info(`Client generated successfully into ${options.folder}`) diff --git a/packages/massimo-cli/lib/frontend-openapi-generator.js b/packages/massimo-cli/lib/frontend-openapi-generator.js index d8bd0c32..b5423a83 100644 --- a/packages/massimo-cli/lib/frontend-openapi-generator.js +++ b/packages/massimo-cli/lib/frontend-openapi-generator.js @@ -1,7 +1,7 @@ import camelcase from 'camelcase' import { generateOperationId } from 'massimo' import { writeNamedSchemas } from './named-schemas.js' -import { getOperationTypeNames, writeOperations } from './openapi-common.js' +import { getOperationTypeNames, getPathMapTypeNames, writeOperations, writePathMap } from './openapi-common.js' import { buildSchemaNames } from './schema-names.js' import { capitalize, @@ -22,9 +22,10 @@ export function processFrontendOpenAPI ({ withCredentials, propsOptional, namedSchemas, + pathMap, typeExt = 'd.mts' }) { - const { types, operations } = generateTypesFromOpenAPI({ schema, name, fullResponse, fullRequest, propsOptional, namedSchemas }) + const { types, operations } = generateTypesFromOpenAPI({ schema, name, fullResponse, fullRequest, propsOptional, namedSchemas, pathMap }) return { types, implementation: generateFrontendImplementationFromOpenAPI({ @@ -395,7 +396,7 @@ function generateFrontendImplementationFromOpenAPI ({ return writer.toString() } -function generateTypesFromOpenAPI ({ schema, name, fullRequest, fullResponse, propsOptional, namedSchemas }) { +function generateTypesFromOpenAPI ({ schema, name, fullRequest, fullResponse, propsOptional, namedSchemas, pathMap }) { const camelCaseName = capitalize(camelcase(name)) const { paths } = schema const generatedOperationIds = new Set() @@ -425,19 +426,22 @@ function generateTypesFromOpenAPI ({ schema, name, fullRequest, fullResponse, pr interfaces.blankLine() // Operation and client types keep their names; a schema that collides is renamed + const operationTypeNames = (namedSchemas || pathMap) ? getOperationTypeNames(operations) : [] + const pathMapNames = pathMap ? getPathMapTypeNames(camelCaseName, operationTypeNames) : undefined const schemaNames = namedSchemas - ? buildSchemaNames(schema, [...getOperationTypeNames(operations), camelCaseName, 'PlatformaticFrontendClient', 'BuildOptions']) + ? buildSchemaNames(schema, [...operationTypeNames, camelCaseName, ...Object.values(pathMapNames ?? {}), 'PlatformaticFrontendClient', 'BuildOptions']) : undefined if (schemaNames) { writeNamedSchemas(interfaces, schema, schemaNames) } + let pathMapEntries writer.blankLine() writer.write(`export interface ${camelCaseName}`).block(() => { writer.writeLine('setBaseUrl(newUrl: string): void;') writer.writeLine('setDefaultHeaders(headers: object): void;') writer.writeLine('setDefaultFetchParams(fetchParams: RequestInit): void;') - writeOperations(interfaces, writer, operations, { + pathMapEntries = writeOperations(interfaces, writer, operations, { fullRequest, fullResponse, optionalHeaders: [], @@ -447,6 +451,10 @@ function generateTypesFromOpenAPI ({ schema, name, fullRequest, fullResponse, pr }) }) + if (pathMap) { + writePathMap(interfaces, pathMapNames, pathMapEntries) + } + // Declare the module-level named exports emitted by the implementation file, // so consumers relying on the .d.ts can `import { setBaseUrl, ... }` without type errors. writer.writeLine(`export declare const setBaseUrl: ${camelCaseName}['setBaseUrl'];`) diff --git a/packages/massimo-cli/lib/openapi-common.js b/packages/massimo-cli/lib/openapi-common.js index 423247f8..15b75787 100644 --- a/packages/massimo-cli/lib/openapi-common.js +++ b/packages/massimo-cli/lib/openapi-common.js @@ -16,6 +16,7 @@ export function writeOperations ( const originalFullRequest = fullRequest let currentFullResponse = originalFullResponse let currentFullRequest = originalFullRequest + const pathMapEntries = [] for (const operation of operations) { const { operationId, description, summary, deprecated } = operation.operation const camelCaseOperationId = camelcase(operationId) @@ -136,9 +137,66 @@ export function writeOperations ( mainWriter.writeLine(` * @returns the API response${fullResponse ? '' : ' body'}`) mainWriter.writeLine(' */') mainWriter.writeLine(`${camelCaseOperationId}(req: ${operationRequestName}): Promise<${allResponsesName}>;`) + pathMapEntries.push({ path: operation.path, method: operation.method, request: operationRequestName, responses: allResponsesName }) currentFullResponse = originalFullResponse currentFullRequest = originalFullRequest } + return pathMapEntries +} + +// The names of the path map and its two lookup aliases declared for the client +// type `clientTypeName`. Operation types end in `Request`, `Responses`, or +// `Response` followed by a status name or preceded by a status code, so none +// can end in `Paths` and only a component schema can take the map's name. A +// component schema is declared under a name of its own only with named +// schemas, and then `buildSchemaNames` renames it. An operation whose id is +// `Path` does declare `PathRequest` and +// `PathResponses`, so an alias whose name an operation type +// already has gets a numeric suffix instead. +export function getPathMapTypeNames (clientTypeName, operationTypeNames) { + const taken = new Set(operationTypeNames) + const free = base => { + let name = base + for (let i = 2; taken.has(name); i++) { + name = `${base}${i}` + } + return name + } + return { + map: `${clientTypeName}Paths`, + request: free(`${clientTypeName}PathRequest`), + responses: free(`${clientTypeName}PathResponses`) + } +} + +// Declares an interface keyed by path template, then HTTP method, whose values +// name the request and response types `writeOperations` declared for that +// operation. The entries are the ones `writeOperations` returns, so the names +// are the ones the client methods use, including operations whose id was +// generated from the path or renamed to stay unique. Two aliases resolve an +// entry's request and responses types for a generic path and method: indexing +// `Map[P][M]['request']` directly does not compile, because the per-path types +// share no constraint that would prove the member exists. +export function writePathMap (writer, names, entries) { + const methodsByPath = new Map() + for (const entry of entries) { + const methods = methodsByPath.get(entry.path) ?? [] + methods.push(entry) + methodsByPath.set(entry.path, methods) + } + writer.write(`export interface ${names.map}`).block(() => { + for (const [path, methods] of methodsByPath) { + writer.quote(path).write(': ').block(() => { + for (const { method, request, responses } of methods) { + writer.quote(method).write(`: { 'request': ${request}; 'responses': ${responses} };`).newLine() + } + }) + } + }) + writer.blankLine() + const params = `P extends keyof ${names.map}, M extends keyof ${names.map}[P]` + writer.writeLine(`export type ${names.request}<${params}> = ${names.map}[P][M] extends { 'request': infer R } ? R : never`) + writer.writeLine(`export type ${names.responses}<${params}> = ${names.map}[P][M] extends { 'responses': infer R } ? R : never`) } // Every type name `writeOperations` declares for these operations. diff --git a/packages/massimo-cli/lib/openapi-generator.js b/packages/massimo-cli/lib/openapi-generator.js index ad283962..ba3222e0 100644 --- a/packages/massimo-cli/lib/openapi-generator.js +++ b/packages/massimo-cli/lib/openapi-generator.js @@ -1,6 +1,6 @@ import { generateOperationId } from 'massimo' import { writeNamedSchemas } from './named-schemas.js' -import { getOperationTypeNames, writeOperations } from './openapi-common.js' +import { getOperationTypeNames, getPathMapTypeNames, writeOperations, writePathMap } from './openapi-common.js' import { buildSchemaNames } from './schema-names.js' import { capitalize, createCodeWriter, toJavaScriptName } from './utils.js' @@ -14,7 +14,8 @@ export function processOpenAPI ({ typesComment, propsOptional, moduleFormat, - namedSchemas + namedSchemas, + pathMap }) { return { types: generateTypesFromOpenAPI({ @@ -25,7 +26,8 @@ export function processOpenAPI ({ optionalHeaders, typesComment, propsOptional, - namedSchemas + namedSchemas, + pathMap }), implementation: generateImplementationFromOpenAPI({ name, fullResponse, fullRequest, validateResponse, moduleFormat }) } @@ -83,7 +85,8 @@ function generateTypesFromOpenAPI ({ optionalHeaders, typesComment, propsOptional, - namedSchemas + namedSchemas, + pathMap }) { const camelcasedName = toJavaScriptName(name) const capitalizedName = capitalize(camelcasedName) @@ -141,15 +144,18 @@ function generateTypesFromOpenAPI ({ interfaces.blankLine() // Operation and client types keep their names; a schema that collides is renamed + const operationTypeNames = (namedSchemas || pathMap) ? getOperationTypeNames(operations) : [] + const pathMapNames = pathMap ? getPathMapTypeNames(capitalizedName, operationTypeNames) : undefined const schemaNames = namedSchemas - ? buildSchemaNames(schema, [...getOperationTypeNames(operations), capitalizedName]) + ? buildSchemaNames(schema, [...operationTypeNames, capitalizedName, ...Object.values(pathMapNames ?? {})]) : undefined if (schemaNames) { writeNamedSchemas(interfaces, schema, schemaNames) } + let pathMapEntries writer.write(`export type ${capitalizedName} =`).block(() => { - writeOperations(interfaces, writer, operations, { + pathMapEntries = writeOperations(interfaces, writer, operations, { fullRequest, fullResponse, optionalHeaders, @@ -159,6 +165,10 @@ function generateTypesFromOpenAPI ({ }) }) + if (pathMap) { + writePathMap(interfaces, pathMapNames, pathMapEntries) + } + writer.write(interfaces.toString()) writer.blankLine() diff --git a/packages/massimo-cli/test/cli-openapi-path-map.test.js b/packages/massimo-cli/test/cli-openapi-path-map.test.js new file mode 100644 index 00000000..5f036c7c --- /dev/null +++ b/packages/massimo-cli/test/cli-openapi-path-map.test.js @@ -0,0 +1,236 @@ +import { execa } from 'execa' +import { existsSync } from 'node:fs' +import { readdir, readFile, writeFile } from 'node:fs/promises' +import { deepEqual, equal, ok } from 'node:assert' +import { after, test } from 'node:test' +import { join } from 'node:path' +import { moveToTmpdir } from './helper.js' + +const cli = join(import.meta.dirname, '..', 'index.js') +const openAPIfile = join(import.meta.dirname, 'fixtures', 'path-map-openapi.json') +const env = { ...process.env, NODE_V8_COVERAGE: undefined } + +function findTSCPath () { + const tscPath = join(import.meta.dirname, '..', 'node_modules', '.bin', 'tsc') + return existsSync(tscPath) ? tscPath : join(import.meta.dirname, '../../..', 'node_modules', '.bin', 'tsc') +} + +// The fixture covers an operation without operationId (`GET /movies/{id}`), an +// operationId used twice (`createFolder`), and an operation whose only +// response is a 204 (`deleteMovie`). +const expectedPathMap = `export interface MoviesPaths { + '/movies': { + 'get': { 'request': GetMoviesRequest; 'responses': GetMoviesResponses }; + 'post': { 'request': CreateMovieRequest; 'responses': CreateMovieResponses }; + } + '/movies/{id}': { + 'get': { 'request': GetMoviesIdRequest; 'responses': GetMoviesIdResponses }; + 'delete': { 'request': DeleteMovieRequest; 'responses': DeleteMovieResponses }; + } + '/accounts/{accountId}/folders': { + 'post': { 'request': CreateFolderRequest; 'responses': CreateFolderResponses }; + } + '/folders': { + 'post': { 'request': PostCreateFolderRequest; 'responses': PostCreateFolderResponses }; + } +}` + +const expectedAliases = `export type MoviesPathRequest

= MoviesPaths[P][M] extends { 'request': infer R } ? R : never +export type MoviesPathResponses

= MoviesPaths[P][M] extends { 'responses': infer R } ? R : never` + +// Type-level assertions on the generated declarations: each entry of the map +// is exactly the request and responses types of the client method, the lookup +// aliases resolve every entry for a generic path and method, and a path or a +// method that is not in the spec does not compile. +const typeCheck = (from) => ` +import type * as T from '${from}' + +type Equal = (() => X extends A ? 1 : 2) extends (() => X extends B ? 1 : 2) ? true : false +type Method = T.Movies[K] extends (req: infer Req) => Promise ? { request: Req; responses: Res } : never + +export const checks: true[] = [ + {} as Equal>, + {} as Equal>, + {} as Equal>, + {} as Equal>, + {} as Equal>, + {} as Equal>, + {} as Equal, T.GetMoviesIdRequest>, + {} as Equal, T.PostCreateFolderRequest>, + {} as Equal, T.PostCreateFolderResponses>, + {} as Equal, T.DeleteMovieResponses> +] + +// Every entry, looked up through the aliases, is the entry itself and not never +type IsNever = [X] extends [never] ? true : false +type Resolved

= + IsNever> extends true ? false + : IsNever> extends true ? false + : Equal; 'responses': T.MoviesPathResponses }> +type AllResolved = { [P in keyof T.MoviesPaths]: { [M in keyof T.MoviesPaths[P]]: Resolved }[keyof T.MoviesPaths[P]] }[keyof T.MoviesPaths] +export const allResolved: Equal = true + +// A caller generic over path and method +export declare function call

(path: P, method: M, req: T.MoviesPathRequest): Promise> +export const created = call('/folders', 'post', { body: { name: 'a', accountId: 1 } }) + +// @ts-expect-error a path that is not in the spec +export type MissingPath = T.MoviesPaths['/series'] +// @ts-expect-error a method that the path does not have +export type MissingMethod = T.MoviesPaths['/folders']['get'] +// @ts-expect-error a path that is not in the spec, through the alias +export type MissingAliasPath = T.MoviesPathRequest<'/series', 'get'> +// @ts-expect-error a method that the path does not have, through the alias +export type MissingAliasMethod = T.MoviesPathResponses<'/folders', 'get'> +` + +async function typecheck (dir, source, lib) { + await writeFile(join(dir, 'check.ts'), source) + await writeFile(join(dir, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + target: 'es2022', + module: 'nodenext', + moduleResolution: 'nodenext', + lib, + strict: true, + noEmit: true, + // the declaration file itself is checked, not only its use from check.ts + skipLibCheck: false, + types: [] + }, + files: ['check.ts'] + }, null, 2)) + await execa(findTSCPath(), ['-p', join(dir, 'tsconfig.json')], { env }) +} + +const generators = [ + { label: 'node', args: ['--types-only'], file: 'movies.d.ts', from: './movies/movies.js', lib: ['es2022'] }, + { label: '--frontend', args: ['--frontend', '--language', 'ts'], file: 'movies-types.d.ts', from: './movies/movies-types.js', lib: ['es2022', 'dom'] } +] + +for (const { label, args, file, from, lib } of generators) { + test(`--path-map declares a path map of the client method types (${label})`, async () => { + const dir = await moveToTmpdir(after) + await writeFile(join(dir, 'package.json'), JSON.stringify({ type: 'module' })) + await execa('node', [cli, openAPIfile, '--name', 'movies', ...args, '--path-map']) + const data = await readFile(join(dir, 'movies', file), 'utf-8') + + ok(data.includes(expectedPathMap)) + ok(data.includes(expectedAliases)) + equal(data.match(/export interface MoviesPaths\b/g).length, 1) + // the operation without operationId is named from its path + ok(data.includes('getMoviesId(req: GetMoviesIdRequest): Promise;')) + // the second `createFolder` is renamed, and the map follows the rename + ok(data.includes('createFolder(req: CreateFolderRequest): Promise;')) + ok(data.includes('postCreateFolder(req: PostCreateFolderRequest): Promise;')) + // the 204-only operation keeps its responses type + ok(data.includes('export type DeleteMovieResponses =\n FullResponse')) + // without --named-schemas the component schemas stay inlined, so no + // schema takes or is moved off the names of the map + equal(data.includes('MoviesPathsSchema'), false) + equal(data.includes('MoviesPathRequestSchema'), false) + + await typecheck(dir, typeCheck(from), lib) + }) + + test(`--path-map with --named-schemas renames a schema named like the map (${label})`, async () => { + const dir = await moveToTmpdir(after) + await writeFile(join(dir, 'package.json'), JSON.stringify({ type: 'module' })) + await execa('node', [cli, openAPIfile, '--name', 'movies', ...args, '--named-schemas', '--path-map']) + const data = await readFile(join(dir, 'movies', file), 'utf-8') + + ok(data.includes(expectedPathMap)) + ok(data.includes(expectedAliases)) + // the component schemas `MoviesPaths` and `MoviesPathRequest` give way + ok(data.includes("// components.schemas['MoviesPaths'] is declared as MoviesPathsSchema: its own name is taken\nexport type MoviesPathsSchema = string")) + ok(data.includes("// components.schemas['MoviesPathRequest'] is declared as MoviesPathRequestSchema: its own name is taken\nexport type MoviesPathRequestSchema = number")) + equal(data.match(/export interface MoviesPaths\b/g).length, 1) + equal(data.match(/export type MoviesPathRequest\b/g).length, 1) + + await typecheck(dir, typeCheck(from), lib) + }) +} + +for (const extra of [[], ['--named-schemas']]) { + test(`--path-map suffixes a lookup alias whose name an operation type already has (${['--path-map', ...extra].join(' ')})`, async () => { + const dir = await moveToTmpdir(after) + await writeFile(join(dir, 'package.json'), JSON.stringify({ type: 'module' })) + const file = join(dir, 'alias-collision.json') + await writeFile(file, JSON.stringify({ + openapi: '3.0.3', + info: { title: 'Alias collision', version: '1.0.0' }, + paths: { '/movies-path': { get: { operationId: 'moviesPath', responses: { 200: { description: 'ok', content: { 'application/json': { schema: { type: 'string' } } } } } } } } + })) + await execa('node', [cli, file, '--name', 'movies', '--types-only', '--path-map', ...extra]) + const data = await readFile(join(dir, 'movies', 'movies.d.ts'), 'utf-8') + + ok(data.includes('moviesPath(req: MoviesPathRequest): Promise;')) + ok(data.includes('export type MoviesPathRequest2<')) + ok(data.includes('export type MoviesPathResponses2<')) + + await typecheck(dir, ` +import type * as T from './movies/movies.js' +type Equal = (() => X extends A ? 1 : 2) extends (() => X extends B ? 1 : 2) ? true : false +export const checks: true[] = [ + {} as Equal, T.MoviesPathRequest>, + {} as Equal, T.MoviesPathResponses> +] +`, ['es2022']) + }) +} + +test('--path-map on a spec without components.schemas does not warn', async () => { + const dir = await moveToTmpdir(after) + const file = join(dir, 'no-schemas.json') + await writeFile(file, JSON.stringify({ + openapi: '3.0.3', + info: { title: 'No schemas', version: '1.0.0' }, + paths: { '/hello': { get: { operationId: 'getHello', responses: { 200: { description: 'ok' } } } } } + })) + const { stdout } = await execa('node', [cli, file, '--name', 'movies', '--types-only', '--path-map']) + const data = await readFile(join(dir, 'movies', 'movies.d.ts'), 'utf-8') + + ok(data.includes("'/hello': {\n 'get': { 'request': GetHelloRequest; 'responses': GetHelloResponses };")) + equal(stdout.includes('has no effect'), false) +}) + +// Everything the CLI writes without --path-map, every file of every mode, is +// what it writes with --path-map once the map and its aliases are removed and, +// with --named-schemas, the renames of the schemas named like the map are undone. +const modes = [['--types-only'], [], ['--frontend'], ['--frontend', '--language', 'ts']] +const declaresMap = /export (interface MoviesPaths\b|type MoviesPath(Request|Responses)<)/ +const mapBlock = /export interface MoviesPaths \{\n[\s\S]*?\n\}\n\nexport type MoviesPathRequest<[^\n]*\nexport type MoviesPathResponses<[^\n]*\n/ + +for (const mode of modes) { + for (const extra of [[], ['--named-schemas']]) { + const flags = [...mode, ...extra].join(' ') || 'full client' + const undone = extra.length === 0 ? '' : ' and the schema renames undone' + test(`without --path-map the output is the same as with it, map and aliases removed${undone} (${flags})`, async () => { + const dir = await moveToTmpdir(after) + await execa('node', [cli, openAPIfile, '--name', 'movies', '--folder', join(dir, 'without'), ...mode, ...extra]) + await execa('node', [cli, openAPIfile, '--name', 'movies', '--folder', join(dir, 'with'), ...mode, ...extra, '--path-map']) + + const files = (await readdir(join(dir, 'without'))).sort() + deepEqual((await readdir(join(dir, 'with'))).sort(), files) + let maps = 0 + for (const name of files) { + const without = await readFile(join(dir, 'without', name), 'utf-8') + const withMap = await readFile(join(dir, 'with', name), 'utf-8') + equal(declaresMap.test(without), false) + if (withMap !== without) { + maps++ + if (extra.length === 0) { + equal(withMap.replace(mapBlock, ''), without) + } else { + // with named schemas, the schemas named like the map are renamed too + equal(withMap.replace(mapBlock, '') + .replace(/\/\/ components\.schemas\['(MoviesPaths|MoviesPathRequest)'\] is declared as [^\n]*\n/g, '') + .replace('export type MoviesPathsSchema = string', 'export type MoviesPaths = string') + .replace('export type MoviesPathRequestSchema = number', 'export type MoviesPathRequest = number'), without) + } + } + } + equal(maps, 1) + }) + } +} diff --git a/packages/massimo-cli/test/fixtures/path-map-openapi.json b/packages/massimo-cli/test/fixtures/path-map-openapi.json new file mode 100644 index 00000000..aafa7d67 --- /dev/null +++ b/packages/massimo-cli/test/fixtures/path-map-openapi.json @@ -0,0 +1,96 @@ +{ + "openapi": "3.0.3", + "info": { "title": "Path map", "version": "1.0.0" }, + "paths": { + "/movies": { + "get": { + "operationId": "getMovies", + "responses": { + "200": { + "description": "Movies", + "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Movie" } } } } + } + } + }, + "post": { + "operationId": "createMovie", + "requestBody": { + "content": { "application/json": { "schema": { "type": "object", "required": ["title"], "properties": { "title": { "type": "string" } } } } } + }, + "responses": { + "200": { + "description": "Movie", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Movie" } } } + } + } + } + }, + "/movies/{id}": { + "get": { + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], + "responses": { + "200": { + "description": "Movie", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Movie" } } } + }, + "404": { + "description": "Not found", + "content": { "application/json": { "schema": { "type": "object", "properties": { "message": { "type": "string" } } } } } + } + } + }, + "delete": { + "operationId": "deleteMovie", + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], + "responses": { + "204": { "description": "Deleted" } + } + } + }, + "/accounts/{accountId}/folders": { + "post": { + "operationId": "createFolder", + "parameters": [{ "name": "accountId", "in": "path", "required": true, "schema": { "type": "integer" } }], + "requestBody": { + "content": { "application/json": { "schema": { "type": "object", "required": ["name"], "properties": { "name": { "type": "string" } } } } } + }, + "responses": { + "200": { + "description": "Folder", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Folder" } } } + } + } + } + }, + "/folders": { + "post": { + "operationId": "createFolder", + "requestBody": { + "content": { "application/json": { "schema": { "type": "object", "required": ["name", "accountId"], "properties": { "name": { "type": "string" }, "accountId": { "type": "integer" } } } } } + }, + "responses": { + "200": { + "description": "Folder", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Folder" } } } + } + } + } + } + }, + "components": { + "schemas": { + "Movie": { + "type": "object", + "required": ["id", "title"], + "properties": { "id": { "type": "integer" }, "title": { "type": "string" } } + }, + "Folder": { + "type": "object", + "required": ["id", "name"], + "properties": { "id": { "type": "integer" }, "name": { "type": "string" } } + }, + "MoviesPaths": { "type": "string" }, + "MoviesPathRequest": { "type": "number" } + } + } +}