From 66f8bb2495f067aa2ed3c9696e7de3c8e1990e87 Mon Sep 17 00:00:00 2001 From: Christopher Radek Date: Fri, 16 Aug 2024 10:12:47 -0700 Subject: [PATCH 1/4] tsp-openapi3 - add context for passing state/stateful methods --- .../src/cli/actions/convert/convert-file.ts | 6 +- .../src/cli/actions/convert/convert.ts | 6 +- .../convert/generators/generate-main.ts | 9 +- .../convert/generators/generate-model.ts | 77 +++--- .../convert/generators/generate-namespace.ts | 13 +- .../convert/generators/generate-operation.ts | 29 ++- .../generators/generate-service-info.ts | 5 +- .../convert/generators/generate-types.ts | 230 ++++++++--------- .../transform-component-parameters.ts | 9 +- .../transforms/transform-component-schemas.ts | 231 +++++++++--------- .../actions/convert/transforms/transforms.ts | 14 +- .../src/cli/actions/convert/utils/context.ts | 29 +++ .../convert/utils/generate-namespace-name.ts | 3 + .../test/tsp-openapi3/generate-type.test.ts | 9 +- 14 files changed, 360 insertions(+), 310 deletions(-) create mode 100644 packages/openapi3/src/cli/actions/convert/utils/context.ts create mode 100644 packages/openapi3/src/cli/actions/convert/utils/generate-namespace-name.ts diff --git a/packages/openapi3/src/cli/actions/convert/convert-file.ts b/packages/openapi3/src/cli/actions/convert/convert-file.ts index 96f0f4ce14..69b1240f08 100644 --- a/packages/openapi3/src/cli/actions/convert/convert-file.ts +++ b/packages/openapi3/src/cli/actions/convert/convert-file.ts @@ -6,15 +6,17 @@ import { handleInternalCompilerError } from "../../utils.js"; import { ConvertCliArgs } from "./args.js"; import { generateMain } from "./generators/generate-main.js"; import { transform } from "./transforms/transforms.js"; +import { createContext } from "./utils/context.js"; export async function convertAction(host: CliHost, args: ConvertCliArgs) { // attempt to read the file const fullPath = resolvePath(process.cwd(), args.path); const model = await parseOpenApiFile(fullPath); - const program = transform(model); + const context = createContext(model); + const program = transform(context); let mainTsp: string; try { - mainTsp = generateMain(program); + mainTsp = generateMain(program, context); } catch (err) { handleInternalCompilerError(err); } diff --git a/packages/openapi3/src/cli/actions/convert/convert.ts b/packages/openapi3/src/cli/actions/convert/convert.ts index 051d9a96fc..010f2e4cc7 100644 --- a/packages/openapi3/src/cli/actions/convert/convert.ts +++ b/packages/openapi3/src/cli/actions/convert/convert.ts @@ -2,10 +2,12 @@ import { formatTypeSpec } from "@typespec/compiler"; import { OpenAPI3Document } from "../../../types.js"; import { generateMain } from "./generators/generate-main.js"; import { transform } from "./transforms/transforms.js"; +import { createContext } from "./utils/context.js"; export async function convertOpenAPI3Document(document: OpenAPI3Document) { - const program = transform(document); - const content = generateMain(program); + const context = createContext(document); + const program = transform(context); + const content = generateMain(program, context); try { return await formatTypeSpec(content, { printWidth: 100, diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-main.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-main.ts index 578af810dc..b4c518804d 100644 --- a/packages/openapi3/src/cli/actions/convert/generators/generate-main.ts +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-main.ts @@ -1,10 +1,11 @@ import { TypeSpecProgram } from "../interfaces.js"; +import { Context } from "../utils/context.js"; import { generateDataType } from "./generate-model.js"; import { generateNamespace } from "./generate-namespace.js"; import { generateOperation } from "./generate-operation.js"; import { generateServiceInformation } from "./generate-service-info.js"; -export function generateMain(program: TypeSpecProgram): string { +export function generateMain(program: TypeSpecProgram, context: Context): string { return ` import "@typespec/http"; import "@typespec/openapi"; @@ -15,12 +16,12 @@ export function generateMain(program: TypeSpecProgram): string { ${generateServiceInformation(program.serviceInfo)} - ${program.types.map(generateDataType).join("\n\n")} + ${program.types.map((t) => generateDataType(t, context)).join("\n\n")} - ${program.operations.map(generateOperation).join("\n\n")} + ${program.operations.map((o) => generateOperation(o, context)).join("\n\n")} ${Object.entries(program.namespaces) - .map(([name, namespace]) => generateNamespace(name, namespace)) + .map(([name, namespace]) => generateNamespace(name, namespace, context)) .join("\n\n")} `; } diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-model.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-model.ts index 0de76ba589..95e079f3cb 100644 --- a/packages/openapi3/src/cli/actions/convert/generators/generate-model.ts +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-model.ts @@ -1,43 +1,38 @@ -import { OpenAPI3Schema, Refable } from "../../../../types.js"; import { TypeSpecAlias, TypeSpecDataTypes, TypeSpecEnum, TypeSpecModel, - TypeSpecModelProperty, TypeSpecScalar, TypeSpecUnion, } from "../interfaces.js"; +import { Context } from "../utils/context.js"; import { getDecoratorsForSchema } from "../utils/decorators.js"; import { generateDocs } from "../utils/docs.js"; import { generateDecorators } from "./generate-decorators.js"; -import { - generateTypeFromSchema, - getRefScopeAndName, - getTypeSpecPrimitiveFromSchema, -} from "./generate-types.js"; +import { getTypeSpecPrimitiveFromSchema } from "./generate-types.js"; -export function generateDataType(type: TypeSpecDataTypes): string { +export function generateDataType(type: TypeSpecDataTypes, context: Context): string { switch (type.kind) { case "alias": - return generateAlias(type); + return generateAlias(type, context); case "enum": return generateEnum(type); case "model": - return generateModel(type); + return generateModel(type, context); case "scalar": - return generateScalar(type); + return generateScalar(type, context); case "union": - return generateUnion(type); + return generateUnion(type, context); } } -function generateAlias(alias: TypeSpecAlias): string { +function generateAlias(alias: TypeSpecAlias, context: Context): string { // Since aliases are not represented in the TypeGraph, // generate a model so that the model name is present in emitted OpenAPI3. // May revisit to allow emitting actual alias. - const { scope, name } = getRefScopeAndName(alias.ref); - return `model ${alias.name} is ${[...scope, name].join(".")};`; + const sourceModel = context.getRefName(alias.ref, alias.scope); + return `model ${alias.name} is ${sourceModel};`; } function generateEnum(tsEnum: TypeSpecEnum): string { @@ -61,7 +56,7 @@ function generateEnum(tsEnum: TypeSpecEnum): string { return definitions.join("\n"); } -function generateScalar(scalar: TypeSpecScalar): string { +function generateScalar(scalar: TypeSpecScalar, context: Context): string { const definitions: string[] = []; if (scalar.doc) { @@ -69,14 +64,14 @@ function generateScalar(scalar: TypeSpecScalar): string { } definitions.push(...generateDecorators(scalar.decorators)); - const type = generateTypeFromSchema(scalar.schema); + const type = context.generateTypeFromRefableSchema(scalar.schema, scalar.scope); definitions.push(`scalar ${scalar.name} extends ${type};`); return definitions.join("\n"); } -function generateUnion(union: TypeSpecUnion): string { +function generateUnion(union: TypeSpecUnion, context: Context): string { const definitions: string[] = []; if (union.doc) { @@ -92,9 +87,13 @@ function generateUnion(union: TypeSpecUnion): string { if (schema.enum) { definitions.push(...schema.enum.map((e) => `${JSON.stringify(e)},`)); } else if (schema.oneOf) { - definitions.push(...schema.oneOf.map(generateUnionMember)); + definitions.push( + ...schema.oneOf.map((member) => context.generateTypeFromRefableSchema(member, union.scope)) + ); } else if (schema.anyOf) { - definitions.push(...schema.anyOf.map(generateUnionMember)); + definitions.push( + ...schema.anyOf.map((member) => context.generateTypeFromRefableSchema(member, union.scope)) + ); } else { // check if it's a primitive type const primitiveType = getTypeSpecPrimitiveFromSchema(schema); @@ -112,11 +111,7 @@ function generateUnion(union: TypeSpecUnion): string { return definitions.join("\n"); } -function generateUnionMember(member: Refable): string { - return `${generateTypeFromSchema(member)},`; -} - -export function generateModel(model: TypeSpecModel): string { +function generateModel(model: TypeSpecModel, context: Context): string { const definitions: string[] = []; const modelDeclaration = generateModelDeclaration(model); @@ -127,10 +122,25 @@ export function generateModel(model: TypeSpecModel): string { definitions.push(...generateDecorators(model.decorators)); definitions.push(modelDeclaration.open); - definitions.push(...model.properties.map(generateModelProperty)); + definitions.push( + ...model.properties.map((prop) => { + // Decorators will be a combination of top-level (parameters) and + // schema-level decorators. + const decorators = generateDecorators([ + ...prop.decorators, + ...getDecoratorsForSchema(prop.schema), + ]).join(" "); + + const doc = prop.doc ? generateDocs(prop.doc) : ""; + + return `${doc}${decorators} ${prop.name}${prop.isOptional ? "?" : ""}: ${context.generateTypeFromRefableSchema(prop.schema, model.scope)};`; + }) + ); if (model.additionalProperties) { - definitions.push(`...Record<${generateTypeFromSchema(model.additionalProperties)}>;`); + definitions.push( + `...Record<${context.generateTypeFromRefableSchema(model.additionalProperties, model.scope)}>;` + ); } if (modelDeclaration.close) definitions.push(modelDeclaration.close); @@ -158,16 +168,3 @@ function generateModelDeclaration(model: TypeSpecModel): ModelDeclarationOutput return { open: `model ${modelName} {`, close: "}" }; } - -function generateModelProperty(property: TypeSpecModelProperty): string { - // Decorators will be a combination of top-level (parameters) and - // schema-level decorators. - const decorators = generateDecorators([ - ...property.decorators, - ...getDecoratorsForSchema(property.schema), - ]).join(" "); - - const doc = property.doc ? generateDocs(property.doc) : ""; - - return `${doc}${decorators} ${property.name}${property.isOptional ? "?" : ""}: ${generateTypeFromSchema(property.schema)};`; -} diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-namespace.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-namespace.ts index a4bf27b841..c2c425fd7f 100644 --- a/packages/openapi3/src/cli/actions/convert/generators/generate-namespace.ts +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-namespace.ts @@ -1,16 +1,21 @@ import { TypeSpecNamespace } from "../interfaces.js"; +import { Context } from "../utils/context.js"; import { generateDataType } from "./generate-model.js"; import { generateOperation } from "./generate-operation.js"; -export function generateNamespace(name: string, namespace: TypeSpecNamespace): string { +export function generateNamespace( + name: string, + namespace: TypeSpecNamespace, + context: Context +): string { const definitions: string[] = []; definitions.push(`namespace ${name} {`); - definitions.push(...namespace.types.map(generateDataType)); - definitions.push(...namespace.operations.map(generateOperation)); + definitions.push(...namespace.types.map((t) => generateDataType(t, context))); + definitions.push(...namespace.operations.map((o) => generateOperation(o, context))); for (const [namespaceName, nestedNamespace] of Object.entries(namespace.namespaces)) { - definitions.push(generateNamespace(namespaceName, nestedNamespace)); + definitions.push(generateNamespace(namespaceName, nestedNamespace, context)); } definitions.push("}"); diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-operation.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-operation.ts index 7686c56950..a694635354 100644 --- a/packages/openapi3/src/cli/actions/convert/generators/generate-operation.ts +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-operation.ts @@ -4,11 +4,11 @@ import { TypeSpecOperationParameter, TypeSpecRequestBody, } from "../interfaces.js"; +import { Context } from "../utils/context.js"; import { generateDocs } from "../utils/docs.js"; import { generateDecorators } from "./generate-decorators.js"; -import { generateTypeFromSchema, getRefName } from "./generate-types.js"; -export function generateOperation(operation: TypeSpecOperation): string { +export function generateOperation(operation: TypeSpecOperation, context: Context): string { const definitions: string[] = []; if (operation.doc) { @@ -21,8 +21,8 @@ export function generateOperation(operation: TypeSpecOperation): string { // generate parameters const parameters: string[] = [ - ...operation.parameters.map(generateOperationParameter), - ...generateRequestBodyParameters(operation.requestBodies), + ...operation.parameters.map((p) => generateOperationParameter(operation, p, context)), + ...generateRequestBodyParameters(operation.requestBodies, context), ]; const responseTypes = operation.responseTypes.length @@ -34,10 +34,14 @@ export function generateOperation(operation: TypeSpecOperation): string { return definitions.join(" "); } -function generateOperationParameter(parameter: Refable) { +function generateOperationParameter( + operation: TypeSpecOperation, + parameter: Refable, + context: Context +) { if ("$ref" in parameter) { // check if referencing a model or a property - const refName = getRefName(parameter.$ref); + const refName = context.getRefName(parameter.$ref, operation.scope); const paramName = refName.indexOf(".") >= 0 ? refName.split(".").pop() : refName; // when refName and paramName match, we're referencing a model and can spread // TODO: Handle optionality @@ -53,13 +57,16 @@ function generateOperationParameter(parameter: Refable !!r.schema).map((r) => generateTypeFromSchema(r.schema!))) + new Set( + requestBodies + .filter((r) => !!r.schema) + .map((r) => context.generateTypeFromRefableSchema(r.schema!, [])) + ) ).join(" | "); if (body) { diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-service-info.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-service-info.ts index be55da1c6a..1abd57ce87 100644 --- a/packages/openapi3/src/cli/actions/convert/generators/generate-service-info.ts +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-service-info.ts @@ -1,5 +1,6 @@ import { TypeSpecServiceInfo } from "../interfaces.js"; import { generateDocs } from "../utils/docs.js"; +import { generateNamespaceName } from "../utils/generate-namespace-name.js"; export function generateServiceInformation(serviceInfo: TypeSpecServiceInfo): string { const definitions: string[] = []; @@ -21,7 +22,3 @@ export function generateServiceInformation(serviceInfo: TypeSpecServiceInfo): st return definitions.join("\n"); } - -function generateNamespaceName(name: string): string { - return name.replaceAll(/[^\w^\d_]+/g, ""); -} diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts index 40990ff69e..c6b53fffb6 100644 --- a/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts @@ -4,146 +4,154 @@ import { getDecoratorsForSchema } from "../utils/decorators.js"; import { getScopeAndName } from "../utils/get-scope-and-name.js"; import { generateDecorators } from "./generate-decorators.js"; -export function generateTypeFromSchema(schema: Refable): string { - return getTypeFromRefableSchema(schema); -} - -function getTypeFromRefableSchema(schema: Refable): string { - const hasRef = "$ref" in schema; - return hasRef ? getRefName(schema.$ref) : getTypeFromSchema(schema); -} - -export function getTypeSpecPrimitiveFromSchema(schema: OpenAPI3Schema): string | undefined { - if (schema.type === "boolean") { - return "boolean"; - } else if (schema.type === "integer") { - return getIntegerType(schema); - } else if (schema.type === "number") { - return getNumberType(schema); - } else if (schema.type === "string") { - return getStringType(schema); +export class SchemaToExpressionGenerator { + constructor(public rootNamespace: string) {} + + public generateTypeFromRefableSchema( + schema: Refable, + callingScope: string[] + ): string { + const hasRef = "$ref" in schema; + return hasRef + ? this.getRefName(schema.$ref, callingScope) + : this.getTypeFromSchema(schema, callingScope); } - return; -} -function getTypeFromSchema(schema: OpenAPI3Schema): string { - let type = "unknown"; - - if (schema.enum) { - type = getEnum(schema.enum); - } else if (schema.anyOf) { - type = getAnyOfType(schema); - } else if (schema.type === "array") { - type = getArrayType(schema); - } else if (schema.type === "boolean") { - type = "boolean"; - } else if (schema.type === "integer") { - type = getIntegerType(schema); - } else if (schema.type === "number") { - type = getNumberType(schema); - } else if (schema.type === "object") { - type = getObjectType(schema); - } else if (schema.oneOf) { - type = getOneOfType(schema); - } else if (schema.type === "string") { - type = getStringType(schema); - } + public generateArrayType(schema: OpenAPI3Schema, callingScope: string[]): string { + const items = schema.items; + if (!items) { + return "unknown[]"; + } - if (schema.nullable) { - type += ` | null`; + if ("$ref" in items) { + return `${this.getRefName(items.$ref, callingScope)}[]`; + } + + // Prettier will get rid of the extra parenthesis for us + return `(${this.getTypeFromSchema(items, callingScope)})[]`; } - if (schema.default) { - type += ` = ${JSON.stringify(schema.default)}`; + public getRefName(ref: string, callingScope: string[]): string { + const { scope, name } = this.getRefScopeAndName(ref, callingScope); + return [...scope, name].join("."); } - return type; -} + private getRefScopeAndName( + ref: string, + _callingScope: string[] + ): ReturnType { + const parts = ref.split("/"); + const name = parts.pop() ?? ""; + const scopeAndName = getScopeAndName(name); -export function getRefName(ref: string): string { - const { scope, name } = getRefScopeAndName(ref); - return [...scope, name].join("."); -} + return scopeAndName; + } -export function getRefScopeAndName(ref: string): ReturnType { - const parts = ref.split("/"); - const name = parts.pop() ?? ""; - const scopeAndName = getScopeAndName(name); + private getTypeFromSchema(schema: OpenAPI3Schema, callingScope: string[]): string { + let type = "unknown"; + + if (schema.enum) { + type = getEnum(schema.enum); + } else if (schema.anyOf) { + type = this.getAnyOfType(schema, callingScope); + } else if (schema.type === "array") { + type = this.generateArrayType(schema, callingScope); + } else if (schema.type === "boolean") { + type = "boolean"; + } else if (schema.type === "integer") { + type = getIntegerType(schema); + } else if (schema.type === "number") { + type = getNumberType(schema); + } else if (schema.type === "object") { + type = this.getObjectType(schema, callingScope); + } else if (schema.oneOf) { + type = this.getOneOfType(schema, callingScope); + } else if (schema.type === "string") { + type = getStringType(schema); + } - return scopeAndName; -} + if (schema.nullable) { + type += ` | null`; + } -function getAnyOfType(schema: OpenAPI3Schema): string { - const definitions: string[] = []; + if (schema.default) { + type += ` = ${JSON.stringify(schema.default)}`; + } - for (const item of schema.anyOf ?? []) { - definitions.push(generateTypeFromSchema(item)); + return type; } - return definitions.join(" | "); -} + private getAnyOfType(schema: OpenAPI3Schema, callingScope: string[]): string { + const definitions: string[] = []; -function getOneOfType(schema: OpenAPI3Schema): string { - const definitions: string[] = []; + for (const item of schema.anyOf ?? []) { + definitions.push(this.generateTypeFromRefableSchema(item, callingScope)); + } - for (const item of schema.oneOf ?? []) { - definitions.push(generateTypeFromSchema(item)); + return definitions.join(" | "); } - return definitions.join(" | "); -} + private getOneOfType(schema: OpenAPI3Schema, callingScope: string[]): string { + const definitions: string[] = []; -function getObjectType(schema: OpenAPI3Schema): string { - // If we have `additionalProperties`, treat that as an 'indexer' and convert to a record. - const recordType = - typeof schema.additionalProperties === "object" - ? `Record<${getTypeFromRefableSchema(schema.additionalProperties)}>` - : ""; + for (const item of schema.oneOf ?? []) { + definitions.push(this.generateTypeFromRefableSchema(item, callingScope)); + } - if (!schema.properties && recordType) { - return recordType; + return definitions.join(" | "); } - const requiredProps = schema.required ?? []; - - const props: string[] = []; - if (schema.properties) { - for (const name of Object.keys(schema.properties)) { - const decorators = generateDecorators(getDecoratorsForSchema(schema.properties[name])) - .map((d) => `${d}\n`) - .join(""); - const isOptional = !requiredProps.includes(name) ? "?" : ""; - props.push( - `${decorators}${printIdentifier(name)}${isOptional}: ${getTypeFromRefableSchema(schema.properties[name])}` - ); + private getObjectType(schema: OpenAPI3Schema, callingScope: string[]): string { + // If we have `additionalProperties`, treat that as an 'indexer' and convert to a record. + const recordType = + typeof schema.additionalProperties === "object" + ? `Record<${this.generateTypeFromRefableSchema(schema.additionalProperties, callingScope)}>` + : ""; + + if (!schema.properties && recordType) { + return recordType; } - } - const propertyCount = Object.keys(props).length; - if (recordType && !propertyCount) { - return recordType; - } else if (recordType && propertyCount) { - props.push(`...${recordType}`); - } + const requiredProps = schema.required ?? []; + + const props: string[] = []; + if (schema.properties) { + for (const name of Object.keys(schema.properties)) { + const decorators = generateDecorators(getDecoratorsForSchema(schema.properties[name])) + .map((d) => `${d}\n`) + .join(""); + const isOptional = !requiredProps.includes(name) ? "?" : ""; + props.push( + `${decorators}${printIdentifier(name)}${isOptional}: ${this.generateTypeFromRefableSchema(schema.properties[name], callingScope)}` + ); + } + } - return `{${props.join("; ")}}`; -} + const propertyCount = Object.keys(props).length; + if (recordType && !propertyCount) { + return recordType; + } else if (recordType && propertyCount) { + props.push(`...${recordType}`); + } -export function getArrayType(schema: OpenAPI3Schema): string { - const items = schema.items; - if (!items) { - return "unknown[]"; + return `{${props.join("; ")}}`; } +} - if ("$ref" in items) { - return `${getRefName(items.$ref)}[]`; +export function getTypeSpecPrimitiveFromSchema(schema: OpenAPI3Schema): string | undefined { + if (schema.type === "boolean") { + return "boolean"; + } else if (schema.type === "integer") { + return getIntegerType(schema); + } else if (schema.type === "number") { + return getNumberType(schema); + } else if (schema.type === "string") { + return getStringType(schema); } - - // Prettier will get rid of the extra parenthesis for us - return `(${getTypeFromSchema(items)})[]`; + return; } -export function getIntegerType(schema: OpenAPI3Schema): string { +function getIntegerType(schema: OpenAPI3Schema): string { const format = schema.format ?? ""; switch (format) { case "int8": @@ -162,7 +170,7 @@ export function getIntegerType(schema: OpenAPI3Schema): string { } } -export function getNumberType(schema: OpenAPI3Schema): string { +function getNumberType(schema: OpenAPI3Schema): string { const format = schema.format ?? ""; switch (format) { case "decimal": @@ -178,7 +186,7 @@ export function getNumberType(schema: OpenAPI3Schema): string { } } -export function getStringType(schema: OpenAPI3Schema): string { +function getStringType(schema: OpenAPI3Schema): string { const format = schema.format ?? ""; let type = "string"; switch (format) { diff --git a/packages/openapi3/src/cli/actions/convert/transforms/transform-component-parameters.ts b/packages/openapi3/src/cli/actions/convert/transforms/transform-component-parameters.ts index 3d26e76e8b..2b0c7f9ccb 100644 --- a/packages/openapi3/src/cli/actions/convert/transforms/transform-component-parameters.ts +++ b/packages/openapi3/src/cli/actions/convert/transforms/transform-component-parameters.ts @@ -1,6 +1,7 @@ import { printIdentifier } from "@typespec/compiler"; -import { OpenAPI3Components, OpenAPI3Parameter } from "../../../../types.js"; +import { OpenAPI3Parameter } from "../../../../types.js"; import { TypeSpecModel, TypeSpecModelProperty } from "../interfaces.js"; +import { Context } from "../utils/context.js"; import { getParameterDecorators } from "../utils/decorators.js"; import { getScopeAndName, scopesMatch } from "../utils/get-scope-and-name.js"; @@ -12,10 +13,8 @@ import { getScopeAndName, scopesMatch } from "../utils/get-scope-and-name.js"; * @param parameters * @returns */ -export function transformComponentParameters( - models: TypeSpecModel[], - parameters?: OpenAPI3Components["parameters"] -): void { +export function transformComponentParameters(context: Context, models: TypeSpecModel[]): void { + const parameters = context.openApi3Doc.components?.parameters; if (!parameters) return; for (const name of Object.keys(parameters)) { diff --git a/packages/openapi3/src/cli/actions/convert/transforms/transform-component-schemas.ts b/packages/openapi3/src/cli/actions/convert/transforms/transform-component-schemas.ts index 0278a35f74..97b2a389a6 100644 --- a/packages/openapi3/src/cli/actions/convert/transforms/transform-component-schemas.ts +++ b/packages/openapi3/src/cli/actions/convert/transforms/transform-component-schemas.ts @@ -1,12 +1,5 @@ import { printIdentifier } from "@typespec/compiler"; -import { OpenAPI3Components, OpenAPI3Schema, Refable } from "../../../../types.js"; -import { - getArrayType, - getIntegerType, - getNumberType, - getRefName, - getStringType, -} from "../generators/generate-types.js"; +import { OpenAPI3Schema, Refable } from "../../../../types.js"; import { TypeSpecDataTypes, TypeSpecEnum, @@ -14,6 +7,7 @@ import { TypeSpecModelProperty, TypeSpecUnion, } from "../interfaces.js"; +import { Context } from "../utils/context.js"; import { getDecoratorsForSchema } from "../utils/decorators.js"; import { getScopeAndName } from "../utils/get-scope-and-name.js"; @@ -24,141 +18,138 @@ import { getScopeAndName } from "../utils/get-scope-and-name.js"; * @param schemas * @returns */ -export function transformComponentSchemas( - models: TypeSpecModel[], - schemas?: OpenAPI3Components["schemas"] -): void { +export function transformComponentSchemas(context: Context, models: TypeSpecModel[]): void { + const schemas = context.openApi3Doc.components?.schemas; if (!schemas) return; for (const name of Object.keys(schemas)) { const schema = schemas[name]; transformComponentSchema(models, name, schema); } -} -function transformComponentSchema( - types: TypeSpecDataTypes[], - name: string, - schema: OpenAPI3Schema -): void { - const kind = getTypeSpecKind(schema); - switch (kind) { - case "alias": - return populateAlias(types, name, schema); - case "enum": - return populateEnum(types, name, schema); - case "model": - return populateModel(types, name, schema); - case "union": - return populateUnion(types, name, schema); - case "scalar": - return populateScalar(types, name, schema); + return; + function transformComponentSchema( + types: TypeSpecDataTypes[], + name: string, + schema: OpenAPI3Schema + ): void { + const kind = getTypeSpecKind(schema); + switch (kind) { + case "alias": + return populateAlias(types, name, schema); + case "enum": + return populateEnum(types, name, schema); + case "model": + return populateModel(types, name, schema); + case "union": + return populateUnion(types, name, schema); + case "scalar": + return populateScalar(types, name, schema); + } } -} -function populateAlias( - types: TypeSpecDataTypes[], - name: string, - schema: Refable -): void { - if (!("$ref" in schema)) { - return; + function populateAlias( + types: TypeSpecDataTypes[], + rawName: string, + schema: Refable + ): void { + if (!("$ref" in schema)) { + return; + } + + const { name, scope } = getScopeAndName(rawName); + + types.push({ + kind: "alias", + name, + scope, + doc: schema.description, + ref: context.getRefName(schema.$ref, scope), + }); } - types.push({ - kind: "alias", - ...getScopeAndName(name), - doc: schema.description, - ref: getRefName(schema.$ref), - }); -} - -function populateEnum(types: TypeSpecDataTypes[], name: string, schema: OpenAPI3Schema): void { - const tsEnum: TypeSpecEnum = { - kind: "enum", - ...getScopeAndName(name), - decorators: getDecoratorsForSchema(schema), - doc: schema.description, - schema, - }; + function populateEnum(types: TypeSpecDataTypes[], name: string, schema: OpenAPI3Schema): void { + const tsEnum: TypeSpecEnum = { + kind: "enum", + ...getScopeAndName(name), + decorators: getDecoratorsForSchema(schema), + doc: schema.description, + schema, + }; - types.push(tsEnum); -} - -function populateScalar(types: TypeSpecDataTypes[], name: string, schema: OpenAPI3Schema): void { - types.push({ - kind: "scalar", - ...getScopeAndName(name), - decorators: getDecoratorsForSchema(schema), - doc: schema.description, - schema, - }); -} - -function populateUnion(types: TypeSpecDataTypes[], name: string, schema: OpenAPI3Schema): void { - const union: TypeSpecUnion = { - kind: "union", - ...getScopeAndName(name), - decorators: getDecoratorsForSchema(schema), - doc: schema.description, - schema, - }; + types.push(tsEnum); + } - types.push(union); -} + function populateModel( + types: TypeSpecDataTypes[], + rawName: string, + schema: OpenAPI3Schema + ): void { + const { name, scope } = getScopeAndName(rawName); + const extendsParent = getModelExtends(schema, scope); + const isParent = getModelIs(schema, scope); + types.push({ + kind: "model", + name, + scope, + decorators: [...getDecoratorsForSchema(schema)], + doc: schema.description, + properties: getModelPropertiesFromObjectSchema(schema), + additionalProperties: + typeof schema.additionalProperties === "object" ? schema.additionalProperties : undefined, + extends: extendsParent, + is: isParent, + type: schema.type, + }); + } -function populateModel(types: TypeSpecDataTypes[], name: string, schema: OpenAPI3Schema): void { - const extendsParent = getModelExtends(schema); - const isParent = getModelIs(schema); - types.push({ - kind: "model", - ...getScopeAndName(name), - decorators: [...getDecoratorsForSchema(schema)], - doc: schema.description, - properties: getModelPropertiesFromObjectSchema(schema), - additionalProperties: - typeof schema.additionalProperties === "object" ? schema.additionalProperties : undefined, - extends: extendsParent, - is: isParent, - type: schema.type, - }); -} + function populateUnion(types: TypeSpecDataTypes[], name: string, schema: OpenAPI3Schema): void { + const union: TypeSpecUnion = { + kind: "union", + ...getScopeAndName(name), + decorators: getDecoratorsForSchema(schema), + doc: schema.description, + schema, + }; -function getModelExtends(schema: OpenAPI3Schema): string | undefined { - switch (schema.type) { - case "boolean": - return "boolean"; - case "integer": - return getIntegerType(schema); - case "number": - return getNumberType(schema); - case "string": - return getStringType(schema); + types.push(union); } - if (schema.type !== "object" || !schema.allOf) { - return; + function populateScalar(types: TypeSpecDataTypes[], name: string, schema: OpenAPI3Schema): void { + types.push({ + kind: "scalar", + ...getScopeAndName(name), + decorators: getDecoratorsForSchema(schema), + doc: schema.description, + schema, + }); } - if (schema.allOf.length !== 1) { - // TODO: Emit warning - can't extend more than 1 model - return; - } + function getModelExtends(schema: OpenAPI3Schema, callingScope: string[]): string | undefined { + if (schema.type !== "object" || !schema.allOf) { + return; + } - const parent = schema.allOf[0]; - if (!parent || !("$ref" in parent)) { - // TODO: Error getting parent - must be a reference, not expression - return; - } + if (schema.allOf.length !== 1) { + // TODO: Emit warning - can't extend more than 1 model + return; + } - return getRefName(parent.$ref); -} + const parent = schema.allOf[0]; + if (!parent || !("$ref" in parent)) { + // TODO: Error getting parent - must be a reference, not expression + return; + } + + return context.getRefName(parent.$ref, callingScope); + } -function getModelIs(schema: OpenAPI3Schema): string | undefined { - if (schema.type !== "array") { - return; + function getModelIs(schema: OpenAPI3Schema, callingScope: string[]): string | undefined { + if (schema.type !== "array") { + return; + } + return context.generateTypeFromRefableSchema(schema, callingScope); } - return getArrayType(schema); } function getModelPropertiesFromObjectSchema({ diff --git a/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts b/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts index ba60aae37c..222b238c78 100644 --- a/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts +++ b/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts @@ -1,13 +1,14 @@ -import { OpenAPI3Document } from "../../../../types.js"; import { TypeSpecModel, TypeSpecProgram } from "../interfaces.js"; +import { Context } from "../utils/context.js"; import { transformComponentParameters } from "./transform-component-parameters.js"; import { transformComponentSchemas } from "./transform-component-schemas.js"; import { transformNamespaces } from "./transform-namespaces.js"; import { transformPaths } from "./transform-paths.js"; import { transformServiceInfo } from "./transform-service-info.js"; -export function transform(openapi: OpenAPI3Document): TypeSpecProgram { - const models = collectModels(openapi); +export function transform(context: Context): TypeSpecProgram { + const openapi = context.openApi3Doc; + const models = collectDataTypes(context); const operations = transformPaths(models, openapi.paths); return { @@ -17,13 +18,12 @@ export function transform(openapi: OpenAPI3Document): TypeSpecProgram { }; } -function collectModels(document: OpenAPI3Document): TypeSpecModel[] { +function collectDataTypes(context: Context): TypeSpecModel[] { const models: TypeSpecModel[] = []; - const components = document.components; // get models from `#/components/schema - transformComponentSchemas(models, components?.schemas); + transformComponentSchemas(context, models); // get models from `#/components/parameters - transformComponentParameters(models, components?.parameters); + transformComponentParameters(context, models); return models; } diff --git a/packages/openapi3/src/cli/actions/convert/utils/context.ts b/packages/openapi3/src/cli/actions/convert/utils/context.ts new file mode 100644 index 0000000000..805e1466d6 --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/utils/context.ts @@ -0,0 +1,29 @@ +import { OpenAPI3Document, OpenAPI3Schema, Refable } from "../../../../types.js"; +import { SchemaToExpressionGenerator } from "../generators/generate-types.js"; +import { generateNamespaceName } from "./generate-namespace-name.js"; + +export interface Context { + readonly openApi3Doc: OpenAPI3Document; + readonly rootNamespace: string; + + generateTypeFromRefableSchema(schema: Refable, callingScope: string[]): string; + getRefName(ref: string, callingScope: string[]): string; +} + +export function createContext(openApi3Doc: OpenAPI3Document): Context { + const rootNamespace = generateNamespaceName(openApi3Doc.info.title); + const schemaExpressionGenerator = new SchemaToExpressionGenerator(rootNamespace); + + const context: Context = { + openApi3Doc, + rootNamespace, + getRefName(ref: string, callingScope: string[]) { + return schemaExpressionGenerator.getRefName(ref, callingScope); + }, + generateTypeFromRefableSchema(schema: Refable, callingScope: string[]) { + return schemaExpressionGenerator.generateTypeFromRefableSchema(schema, callingScope); + }, + }; + + return context; +} diff --git a/packages/openapi3/src/cli/actions/convert/utils/generate-namespace-name.ts b/packages/openapi3/src/cli/actions/convert/utils/generate-namespace-name.ts new file mode 100644 index 0000000000..dca77d4d34 --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/utils/generate-namespace-name.ts @@ -0,0 +1,3 @@ +export function generateNamespaceName(name: string): string { + return name.replaceAll(/[^\w^\d_]+/g, ""); +} diff --git a/packages/openapi3/test/tsp-openapi3/generate-type.test.ts b/packages/openapi3/test/tsp-openapi3/generate-type.test.ts index 85e5f42788..cc8c4d322c 100644 --- a/packages/openapi3/test/tsp-openapi3/generate-type.test.ts +++ b/packages/openapi3/test/tsp-openapi3/generate-type.test.ts @@ -1,7 +1,7 @@ import { formatTypeSpec } from "@typespec/compiler"; import { strictEqual } from "node:assert"; import { describe, it } from "vitest"; -import { generateTypeFromSchema } from "../../src/cli/actions/convert/generators/generate-types.js"; +import { createContext } from "../../src/cli/actions/convert/utils/context.js"; import { OpenAPI3Schema, Refable } from "../../src/types.js"; interface TestScenario { @@ -145,9 +145,14 @@ const testScenarios: TestScenario[] = [ ]; describe("tsp-openapi: generate-type", () => { + const context = createContext({ + openapi: "3.0.0", + info: { title: "Test", version: "1.0.0" }, + paths: {}, + }); testScenarios.forEach((t) => it(`${generateScenarioName(t)}`, async () => { - const type = generateTypeFromSchema(t.schema); + const type = context.generateTypeFromRefableSchema(t.schema, []); const wrappedType = await formatWrappedType(type); const wrappedExpected = await formatWrappedType(t.expected); strictEqual(wrappedType, wrappedExpected); From aa9b8f902626b51c796a773a7ea2b86bbc47739b Mon Sep 17 00:00:00 2001 From: Christopher Radek Date: Fri, 16 Aug 2024 10:41:05 -0700 Subject: [PATCH 2/4] add scoping to parameter models --- .../convert/generators/generate-operation.ts | 7 +- .../convert/generators/generate-types.ts | 27 ++- .../transform-component-parameters.ts | 46 ++---- .../output/escaped-identifiers/main.tsp | 12 +- .../output/param-decorators/main.tsp | 24 +-- .../output/playground-http-service/main.tsp | 12 +- .../test/tsp-openapi3/parameters.test.ts | 155 ++++++++++++++++++ .../tsp-openapi3/utils/tsp-for-openapi3.ts | 13 +- 8 files changed, 243 insertions(+), 53 deletions(-) create mode 100644 packages/openapi3/test/tsp-openapi3/parameters.test.ts diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-operation.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-operation.ts index a694635354..39003cfefe 100644 --- a/packages/openapi3/src/cli/actions/convert/generators/generate-operation.ts +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-operation.ts @@ -40,12 +40,7 @@ function generateOperationParameter( context: Context ) { if ("$ref" in parameter) { - // check if referencing a model or a property - const refName = context.getRefName(parameter.$ref, operation.scope); - const paramName = refName.indexOf(".") >= 0 ? refName.split(".").pop() : refName; - // when refName and paramName match, we're referencing a model and can spread - // TODO: Handle optionality - return refName === paramName ? `...${refName}` : `${paramName}: ${refName}`; + return `...${context.getRefName(parameter.$ref, operation.scope)}`; } const definitions: string[] = []; diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts index c6b53fffb6..328c7d3e8a 100644 --- a/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts @@ -38,12 +38,37 @@ export class SchemaToExpressionGenerator { private getRefScopeAndName( ref: string, - _callingScope: string[] + callingScope: string[] ): ReturnType { const parts = ref.split("/"); const name = parts.pop() ?? ""; + const componentType = parts.pop()?.toLowerCase() ?? ""; const scopeAndName = getScopeAndName(name); + switch (componentType) { + case "schemas": + if (callingScope.length) { + /* + Since schemas are generated in the file namespace, + need to reference them against the file namespace + to prevent name collisions. + Example: + namespace Service; + scalar Foo extends string; + namespace Parameters { + model Foo { + @query foo: Service.Foo + } + } + */ + scopeAndName.scope.unshift(this.rootNamespace); + } + break; + case "parameters": + scopeAndName.scope.unshift("Parameters"); + break; + } + return scopeAndName; } diff --git a/packages/openapi3/src/cli/actions/convert/transforms/transform-component-parameters.ts b/packages/openapi3/src/cli/actions/convert/transforms/transform-component-parameters.ts index 2b0c7f9ccb..45966d5abc 100644 --- a/packages/openapi3/src/cli/actions/convert/transforms/transform-component-parameters.ts +++ b/packages/openapi3/src/cli/actions/convert/transforms/transform-component-parameters.ts @@ -1,9 +1,9 @@ import { printIdentifier } from "@typespec/compiler"; import { OpenAPI3Parameter } from "../../../../types.js"; -import { TypeSpecModel, TypeSpecModelProperty } from "../interfaces.js"; +import { TypeSpecDataTypes, TypeSpecModelProperty } from "../interfaces.js"; import { Context } from "../utils/context.js"; import { getParameterDecorators } from "../utils/decorators.js"; -import { getScopeAndName, scopesMatch } from "../utils/get-scope-and-name.js"; +import { getScopeAndName } from "../utils/get-scope-and-name.js"; /** * Transforms #/components/parameters into TypeSpec models. @@ -13,47 +13,35 @@ import { getScopeAndName, scopesMatch } from "../utils/get-scope-and-name.js"; * @param parameters * @returns */ -export function transformComponentParameters(context: Context, models: TypeSpecModel[]): void { +export function transformComponentParameters( + context: Context, + dataTypes: TypeSpecDataTypes[] +): void { const parameters = context.openApi3Doc.components?.parameters; if (!parameters) return; for (const name of Object.keys(parameters)) { const parameter = parameters[name]; - transformComponentParameter(models, name, parameter); + transformComponentParameter(dataTypes, name, parameter); } } function transformComponentParameter( - models: TypeSpecModel[], + dataTypes: TypeSpecDataTypes[], key: string, parameter: OpenAPI3Parameter ): void { const { name, scope } = getScopeAndName(key); - // Get the model name this parameter belongs to - const modelName = scope.length > 0 ? scope.pop()! : name; - - // find a matching model, or create one if it doesn't exist - let model = models.find((m) => m.name === modelName && scopesMatch(m.scope, scope)); - if (!model) { - model = { - kind: "model", - scope, - name: modelName, - decorators: [], - properties: [], - }; - models.push(model); - } + // Parameters should live in the root Parameters namespace + scope.unshift("Parameters"); - const modelProperty = getModelPropertyFromParameter(parameter); - - // Check if the model already has a property of the matching name - const propIndex = model.properties.findIndex((p) => p.name === modelProperty.name); - if (propIndex >= 0) { - model.properties[propIndex] = modelProperty; - } else { - model.properties.push(modelProperty); - } + dataTypes.push({ + kind: "model", + scope, + name, + decorators: [], + properties: [getModelPropertyFromParameter(parameter)], + }); } function getModelPropertyFromParameter(parameter: OpenAPI3Parameter): TypeSpecModelProperty { diff --git a/packages/openapi3/test/tsp-openapi3/output/escaped-identifiers/main.tsp b/packages/openapi3/test/tsp-openapi3/output/escaped-identifiers/main.tsp index fb55c6c963..2189df76ec 100644 --- a/packages/openapi3/test/tsp-openapi3/output/escaped-identifiers/main.tsp +++ b/packages/openapi3/test/tsp-openapi3/output/escaped-identifiers/main.tsp @@ -17,7 +17,7 @@ scalar `Foo-Bar` extends string; model `Escaped-Model` { id: string; - @path `escaped-property`: string; + `escaped-property`?: string; } /** @@ -28,6 +28,14 @@ model `get-thingDefaultResponse` {} @route("/{escaped-property}") @get op `get-thing`( @query `weird@param`?: `Foo-Bar`, - `escaped-property`: `Escaped-Model`.`escaped-property`, + ...Parameters.`Escaped-Model`.`escaped-property`, @bodyRoot body: `Escaped-Model`, ): `get-thingDefaultResponse`; + +namespace Parameters { + namespace `Escaped-Model` { + model `escaped-property` { + @path `escaped-property`: string; + } + } +} diff --git a/packages/openapi3/test/tsp-openapi3/output/param-decorators/main.tsp b/packages/openapi3/test/tsp-openapi3/output/param-decorators/main.tsp index 6ae3ee52d8..8c328eaf3a 100644 --- a/packages/openapi3/test/tsp-openapi3/output/param-decorators/main.tsp +++ b/packages/openapi3/test/tsp-openapi3/output/param-decorators/main.tsp @@ -18,16 +18,6 @@ model Thing { @format("UUID") id: string; } -model NameParameter { - /** - * Name parameter - */ - @pattern("^[a-zA-Z0-9-]{3,24}$") - @format("UUID") - @path - name: string; -} - /** * The request has succeeded. */ @@ -57,6 +47,18 @@ model Operations_putThing200ApplicationJsonResponse { ): Operations_getThing200ApplicationJsonResponse; @route("/thing/{name}") @put op Operations_putThing( - ...NameParameter, + ...Parameters.NameParameter, @bodyRoot body: Thing, ): Operations_putThing200ApplicationJsonResponse; + +namespace Parameters { + model NameParameter { + /** + * Name parameter + */ + @pattern("^[a-zA-Z0-9-]{3,24}$") + @format("UUID") + @path + name: string; + } +} diff --git a/packages/openapi3/test/tsp-openapi3/output/playground-http-service/main.tsp b/packages/openapi3/test/tsp-openapi3/output/playground-http-service/main.tsp index 009f580477..4e1e8100e8 100644 --- a/packages/openapi3/test/tsp-openapi3/output/playground-http-service/main.tsp +++ b/packages/openapi3/test/tsp-openapi3/output/playground-http-service/main.tsp @@ -19,7 +19,7 @@ model Error { } model Widget { - @path id: string; + id: string; weight: int32; color: "red" | "blue"; } @@ -160,7 +160,7 @@ op Widgets_read( @route("/widgets/{id}") @patch op Widgets_update( - id: Widget.id, + ...Parameters.Widget.id, @bodyRoot body: WidgetUpdate, ): Widgets_update200ApplicationJsonResponse | Widgets_updateDefaultApplicationJsonResponse; @@ -170,3 +170,11 @@ op Widgets_update( op Widgets_analyze( @path id: string, ): Widgets_analyze200ApplicationJsonResponse | Widgets_analyzeDefaultApplicationJsonResponse; + +namespace Parameters { + namespace Widget { + model id { + @path id: string; + } + } +} diff --git a/packages/openapi3/test/tsp-openapi3/parameters.test.ts b/packages/openapi3/test/tsp-openapi3/parameters.test.ts new file mode 100644 index 0000000000..7e8d7b5ed6 --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/parameters.test.ts @@ -0,0 +1,155 @@ +import { assert, describe, expect, it } from "vitest"; +import { tspForOpenAPI3 } from "./utils/tsp-for-openapi3.js"; + +describe("converts top-level parameters", () => { + (["query", "header", "path"] as const).forEach((location) => { + it(`Supports location: ${location}`, async () => { + const serviceNamespace = await tspForOpenAPI3({ + parameters: { + Foo: { + name: "foo", + in: location, + required: true, + schema: { + type: "string", + }, + }, + }, + }); + + const parametersNamespace = serviceNamespace.namespaces.get("Parameters"); + assert(parametersNamespace, "Parameters namespace not found"); + + const models = parametersNamespace.models; + + /* model Foo { @ foo: string, } */ + const Foo = models.get("Foo"); + assert(Foo, "Foo model not found"); + expect(Foo.properties.size).toBe(1); + expect(Foo.properties.get("foo")).toMatchObject({ + optional: false, + type: { kind: "Scalar", name: "string" }, + decorators: [{ definition: { name: `@${location}` } }], + }); + }); + }); + + it("supports optionality", async () => { + const serviceNamespace = await tspForOpenAPI3({ + parameters: { + RequiredFoo: { + name: "foo", + in: "query", + required: true, + schema: { + type: "string", + }, + }, + OptionalFoo: { + name: "foo", + in: "query", + schema: { + type: "string", + }, + }, + }, + }); + + const parametersNamespace = serviceNamespace.namespaces.get("Parameters"); + assert(parametersNamespace, "Parameters namespace not found"); + + const models = parametersNamespace.models; + + /* model RequiredFoo { @query foo: string, } */ + const RequiredFoo = models.get("RequiredFoo"); + assert(RequiredFoo, "RequiredFoo model not found"); + expect(RequiredFoo.properties.size).toBe(1); + expect(RequiredFoo.properties.get("foo")).toMatchObject({ + optional: false, + type: { kind: "Scalar", name: "string" }, + decorators: [{ definition: { name: "@query" } }], + }); + + /* model OptionalFoo { @query foo?: string, } */ + const OptionalFoo = models.get("OptionalFoo"); + assert(OptionalFoo, "RequiredFoo model not found"); + expect(OptionalFoo.properties.size).toBe(1); + expect(OptionalFoo.properties.get("foo")).toMatchObject({ + optional: true, + type: { kind: "Scalar", name: "string" }, + decorators: [{ definition: { name: "@query" } }], + }); + }); + + it("supports doc generation", async () => { + const serviceNamespace = await tspForOpenAPI3({ + parameters: { + Foo: { + name: "foo", + in: "query", + description: "Docs for foo", + schema: { + type: "string", + }, + }, + }, + }); + + const parametersNamespace = serviceNamespace.namespaces.get("Parameters"); + assert(parametersNamespace, "Parameters namespace not found"); + + const models = parametersNamespace.models; + + /* + model Foo { + // Docs for foo + @query foo?: string, + } + Note: actual doc comment uses jsdoc syntax + */ + const Foo = models.get("Foo"); + assert(Foo, "Foo model not found"); + expect(Foo.properties.size).toBe(1); + const foo = Foo.properties.get("foo"); + expect(foo).toMatchObject({ + optional: true, + type: { kind: "Scalar", name: "string" }, + }); + expect(foo?.decorators.find((d) => d.definition?.name === "@query")).toBeTruthy(); + const docDecorator = foo?.decorators.find((d) => d.decorator?.name === "$docFromComment"); + expect(docDecorator?.args[1]).toMatchObject({ jsValue: "Docs for foo" }); + }); + + it("supports referenced schemas", async () => { + const serviceNamespace = await tspForOpenAPI3({ + schemas: { + Foo: { + type: "string", + }, + }, + parameters: { + Foo: { + name: "foo", + in: "query", + schema: { + $ref: "#/components/schemas/Foo", + } as any, + }, + }, + }); + const parametersNamespace = serviceNamespace.namespaces.get("Parameters"); + assert(parametersNamespace, "Parameters namespace not found"); + + const models = parametersNamespace.models; + + /* model Foo { @query foo?: TestService.Foo, } */ + const Foo = models.get("Foo"); + assert(Foo, "Foo model not found"); + expect(Foo.properties.size).toBe(1); + expect(Foo.properties.get("foo")).toMatchObject({ + optional: true, + decorators: [{ definition: { name: "@query" } }], + }); + expect(Foo.properties.get("foo")?.type).toBe(serviceNamespace.scalars.get("Foo")); + }); +}); diff --git a/packages/openapi3/test/tsp-openapi3/utils/tsp-for-openapi3.ts b/packages/openapi3/test/tsp-openapi3/utils/tsp-for-openapi3.ts index 0ec2db5dca..f642538e5b 100644 --- a/packages/openapi3/test/tsp-openapi3/utils/tsp-for-openapi3.ts +++ b/packages/openapi3/test/tsp-openapi3/utils/tsp-for-openapi3.ts @@ -4,7 +4,12 @@ import { OpenAPITestLibrary } from "@typespec/openapi/testing"; import assert from "node:assert"; import { convertOpenAPI3Document } from "../../../src/index.js"; import { OpenAPI3TestLibrary } from "../../../src/testing/index.js"; -import { OpenAPI3Document, OpenAPI3Schema, Refable } from "../../../src/types.js"; +import { + OpenAPI3Document, + OpenAPI3Parameter, + OpenAPI3Schema, + Refable, +} from "../../../src/types.js"; function wrapCodeInTest(code: string): string { // Find the 1st namespace declaration and decorate it @@ -14,9 +19,10 @@ function wrapCodeInTest(code: string): string { export interface OpenAPI3Options { schemas?: Record>; + parameters?: Record>; } -export async function tspForOpenAPI3({ schemas }: OpenAPI3Options) { +export async function tspForOpenAPI3({ parameters, schemas }: OpenAPI3Options) { const openApi3Doc: OpenAPI3Document = { info: { title: "Test Service", @@ -28,6 +34,9 @@ export async function tspForOpenAPI3({ schemas }: OpenAPI3Options) { schemas: { ...(schemas as any), }, + parameters: { + ...(parameters as any), + }, }, }; From 8148743898728886d903f227718678892190272d Mon Sep 17 00:00:00 2001 From: Christopher Radek Date: Tue, 20 Aug 2024 09:44:27 -0700 Subject: [PATCH 3/4] add changelog --- .../changes/tsp-openapi3-add-context-2024-7-20-9-44-3.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .chronus/changes/tsp-openapi3-add-context-2024-7-20-9-44-3.md diff --git a/.chronus/changes/tsp-openapi3-add-context-2024-7-20-9-44-3.md b/.chronus/changes/tsp-openapi3-add-context-2024-7-20-9-44-3.md new file mode 100644 index 0000000000..cdd91dc72d --- /dev/null +++ b/.chronus/changes/tsp-openapi3-add-context-2024-7-20-9-44-3.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/openapi3" +--- + +Fixes issue in tsp-openapi3 that resulted in component schemas and parameters with the same name being merged into a single TypeSpec data type. \ No newline at end of file From 7ea877a604595c8d35c0b8049d530b70fdacf6ac Mon Sep 17 00:00:00 2001 From: Christopher Radek Date: Wed, 21 Aug 2024 14:39:57 -0700 Subject: [PATCH 4/4] tsp-openapi3 - improve model generation of schemas using allOf --- ...enapi3-improve-allof-2024-7-21-14-39-30.md | 7 + .../convert/generators/generate-model.ts | 11 +- .../src/cli/actions/convert/interfaces.ts | 2 + .../transforms/transform-component-schemas.ts | 64 ++++-- .../src/cli/actions/convert/utils/context.ts | 6 + .../test/tsp-openapi3/data-types.test.ts | 187 ++++++++++++++++++ .../tsp-openapi3/output/one-any-all/main.tsp | 6 +- 7 files changed, 260 insertions(+), 23 deletions(-) create mode 100644 .chronus/changes/tsp-openapi3-improve-allof-2024-7-21-14-39-30.md diff --git a/.chronus/changes/tsp-openapi3-improve-allof-2024-7-21-14-39-30.md b/.chronus/changes/tsp-openapi3-improve-allof-2024-7-21-14-39-30.md new file mode 100644 index 0000000000..9e1536b515 --- /dev/null +++ b/.chronus/changes/tsp-openapi3-improve-allof-2024-7-21-14-39-30.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/openapi3" +--- + +Improves tsp-openapi3 model generation from schemas utilizing allOf. Models will now extend an allOf member if it is a schema reference and the only member with a discriminator. Other members will be spread into the model if defined as a schema reference, or have their properties treated as top-level properties if they are an inline-schema. \ No newline at end of file diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-model.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-model.ts index 95e079f3cb..97ef77e414 100644 --- a/packages/openapi3/src/cli/actions/convert/generators/generate-model.ts +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-model.ts @@ -122,6 +122,10 @@ function generateModel(model: TypeSpecModel, context: Context): string { definitions.push(...generateDecorators(model.decorators)); definitions.push(modelDeclaration.open); + if (model.spread?.length) { + definitions.push(...model.spread.map((spread) => `...${spread};`)); + } + definitions.push( ...model.properties.map((prop) => { // Decorators will be a combination of top-level (parameters) and @@ -152,17 +156,12 @@ type ModelDeclarationOutput = { open: string; close?: string }; function generateModelDeclaration(model: TypeSpecModel): ModelDeclarationOutput { const modelName = model.name; - const modelType = model.type ?? "object"; if (model.is) { return { open: `model ${modelName} is ${model.is};` }; } - if (!model.extends) { - return { open: `model ${modelName} {`, close: "}" }; - } - - if (modelType === "object") { + if (model.extends) { return { open: `model ${modelName} extends ${model.extends} {`, close: "}" }; } diff --git a/packages/openapi3/src/cli/actions/convert/interfaces.ts b/packages/openapi3/src/cli/actions/convert/interfaces.ts index 9925e8fe07..eeb04eb2c3 100644 --- a/packages/openapi3/src/cli/actions/convert/interfaces.ts +++ b/packages/openapi3/src/cli/actions/convert/interfaces.ts @@ -65,6 +65,8 @@ export interface TypeSpecModel extends TypeSpecDeclaration { * Defaults to 'object' */ type?: OpenAPI3Schema["type"]; + + spread?: string[]; } export interface TypeSpecAlias extends Pick { diff --git a/packages/openapi3/src/cli/actions/convert/transforms/transform-component-schemas.ts b/packages/openapi3/src/cli/actions/convert/transforms/transform-component-schemas.ts index 97b2a389a6..775f0915ea 100644 --- a/packages/openapi3/src/cli/actions/convert/transforms/transform-component-schemas.ts +++ b/packages/openapi3/src/cli/actions/convert/transforms/transform-component-schemas.ts @@ -86,7 +86,7 @@ export function transformComponentSchemas(context: Context, models: TypeSpecMode schema: OpenAPI3Schema ): void { const { name, scope } = getScopeAndName(rawName); - const extendsParent = getModelExtends(schema, scope); + const allOfDetails = getAllOfDetails(schema, scope); const isParent = getModelIs(schema, scope); types.push({ kind: "model", @@ -94,12 +94,13 @@ export function transformComponentSchemas(context: Context, models: TypeSpecMode scope, decorators: [...getDecoratorsForSchema(schema)], doc: schema.description, - properties: getModelPropertiesFromObjectSchema(schema), + properties: [...getModelPropertiesFromObjectSchema(schema), ...allOfDetails.properties], additionalProperties: typeof schema.additionalProperties === "object" ? schema.additionalProperties : undefined, - extends: extendsParent, + extends: allOfDetails.extends, is: isParent, type: schema.type, + spread: allOfDetails.spread, }); } @@ -125,23 +126,56 @@ export function transformComponentSchemas(context: Context, models: TypeSpecMode }); } - function getModelExtends(schema: OpenAPI3Schema, callingScope: string[]): string | undefined { - if (schema.type !== "object" || !schema.allOf) { - return; - } + interface AllOfDetails { + extends?: string; + properties: TypeSpecModelProperty[]; + spread: string[]; + } + function getAllOfDetails(schema: OpenAPI3Schema, callingScope: string[]): AllOfDetails { + const details: AllOfDetails = { + spread: [], + properties: [], + }; - if (schema.allOf.length !== 1) { - // TODO: Emit warning - can't extend more than 1 model - return; + if (!schema.allOf) { + return details; } - const parent = schema.allOf[0]; - if (!parent || !("$ref" in parent)) { - // TODO: Error getting parent - must be a reference, not expression - return; + let foundParentWithDiscriminator = false; + + for (const member of schema.allOf) { + // inline-schemas treated as normal objects with properties + if (!("$ref" in member)) { + details.properties.push(...getModelPropertiesFromObjectSchema(member)); + continue; + } + + const refSchema = context.getSchemaByRef(member.$ref); + + // Inheritance only supported if parent has a discriminator defined, otherwise prefer + // composition via spreading. + if (!refSchema?.discriminator) { + details.spread.push(context.getRefName(member.$ref, callingScope)); + continue; + } + + if (!foundParentWithDiscriminator) { + details.extends = context.getRefName(member.$ref, callingScope); + foundParentWithDiscriminator = true; + continue; + } + + // can only extend once, so if we have multiple potential parents, spread them all + // user will need to resolve TypeSpec errors (e.g. duplicate fields) manually + if (details.extends) { + details.spread.push(details.extends); + details.extends = undefined; + } + + details.spread.push(context.getRefName(member.$ref, callingScope)); } - return context.getRefName(parent.$ref, callingScope); + return details; } function getModelIs(schema: OpenAPI3Schema, callingScope: string[]): string | undefined { diff --git a/packages/openapi3/src/cli/actions/convert/utils/context.ts b/packages/openapi3/src/cli/actions/convert/utils/context.ts index 805e1466d6..14e8cec0e0 100644 --- a/packages/openapi3/src/cli/actions/convert/utils/context.ts +++ b/packages/openapi3/src/cli/actions/convert/utils/context.ts @@ -8,6 +8,7 @@ export interface Context { generateTypeFromRefableSchema(schema: Refable, callingScope: string[]): string; getRefName(ref: string, callingScope: string[]): string; + getSchemaByRef(ref: string): OpenAPI3Schema | undefined; } export function createContext(openApi3Doc: OpenAPI3Document): Context { @@ -23,6 +24,11 @@ export function createContext(openApi3Doc: OpenAPI3Document): Context { generateTypeFromRefableSchema(schema: Refable, callingScope: string[]) { return schemaExpressionGenerator.generateTypeFromRefableSchema(schema, callingScope); }, + getSchemaByRef(ref) { + const schemaName = ref.replace("#/components/schemas/", ""); + const schema = openApi3Doc.components?.schemas?.[schemaName]; + return schema; + }, }; return context; diff --git a/packages/openapi3/test/tsp-openapi3/data-types.test.ts b/packages/openapi3/test/tsp-openapi3/data-types.test.ts index 8bdfba4ca5..978a530f64 100644 --- a/packages/openapi3/test/tsp-openapi3/data-types.test.ts +++ b/packages/openapi3/test/tsp-openapi3/data-types.test.ts @@ -336,5 +336,192 @@ describe("converts top-level schemas", () => { expect(FooAlias?.sourceModels[0].usage).toBe("is"); expect(FooAlias?.sourceModels[0].model).toBe(Foo); }); + + it("allOf with parent discriminator", async () => { + const serviceNamespace = await tspForOpenAPI3({ + schemas: { + Pet: { + type: "object", + required: ["kind"], + properties: { + kind: { type: "string" }, + }, + discriminator: { propertyName: "kind" }, + }, + Cat: { + allOf: [ + { $ref: "#/components/schemas/Pet" }, + { + type: "object", + required: ["kind", "meow"], + properties: { kind: { type: "string", enum: ["cat"] }, meow: { type: "string" } }, + }, + ], + }, + }, + }); + + /* @discriminator("kind") model Pet { kind: string, } */ + const Pet = serviceNamespace.models.get("Pet"); + assert(Pet, "Pet model not found"); + + /* model Cat extends Pet { kind: "cat", meow: string, } */ + const Cat = serviceNamespace.models.get("Cat"); + assert(Cat, "Cat model not found"); + expect(Cat.baseModel).toBe(Pet); + expect(Cat.properties.size).toBe(2); + expect(Cat.properties.get("kind")).toMatchObject({ + optional: false, + type: { kind: "String", value: "cat" }, + }); + expect(Cat.properties.get("meow")).toMatchObject({ + optional: false, + type: { kind: "Scalar", name: "string" }, + }); + }); + + it("allOf without parent discriminator", async () => { + const serviceNamespace = await tspForOpenAPI3({ + schemas: { + Pet: { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + }, + }, + Cat: { + allOf: [ + { $ref: "#/components/schemas/Pet" }, + { + type: "object", + required: ["kind", "meow"], + properties: { kind: { type: "string", enum: ["cat"] }, meow: { type: "string" } }, + }, + ], + }, + }, + }); + + /* model Pet { name: string, } */ + const Pet = serviceNamespace.models.get("Pet"); + assert(Pet, "Pet model not found"); + + /* model Cat { ...Pet, kind: "cat", meow: string, } */ + const Cat = serviceNamespace.models.get("Cat"); + assert(Cat, "Cat model not found"); + expect(Cat.baseModel).toBeUndefined(); + expect(Cat.properties.size).toBe(3); + expect(Cat.properties.get("name")).toMatchObject({ + optional: false, + type: { kind: "Scalar", name: "string" }, + }); + expect(Cat.properties.get("kind")).toMatchObject({ + optional: false, + type: { kind: "String", value: "cat" }, + }); + expect(Cat.properties.get("meow")).toMatchObject({ + optional: false, + type: { kind: "Scalar", name: "string" }, + }); + }); + + it("allOf with props", async () => { + const serviceNamespace = await tspForOpenAPI3({ + schemas: { + Pet: { + type: "object", + required: ["kind"], + properties: { + kind: { type: "string" }, + }, + discriminator: { propertyName: "kind" }, + }, + Cat: { + type: "object", + properties: { + paws: { type: "integer", format: "int8" }, + }, + allOf: [ + { $ref: "#/components/schemas/Pet" }, + { + type: "object", + required: ["kind", "meow"], + properties: { kind: { type: "string", enum: ["cat"] }, meow: { type: "string" } }, + }, + ], + }, + }, + }); + + /* @discriminator("kind") model Pet { kind: string, } */ + const Pet = serviceNamespace.models.get("Pet"); + assert(Pet, "Pet model not found"); + + /* model Cat extends Pet { kind: "cat", meow: string, paws?: int8 } */ + const Cat = serviceNamespace.models.get("Cat"); + assert(Cat, "Cat model not found"); + expect(Cat.baseModel).toBe(Pet); + expect(Cat.properties.size).toBe(3); + expect(Cat.properties.get("kind")).toMatchObject({ + optional: false, + type: { kind: "String", value: "cat" }, + }); + expect(Cat.properties.get("meow")).toMatchObject({ + optional: false, + type: { kind: "Scalar", name: "string" }, + }); + expect(Cat.properties.get("paws")).toMatchObject({ + optional: true, + type: { kind: "Scalar", name: "int8" }, + }); + }); + + it("allOf with multiple discriminators fallback to spread", async () => { + const serviceNamespace = await tspForOpenAPI3({ + schemas: { + Foo: { + type: "object", + required: ["kind"], + properties: { + kind: { type: "string" }, + }, + discriminator: { propertyName: "kind" }, + }, + Bar: { + type: "object", + required: ["type"], + properties: { + type: { type: "string" }, + }, + discriminator: { propertyName: "type" }, + }, + Thing: { + allOf: [{ $ref: "#/components/schemas/Foo" }, { $ref: "#/components/schemas/Bar" }], + }, + }, + }); + + /* @discriminator("kind") model Foo { kind: string, } */ + const Foo = serviceNamespace.models.get("Foo"); + assert(Foo, "Foo model not found"); + /* @discriminator("type") model Bar { type: string, } */ + const Bar = serviceNamespace.models.get("Bar"); + assert(Bar, "Bar model not found"); + + /* model Thing { ...Foo; ...Bar; } */ + const Thing = serviceNamespace.models.get("Thing"); + assert(Thing, "Thing model not found"); + expect(Thing.baseModel).toBeUndefined(); + expect(Thing.properties.size).toBe(2); + expect(Thing.properties.get("kind")).toMatchObject({ + optional: false, + type: { kind: "Scalar", name: "string" }, + }); + expect(Thing.properties.get("type")).toMatchObject({ + optional: false, + type: { kind: "Scalar", name: "string" }, + }); + }); }); }); diff --git a/packages/openapi3/test/tsp-openapi3/output/one-any-all/main.tsp b/packages/openapi3/test/tsp-openapi3/output/one-any-all/main.tsp index db4352b089..60aec09ddc 100644 --- a/packages/openapi3/test/tsp-openapi3/output/one-any-all/main.tsp +++ b/packages/openapi3/test/tsp-openapi3/output/one-any-all/main.tsp @@ -13,11 +13,13 @@ using OpenAPI; }) namespace OneAnyAllService; -model Cat extends Pet { +model Cat { + ...Pet; hunts: boolean; } -model Dog extends Pet { +model Dog { + ...Pet; bark: boolean; breed: "Husky" | "Corgi" | "Terrier"; }