Skip to content

Commit

Permalink
tsp-openapi3 - update to group items by namespace when separated by d…
Browse files Browse the repository at this point in the history
…ot, and escaped otherwise invalid identifiers (#3844)

Fixes #3810 and replaces #3808

This PR does 2 things:
1. Groups operations/models into namespaces based on the dots in their
schema names/operationIds.
2. Escapes invalid identifiers by surrounding them with backticks.

---------

Co-authored-by: Christopher Radek <Christopher.Radek@microsoft.com>
  • Loading branch information
chrisradek and Christopher Radek authored Jul 16, 2024
1 parent fef3f01 commit ae229b2
Show file tree
Hide file tree
Showing 17 changed files with 502 additions and 141 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/openapi3"
---

Updates tsp-openapi3 to escape identifiers that would otherwise be invalid, and automatically resolve namespaces for schemas with dots in their names.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { TypeSpecProgram } from "../interfaces.js";
import { generateModel } from "./generate-model.js";
import { generateNamespace } from "./generate-namespace.js";
import { generateOperation } from "./generate-operation.js";
import { generateServiceInformation } from "./generate-service-info.js";

Expand All @@ -17,5 +18,9 @@ export function generateMain(program: TypeSpecProgram): string {
${program.models.map(generateModel).join("\n\n")}
${program.operations.map(generateOperation).join("\n\n")}
${Object.entries(program.namespaces)
.map(([name, namespace]) => generateNamespace(name, namespace))
.join("\n\n")}
`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { TypeSpecNamespace } from "../interfaces.js";
import { generateModel } from "./generate-model.js";
import { generateOperation } from "./generate-operation.js";

export function generateNamespace(name: string, namespace: TypeSpecNamespace): string {
const definitions: string[] = [];
definitions.push(`namespace ${name} {`);

definitions.push(...namespace.models.map(generateModel));
definitions.push(...namespace.operations.map(generateOperation));

for (const [namespaceName, nestedNamespace] of Object.entries(namespace.namespaces)) {
definitions.push(generateNamespace(namespaceName, nestedNamespace));
}

definitions.push("}");
return definitions.join("\n");
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { OpenAPI3Response, Refable } from "../../../../types.js";
import { Refable } from "../../../../types.js";
import {
TypeSpecOperation,
TypeSpecOperationParameter,
TypeSpecRequestBody,
} from "../interfaces.js";
import { generateResponseModelName } from "../transforms/transform-operation-responses.js";
import { generateDocs } from "../utils/docs.js";
import { generateDecorators } from "./generate-decorators.js";
import { generateTypeFromSchema, getRefName } from "./generate-types.js";
Expand All @@ -26,9 +25,11 @@ export function generateOperation(operation: TypeSpecOperation): string {
...generateRequestBodyParameters(operation.requestBodies),
];

const responseTypes = generateResponses(operation.operationId!, operation.responses);
const responseTypes = operation.responseTypes.length
? operation.responseTypes.join(" | ")
: "void";

definitions.push(`op ${operation.name}(${parameters.join(", ")}): ${responseTypes.join(" | ")};`);
definitions.push(`op ${operation.name}(${parameters.join(", ")}): ${responseTypes};`);

return definitions.join(" ");
}
Expand Down Expand Up @@ -86,39 +87,3 @@ function generateRequestBodyParameters(requestBodies: TypeSpecRequestBody[]): st
function supportsOnlyJson(contentTypes: string[]) {
return contentTypes.length === 1 && contentTypes[0] === "application/json";
}

function generateResponses(
operationId: string,
responses: TypeSpecOperation["responses"]
): string[] {
if (!responses) {
return ["void"];
}

const definitions: string[] = [];

for (const statusCode of Object.keys(responses)) {
const response = responses[statusCode];
definitions.push(...generateResponseForStatus(operationId, statusCode, response));
}

return definitions;
}

function generateResponseForStatus(
operationId: string,
statusCode: string,
response: Refable<OpenAPI3Response>
): string[] {
if ("$ref" in response) {
return [getRefName(response.$ref)];
}

if (!response.content) {
return [generateResponseModelName(operationId, statusCode)];
}

return Object.keys(response.content).map((contentType) =>
generateResponseModelName(operationId, statusCode, contentType)
);
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { printIdentifier } from "@typespec/compiler";
import { OpenAPI3Schema, Refable } from "../../../../types.js";
import { getDecoratorsForSchema } from "../utils/decorators.js";
import { generateDecorators } from "./generate-decorators.js";
Expand Down Expand Up @@ -47,8 +48,7 @@ function getTypeFromSchema(schema: OpenAPI3Schema): string {

export function getRefName(ref: string): string {
const name = ref.split("/").pop() ?? "";
// TODO: account for `.` in the name
return name;
return name.split(".").map(printIdentifier).join(".");
}

function getAnyOfType(schema: OpenAPI3Schema): string {
Expand Down
23 changes: 17 additions & 6 deletions packages/openapi3/src/cli/actions/convert/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import { Contact, License } from "@typespec/openapi";
import { OpenAPI3Encoding, OpenAPI3Responses, OpenAPI3Schema, Refable } from "../../../types.js";
import { OpenAPI3Encoding, OpenAPI3Schema, Refable } from "../../../types.js";

export interface TypeSpecProgram {
serviceInfo: TypeSpecServiceInfo;
namespaces: Record<string, TypeSpecNamespace>;
models: TypeSpecModel[];
augmentations: TypeSpecAugmentation[];
operations: TypeSpecOperation[];
}

export interface TypeSpecDeclaration {
name: string;
doc?: string;
scope: string[];
}

export interface TypeSpecNamespace {
namespaces: Record<string, TypeSpecNamespace>;
models: TypeSpecModel[];
operations: TypeSpecOperation[];
}

export interface TypeSpecServiceInfo {
name: string;
doc?: string;
Expand All @@ -27,9 +40,7 @@ export interface TypeSpecAugmentation extends TypeSpecDecorator {
target: string;
}

export interface TypeSpecModel {
name: string;
doc?: string;
export interface TypeSpecModel extends TypeSpecDeclaration {
decorators: TypeSpecDecorator[];
properties: TypeSpecModelProperty[];
additionalProperties?: Refable<OpenAPI3Schema>;
Expand Down Expand Up @@ -60,14 +71,14 @@ export interface TypeSpecModelProperty {
schema: Refable<OpenAPI3Schema>;
}

export interface TypeSpecOperation {
export interface TypeSpecOperation extends TypeSpecDeclaration {
name: string;
doc?: string;
decorators: TypeSpecDecorator[];
operationId?: string;
parameters: Refable<TypeSpecOperationParameter>[];
requestBodies: TypeSpecRequestBody[];
responses: OpenAPI3Responses;
responseTypes: string[];
tags: string[];
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { printIdentifier } from "@typespec/compiler";
import { OpenAPI3Components, OpenAPI3Parameter } from "../../../../types.js";
import { TypeSpecModel, TypeSpecModelProperty } from "../interfaces.js";
import { getParameterDecorators } from "../utils/decorators.js";
import { getScopeAndName, scopesMatch } from "../utils/get-scope-and-name.js";

/**
* Transforms #/components/parameters into TypeSpec models.
Expand All @@ -17,37 +19,46 @@ export function transformComponentParameters(
if (!parameters) return;

for (const name of Object.keys(parameters)) {
// Determine what the name of the parameter's model is since name may point at
// a nested property.
const modelName = name.indexOf(".") < 0 ? name : name.split(".").shift()!;
const parameter = parameters[name];
transformComponentParameter(models, name, parameter);
}
}

function transformComponentParameter(
models: TypeSpecModel[],
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;

// Check if model already exists; if not, create it
let model = models.find((m) => m.name === modelName);
if (!model) {
model = {
name: modelName,
decorators: [],
properties: [],
};
models.push(model);
}
// 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 = {
scope,
name: modelName,
decorators: [],
properties: [],
};
models.push(model);
}

const parameter = parameters[name];
const modelParameter = getModelPropertyFromParameter(parameter);
const modelProperty = getModelPropertyFromParameter(parameter);

// Check if the model already has a property of the matching name
const propIndex = model.properties.findIndex((p) => p.name === modelParameter.name);
if (propIndex >= 0) {
model.properties[propIndex] = modelParameter;
} else {
model.properties.push(modelParameter);
}
// 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);
}
}

function getModelPropertyFromParameter(parameter: OpenAPI3Parameter): TypeSpecModelProperty {
return {
name: parameter.name,
name: printIdentifier(parameter.name),
isOptional: !parameter.required,
doc: parameter.description ?? parameter.schema.description,
decorators: getParameterDecorators(parameter),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { printIdentifier } from "@typespec/compiler";
import { OpenAPI3Components, OpenAPI3Schema } from "../../../../types.js";
import {
getArrayType,
Expand All @@ -8,6 +9,7 @@ import {
} from "../generators/generate-types.js";
import { TypeSpecModel, TypeSpecModelProperty } from "../interfaces.js";
import { getDecoratorsForSchema } from "../utils/decorators.js";
import { getScopeAndName } from "../utils/get-scope-and-name.js";

/**
* Transforms #/components/schemas into TypeSpec models.
Expand All @@ -24,22 +26,30 @@ export function transformComponentSchemas(

for (const name of Object.keys(schemas)) {
const schema = schemas[name];
const extendsParent = getModelExtends(schema);
const isParent = getModelIs(schema);
models.push({
name: name.replace(/-/g, "_"),
decorators: [...getDecoratorsForSchema(schema)],
doc: schema.description,
properties: getModelPropertiesFromObjectSchema(schema),
additionalProperties:
typeof schema.additionalProperties === "object" ? schema.additionalProperties : undefined,
extends: extendsParent,
is: isParent,
type: schema.type,
});
transformComponentSchema(models, name, schema);
}
}

function transformComponentSchema(
models: TypeSpecModel[],
name: string,
schema: OpenAPI3Schema
): void {
const extendsParent = getModelExtends(schema);
const isParent = getModelIs(schema);
models.push({
...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 getModelExtends(schema: OpenAPI3Schema): string | undefined {
switch (schema.type) {
case "boolean":
Expand Down Expand Up @@ -88,7 +98,7 @@ function getModelPropertiesFromObjectSchema({
const property = properties[name];

modelProperties.push({
name,
name: printIdentifier(name),
doc: property.description,
schema: property,
isOptional: !required.includes(name),
Expand Down
Loading

0 comments on commit ae229b2

Please sign in to comment.