Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add openapi 3.1 support #5372

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
changeKind: feature
packages:
- "@typespec/openapi3"
---

Adds support for emitting Open API 3.1 models using the `openapi-versions` emitter configuration option.
Open API 3.0 is emitted by default.
4 changes: 4 additions & 0 deletions packages/openapi3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ Example Multiple service with versioning
- `openapi.Org1.Service2.v1.0.yaml`
- `openapi.Org1.Service2.v1.1.yaml`

### `openapi-versions`

**Type:** `array`

### `new-line`

**Type:** `"crlf" | "lf"`
Expand Down
12 changes: 12 additions & 0 deletions packages/openapi3/src/attach-extensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Program, Type } from "@typespec/compiler";
import { getExtensions } from "@typespec/openapi";

export function attachExtensions(program: Program, type: Type, emitObject: any) {
// Attach any OpenAPI extensions
const extensions = getExtensions(program, type);
if (extensions) {
for (const key of extensions.keys()) {
emitObject[key] = extensions.get(key);
}
}
}
4 changes: 2 additions & 2 deletions packages/openapi3/src/encoding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { type ModelProperty, Program, type Scalar, getEncode } from "@typespec/c
import { ObjectBuilder } from "@typespec/compiler/emitter-framework";
import type { ResolvedOpenAPI3EmitterOptions } from "./openapi.js";
import { getSchemaForStdScalars } from "./std-scalar-schemas.js";
import type { OpenAPI3Schema } from "./types.js";
import type { OpenAPI3Schema, OpenAPISchema3_1 } from "./types.js";

