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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,9 @@ massimo <url> --name myclient --type-extension

# Declare components.schemas as named types instead of inlining every $ref
massimo <url> --name myclient --named-schemas

# Declare a map from path template and HTTP method to the operation types
massimo <url> --name myclient --path-map
```

### Named Schemas
Expand All @@ -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<P extends keyof MyclientPaths, M extends keyof MyclientPaths[P]> = MyclientPaths[P][M] extends { 'request': infer R } ? R : never
export type MyclientPathResponses<P extends keyof MyclientPaths, M extends keyof MyclientPaths[P]> = 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:
Expand Down
1 change: 1 addition & 0 deletions packages/massimo-cli/help/help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Name>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 `<Name>PathRequest<P, M>` and `<Name>PathResponses<P, M>`. 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
41 changes: 28 additions & 13 deletions packages/massimo-cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ async function writeOpenAPIClient (
moduleFormat,
typeExtension,
explicitModuleFormat,
namedSchemas
namedSchemas,
pathMap
) {
await createDirectory(folder)

Expand Down Expand Up @@ -160,6 +161,7 @@ async function writeOpenAPIClient (
withCredentials,
propsOptional,
namedSchemas,
pathMap,
typeExt
})
await writeFile(join(folder, `${name}-types.${typeExt}`), types)
Expand All @@ -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'
Expand Down Expand Up @@ -250,7 +253,8 @@ async function downloadAndWriteOpenAPI (
moduleFormat,
typeExtension,
explicitModuleFormat,
namedSchemas
namedSchemas,
pathMap
) {
logger.debug(`Trying to download OpenAPI schema from ${url}`)
let requestOptions
Expand Down Expand Up @@ -291,7 +295,8 @@ async function downloadAndWriteOpenAPI (
moduleFormat,
typeExtension,
explicitModuleFormat,
namedSchemas
namedSchemas,
pathMap
)
/* c8 ignore next 3 */
} catch (err) {
Expand Down Expand Up @@ -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')
Expand All @@ -391,7 +397,8 @@ async function readFromFileAndWrite (
moduleFormat,
typeExtension,
explicitModuleFormat,
namedSchemas
namedSchemas,
pathMap
)
return 'openapi'
} catch (err) {
Expand Down Expand Up @@ -440,7 +447,8 @@ async function downloadAndProcess (options) {
typeExtension,
explicitModuleFormat,
skipPrefixedUrl,
namedSchemas
namedSchemas,
pathMap
} = options

const generateImplementation = options.generateImplementation
Expand Down Expand Up @@ -473,7 +481,8 @@ async function downloadAndProcess (options) {
moduleFormat,
typeExtension,
explicitModuleFormat,
namedSchemas
namedSchemas,
pathMap
)
)
}
Expand All @@ -500,7 +509,8 @@ async function downloadAndProcess (options) {
moduleFormat,
typeExtension,
explicitModuleFormat,
namedSchemas
namedSchemas,
pathMap
)
)
} else if (options.type === 'graphql') {
Expand Down Expand Up @@ -558,7 +568,8 @@ async function downloadAndProcess (options) {
moduleFormat,
typeExtension,
explicitModuleFormat,
namedSchemas
namedSchemas,
pathMap
)
)
toTry.push(
Expand Down Expand Up @@ -598,7 +609,8 @@ async function downloadAndProcess (options) {
moduleFormat,
typeExtension,
explicitModuleFormat,
namedSchemas
namedSchemas,
pathMap
)
)
toTry.push(
Expand Down Expand Up @@ -638,7 +650,8 @@ async function downloadAndProcess (options) {
moduleFormat,
typeExtension,
explicitModuleFormat,
namedSchemas
namedSchemas,
pathMap
)
)
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}`)
Expand Down
18 changes: 13 additions & 5 deletions packages/massimo-cli/lib/frontend-openapi-generator.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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({
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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: [],
Expand All @@ -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'];`)
Expand Down
58 changes: 58 additions & 0 deletions packages/massimo-cli/lib/openapi-common.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
// `<clientTypeName>Path` does declare `<clientTypeName>PathRequest` and
// `<clientTypeName>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.
Expand Down
22 changes: 16 additions & 6 deletions packages/massimo-cli/lib/openapi-generator.js
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -14,7 +14,8 @@ export function processOpenAPI ({
typesComment,
propsOptional,
moduleFormat,
namedSchemas
namedSchemas,
pathMap
}) {
return {
types: generateTypesFromOpenAPI({
Expand All @@ -25,7 +26,8 @@ export function processOpenAPI ({
optionalHeaders,
typesComment,
propsOptional,
namedSchemas
namedSchemas,
pathMap
}),
implementation: generateImplementationFromOpenAPI({ name, fullResponse, fullRequest, validateResponse, moduleFormat })
}
Expand Down Expand Up @@ -83,7 +85,8 @@ function generateTypesFromOpenAPI ({
optionalHeaders,
typesComment,
propsOptional,
namedSchemas
namedSchemas,
pathMap
}) {
const camelcasedName = toJavaScriptName(name)
const capitalizedName = capitalize(camelcasedName)
Expand Down Expand Up @@ -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,
Expand All @@ -159,6 +165,10 @@ function generateTypesFromOpenAPI ({
})
})

if (pathMap) {
writePathMap(interfaces, pathMapNames, pathMapEntries)
}

writer.write(interfaces.toString())

writer.blankLine()
Expand Down
Loading