From b0b6a0a1dbe6e44b55da83fa814c53f96d609fb3 Mon Sep 17 00:00:00 2001 From: Christopher Radek <14189820+chrisradek@users.noreply.github.com> Date: Thu, 29 Aug 2024 11:55:59 -0700 Subject: [PATCH] tsp-openapi3 - scope top-level parameters to Parameters namespace (#4216) Fixes #4151 This PR updates tsp-openapi3's model generation so that all top-level parameters (`#/components/parameters`) are nested in a `Parameters` block namespace. Prior to this change, if top-level parameter had the same name as a top-level schema, we would attempt to merge the two. This worked OK if the schema was an object type, but led to broken results if the schema was anything else. Note: In the linked issue, it was suggested that top-level schemas not be scoped to their own namespace, so if a schema is referenced by a parameter, it will now qualify it with the file-level namespace. This PR introduces a `context` object that contains some state that can be passed around. This is useful for keeping track of the file-level namespace and using it when necessary, but the context will also be useful in cases where we need to look at the definition of a referenced schema from another schema. --------- Co-authored-by: Christopher Radek --- ...p-openapi3-add-context-2024-7-20-9-44-3.md | 7 + .../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 | 34 ++- .../generators/generate-service-info.ts | 5 +- .../convert/generators/generate-types.ts | 255 +++++++++-------- .../transform-component-parameters.ts | 49 ++-- .../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 +- .../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 | 264 ++++++++++++++++++ .../tsp-openapi3/utils/tsp-for-openapi3.ts | 13 +- 20 files changed, 714 insertions(+), 358 deletions(-) create mode 100644 .chronus/changes/tsp-openapi3-add-context-2024-7-20-9-44-3.md 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 create mode 100644 packages/openapi3/test/tsp-openapi3/parameters.test.ts 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 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..39003cfefe 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,14 +34,13 @@ 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 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[] = []; @@ -53,13 +52,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..328c7d3e8a 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,179 @@ 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 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; + } -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 +195,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 +211,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..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,8 +1,9 @@ import { printIdentifier } from "@typespec/compiler"; -import { OpenAPI3Components, OpenAPI3Parameter } from "../../../../types.js"; -import { TypeSpecModel, TypeSpecModelProperty } from "../interfaces.js"; +import { OpenAPI3Parameter } from "../../../../types.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,48 +14,34 @@ import { getScopeAndName, scopesMatch } from "../utils/get-scope-and-name.js"; * @returns */ export function transformComponentParameters( - models: TypeSpecModel[], - parameters?: OpenAPI3Components["parameters"] + 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; + // Parameters should live in the root Parameters namespace + scope.unshift("Parameters"); - // 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); - } - - 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/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); 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..9b28ad087f --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/parameters.test.ts @@ -0,0 +1,264 @@ +import { Numeric } from "@typespec/compiler"; +import { assert, describe, expect, it } from "vitest"; +import { tspForOpenAPI3 } from "./utils/tsp-for-openapi3.js"; + +describe("converts top-level parameters", () => { + it.each(["query", "header", "path"] as const)(`Supports location: %s`, async (location) => { + 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 string schema constraints`, async () => { + const serviceNamespace = await tspForOpenAPI3({ + parameters: { + Foo: { + name: "foo", + in: "query", + required: true, + schema: { + type: "string", + minLength: 3, + maxLength: 10, + pattern: "^[a-z]+$", + format: "UUID", + }, + }, + }, + }); + + const parametersNamespace = serviceNamespace.namespaces.get("Parameters"); + assert(parametersNamespace, "Parameters namespace not found"); + + const models = parametersNamespace.models; + + /* model Foo { @query @format("UUID") @pattern("^[a-z]+$") @maxLength(10) @minLength(3) 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: "@query" } }, + { definition: { name: "@format" }, args: [{ jsValue: "UUID" }] }, + { definition: { name: "@pattern" }, args: [{ jsValue: "^[a-z]+$" }] }, + { definition: { name: "@maxLength" }, args: [{ jsValue: Numeric("10") }] }, + { definition: { name: "@minLength" }, args: [{ jsValue: Numeric("3") }] }, + ], + }); + }); + + it(`Supports numeric schema constraints`, async () => { + const serviceNamespace = await tspForOpenAPI3({ + parameters: { + Foo: { + name: "foo", + in: "query", + required: true, + schema: { + type: "integer", + minimum: 3, + maximum: 10, + format: "int32", + }, + }, + }, + }); + + const parametersNamespace = serviceNamespace.namespaces.get("Parameters"); + assert(parametersNamespace, "Parameters namespace not found"); + + const models = parametersNamespace.models; + + /* model Foo { @query @maxValue(10) @minValue(3) 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: "int32" }, + decorators: [ + { definition: { name: "@query" } }, + { definition: { name: "@maxValue" }, args: [{ jsValue: Numeric("10") }] }, + { definition: { name: "@minValue" }, args: [{ jsValue: Numeric("3") }] }, + ], + }); + }); + + 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")); + }); + + it.each(["model", "interface", "namespace", "hyphen-name"])( + `escapes invalid names: %s`, + async (reservedKeyword) => { + const serviceNamespace = await tspForOpenAPI3({ + parameters: { + [reservedKeyword]: { + name: reservedKeyword, + in: "query", + required: true, + schema: { + type: "string", + }, + }, + }, + }); + + const parametersNamespace = serviceNamespace.namespaces.get("Parameters"); + assert(parametersNamespace, "Parameters namespace not found"); + + const models = parametersNamespace.models; + + /* model `{reservedKeyWord}` { @query `{reservedKeyword}`: string, } */ + const escapedModel = models.get(reservedKeyword); + assert(escapedModel, "escapedModel model not found"); + expect(escapedModel.properties.size).toBe(1); + expect(escapedModel.properties.get(reservedKeyword)).toMatchObject({ + optional: false, + type: { kind: "Scalar", name: "string" }, + decorators: [{ definition: { name: "@query" } }], + }); + } + ); +}); 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), + }, }, };