export function applyEncoding(
program: Program,
typespecType: Scalar | ModelProperty,
target: OpenAPI3Schema,
options: ResolvedOpenAPI3EmitterOptions,
): OpenAPI3Schema {
): OpenAPI3Schema & OpenAPISchema3_1 {
const encodeData = getEncode(program, typespecType);
if (encodeData) {
const newTarget = new ObjectBuilder(target);
Expand Down
21 changes: 21 additions & 0 deletions packages/openapi3/src/lib.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createTypeSpecLibrary, JSONSchemaType, paramMessage } from "@typespec/compiler";

export type FileType = "yaml" | "json";
export type OpenAPIVersion = "3.0.0" | "3.1.0";
export interface OpenAPI3EmitterOptions {
/**
* If the content should be serialized as YAML or JSON.
Expand Down Expand Up @@ -36,6 +37,16 @@ export interface OpenAPI3EmitterOptions {
*/
"output-file"?: string;

/**
* The Open API specification versions to emit.
* If more than one version is specified, then the output file
* will be created inside a directory matching each specification version.
*
* @default ["v3.0"]
* @internal
*/
"openapi-versions"?: OpenAPIVersion[];

/**
* Set the newline character for emitting files.
* @default lf
Expand Down Expand Up @@ -104,6 +115,16 @@ const EmitterOptionsSchema: JSONSchemaType<OpenAPI3EmitterOptions> = {
" - `openapi.Org1.Service2.v1.1.yaml` ",
].join("\n"),
},
"openapi-versions": {
type: "array",
items: {
type: "string",
enum: ["3.0.0", "3.1.0"],
nullable: true,
description: "The versions of OpenAPI to emit. Defaults to `v3.0`",
},
nullable: true,
},
"new-line": {
type: "string",
enum: ["crlf", "lf"],
Expand Down
79 changes: 79 additions & 0 deletions packages/openapi3/src/openapi-spec-mappings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { EmitContext, Namespace, Program } from "@typespec/compiler";
import { AssetEmitter } from "@typespec/compiler/emitter-framework";
import { MetadataInfo } from "@typespec/http";
import { getExternalDocs, resolveInfo } from "@typespec/openapi";
import { OpenAPI3EmitterOptions, OpenAPIVersion } from "./lib.js";
import { ResolvedOpenAPI3EmitterOptions } from "./openapi.js";
import { createSchemaEmitter3_0 } from "./schema-emitter-3-0.js";
import { createSchemaEmitter3_1 } from "./schema-emitter-3-1.js";
import { OpenAPI3Schema, OpenAPISchema3_1, SupportedOpenAPIDocuments } from "./types.js";
import { VisibilityUsageTracker } from "./visibility-usage.js";
import { XmlModule } from "./xml-module.js";

export type CreateSchemaEmitter = (props: {
program: Program;
context: EmitContext<OpenAPI3EmitterOptions>;
metadataInfo: MetadataInfo;
visibilityUsage: VisibilityUsageTracker;
options: ResolvedOpenAPI3EmitterOptions;
xmlModule: XmlModule | undefined;
}) => AssetEmitter<OpenAPI3Schema | OpenAPISchema3_1, OpenAPI3EmitterOptions>;

export interface OpenApiSpecSpecificProps {
createRootDoc: (
program: Program,
serviceType: Namespace,
serviceVersion?: string,
) => SupportedOpenAPIDocuments;

createSchemaEmitter: CreateSchemaEmitter;
}

export function getOpenApiSpecProps(specVersion: OpenAPIVersion): OpenApiSpecSpecificProps {
switch (specVersion) {
case "3.0.0":
return {
createRootDoc(program, serviceType, serviceVersion) {
return createRoot(program, serviceType, specVersion, serviceVersion);
},
createSchemaEmitter: createSchemaEmitter3_0,
};
case "3.1.0":
return {
createRootDoc(program, serviceType, serviceVersion) {
return createRoot(program, serviceType, specVersion, serviceVersion);
},
createSchemaEmitter: createSchemaEmitter3_1,
};
}
}

function createRoot(
program: Program,
serviceType: Namespace,
specVersion: OpenAPIVersion,
serviceVersion?: string,
): SupportedOpenAPIDocuments {
const info = resolveInfo(program, serviceType);

return {
openapi: specVersion,
info: {
title: "(title)",
...info,
version: serviceVersion ?? info?.version ?? "0.0.0",
},
externalDocs: getExternalDocs(program, serviceType),
tags: [],
paths: {},
security: undefined,
components: {
parameters: {},
requestBodies: {},
responses: {},
schemas: {},
examples: {},
securitySchemes: {},
},
};
}
91 changes: 48 additions & 43 deletions packages/openapi3/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ import {
getParameterKey,
getTagsMetadata,
isReadonlyProperty,
resolveInfo,
resolveOperationId,
shouldInline,
} from "@typespec/openapi";
Expand All @@ -89,11 +88,10 @@ import { stringify } from "yaml";
import { getRef } from "./decorators.js";
import { applyEncoding } from "./encoding.js";
import { getExampleOrExamples, OperationExamples, resolveOperationExamples } from "./examples.js";
import { createDiagnostic, FileType, OpenAPI3EmitterOptions } from "./lib.js";
import { getDefaultValue, isBytesKeptRaw, OpenAPI3SchemaEmitter } from "./schema-emitter.js";
import { createDiagnostic, FileType, OpenAPI3EmitterOptions, OpenAPIVersion } from "./lib.js";
import { getOpenApiSpecProps } from "./openapi-spec-mappings.js";
import { getOpenAPI3StatusCodes } from "./status-codes.js";
import {
OpenAPI3Document,
OpenAPI3Encoding,
OpenAPI3Header,
OpenAPI3MediaType,
Expand All @@ -111,9 +109,17 @@ import {
OpenAPI3StatusCode,
OpenAPI3Tag,
OpenAPI3VersionedServiceRecord,
OpenAPISchema3_1,
Refable,
SupportedOpenAPIDocuments,
} from "./types.js";
import { deepEquals, isSharedHttpOperation, SharedHttpOperation } from "./util.js";
import {
deepEquals,
getDefaultValue,
isBytesKeptRaw,
isSharedHttpOperation,
SharedHttpOperation,
} from "./util.js";
import { resolveVisibilityUsage, VisibilityUsageTracker } from "./visibility-usage.js";
import { resolveXmlModule, XmlModule } from "./xml-module.js";

Expand All @@ -127,8 +133,10 @@ const defaultOptions = {

export async function $onEmit(context: EmitContext<OpenAPI3EmitterOptions>) {
const options = resolveOptions(context);
const emitter = createOAPIEmitter(context, options);
await emitter.emitOpenAPI();
for (const specVersion of options.openapiVersions) {
const emitter = createOAPIEmitter(context, options, specVersion);
await emitter.emitOpenAPI();
}
}

type IrrelevantOpenAPI3EmitterOptionsForObject = "file-type" | "output-file" | "new-line";
Expand Down Expand Up @@ -158,8 +166,12 @@ export async function getOpenAPI3(
};

const resolvedOptions = resolveOptions(context);
const emitter = createOAPIEmitter(context, resolvedOptions);
return emitter.getOpenAPI();
const serviceRecords: OpenAPI3ServiceRecord[] = [];
for (const specVersion of resolvedOptions.openapiVersions) {
const emitter = createOAPIEmitter(context, resolvedOptions, specVersion);
serviceRecords.push(...(await emitter.getOpenAPI()));
}
return serviceRecords;
}

function findFileTypeFromFilename(filename: string | undefined): FileType {
Expand All @@ -186,19 +198,26 @@ export function resolveOptions(

const outputFile =
resolvedOptions["output-file"] ?? `openapi.{service-name}.{version}.${fileType}`;

const openapiVersions = resolvedOptions["openapi-versions"] ?? ["3.0.0"];

const specDir = openapiVersions.length > 1 ? "{openapi-version}" : "";

return {
fileType,
newLine: resolvedOptions["new-line"],
omitUnreachableTypes: resolvedOptions["omit-unreachable-types"],
includeXTypeSpecName: resolvedOptions["include-x-typespec-name"],
safeintStrategy: resolvedOptions["safeint-strategy"],
outputFile: resolvePath(context.emitterOutputDir, outputFile),
outputFile: resolvePath(context.emitterOutputDir, specDir, outputFile),
openapiVersions,
};
}

export interface ResolvedOpenAPI3EmitterOptions {
fileType: FileType;
outputFile: string;
openapiVersions: OpenAPIVersion[];
newLine: NewLine;
omitUnreachableTypes: boolean;
includeXTypeSpecName: "inline-only" | "never";
Expand All @@ -208,11 +227,13 @@ export interface ResolvedOpenAPI3EmitterOptions {
function createOAPIEmitter(
context: EmitContext<OpenAPI3EmitterOptions>,
options: ResolvedOpenAPI3EmitterOptions,
specVersion: OpenAPIVersion = "3.0.0",
) {
const { createRootDoc, createSchemaEmitter } = getOpenApiSpecProps(specVersion);
let program = context.program;
let schemaEmitter: AssetEmitter<OpenAPI3Schema, OpenAPI3EmitterOptions>;
let schemaEmitter: AssetEmitter<OpenAPI3Schema | OpenAPISchema3_1, OpenAPI3EmitterOptions>;

let root: OpenAPI3Document;
let root: SupportedOpenAPIDocuments;
let diagnostics: DiagnosticCollector;
let currentService: Service;
let serviceAuth: HttpServiceAuthentication;
Expand Down Expand Up @@ -306,40 +327,23 @@ function createOAPIEmitter(
options.omitUnreachableTypes,
);

schemaEmitter = createAssetEmitter(
schemaEmitter = createSchemaEmitter({
program,
class extends OpenAPI3SchemaEmitter {
constructor(emitter: AssetEmitter<Record<string, any>, OpenAPI3EmitterOptions>) {
super(emitter, metadataInfo, visibilityUsage, options, xmlModule);
}
} as any,
context,
);
metadataInfo,
visibilityUsage,
options,
xmlModule,
});

const securitySchemes = getOpenAPISecuritySchemes(allHttpAuthentications);
const security = getOpenAPISecurity(defaultAuth);

const info = resolveInfo(program, service.type);
root = {
openapi: "3.0.0",
info: {
title: "(title)",
...info,
version: version ?? info?.version ?? "0.0.0",
},
externalDocs: getExternalDocs(program, service.type),
tags: [],
paths: {},
security: security.length > 0 ? security : undefined,
components: {
parameters: {},
requestBodies: {},
responses: {},
schemas: {},
examples: {},
securitySchemes: securitySchemes,
},
};
root = createRootDoc(program, service.type, version);
if (security.length > 0) {
root.security = security;
}
root.components!.securitySchemes = securitySchemes;

const servers = getServers(program, service.type);
if (servers) {
Expand Down Expand Up @@ -515,6 +519,7 @@ function createOAPIEmitter(

function resolveOutputFile(service: Service, multipleService: boolean, version?: string): string {
return interpolatePath(options.outputFile, {
"openapi-version": specVersion,
"service-name": multipleService ? getNamespaceFullName(service.type) : undefined,
version,
});
Expand Down Expand Up @@ -627,7 +632,7 @@ function createOAPIEmitter(
async function getOpenApiFromVersion(
service: Service,
version?: string,
): Promise<[OpenAPI3Document, Readonly<Diagnostic[]>] | undefined> {
): Promise<[SupportedOpenAPIDocuments, Readonly<Diagnostic[]>] | undefined> {
try {
const httpService = ignoreDiagnostics(getHttpService(program, service.type));
const auth = (serviceAuth = resolveAuthentication(httpService));
Expand Down Expand Up @@ -1834,7 +1839,7 @@ function createOAPIEmitter(
}
}

function serializeDocument(root: OpenAPI3Document, fileType: FileType): string {
function serializeDocument(root: SupportedOpenAPIDocuments, fileType: FileType): string {
sortOpenAPIDocument(root);
switch (fileType) {
case "json":
Expand Down Expand Up @@ -1867,7 +1872,7 @@ function sortObjectByKeys<T extends Record<string, unknown>>(obj: T): T {
}, {});
}

function sortOpenAPIDocument(doc: OpenAPI3Document): void {
function sortOpenAPIDocument(doc: SupportedOpenAPIDocuments): void {
doc.paths = sortObjectByKeys(doc.paths);
if (doc.components?.schemas) {
doc.components.schemas = sortObjectByKeys(doc.components.schemas);
Expand Down
Loading
Loading