diff --git a/.chronus/changes/openapi3-function-2024-1-23-14-2-48.md b/.chronus/changes/openapi3-function-2024-1-23-14-2-48.md new file mode 100644 index 0000000000..1aafbd6748 --- /dev/null +++ b/.chronus/changes/openapi3-function-2024-1-23-14-2-48.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi3" +--- + +Add `getOpenAPI3` function that takes a TypeSpec program and returns the emitted OpenAPI as an object. Useful for other emitters and tools that want to work with emitted OpenAPI directly without writing it to disk. \ No newline at end of file diff --git a/.chronus/changes/openapi3-function-2024-1-26-14-26-10.md b/.chronus/changes/openapi3-function-2024-1-26-14-26-10.md new file mode 100644 index 0000000000..ef4d08a0de --- /dev/null +++ b/.chronus/changes/openapi3-function-2024-1-26-14-26-10.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/versioning" +--- + +Export the VersionProjections interface. \ No newline at end of file diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index 45b3ccffc5..ad05498879 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -243,6 +243,6 @@ export const libDef = { } as const; export const $lib = createTypeSpecLibrary(libDef); -export const { reportDiagnostic, createStateSymbol } = $lib; +export const { createDiagnostic, reportDiagnostic, createStateSymbol } = $lib; export type OpenAPILibrary = typeof $lib; diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 8f85bc6134..a57184c98b 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -1,5 +1,8 @@ import { compilerAssert, + createDiagnosticCollector, + Diagnostic, + DiagnosticCollector, DiagnosticTarget, EmitContext, emitFile, @@ -44,7 +47,8 @@ import { TypeNameOptions, } from "@typespec/compiler"; -import { AssetEmitter, EmitEntity } from "@typespec/compiler/emitter-framework"; +import { AssetEmitter, createAssetEmitter, EmitEntity } from "@typespec/compiler/emitter-framework"; +import {} from "@typespec/compiler/utils"; import { createMetadataInfo, getAuthentication, @@ -83,10 +87,10 @@ import { resolveOperationId, shouldInline, } from "@typespec/openapi"; -import { buildVersionProjections } from "@typespec/versioning"; +import { buildVersionProjections, VersionProjections } from "@typespec/versioning"; import { stringify } from "yaml"; import { getRef } from "./decorators.js"; -import { FileType, OpenAPI3EmitterOptions, reportDiagnostic } from "./lib.js"; +import { createDiagnostic, FileType, OpenAPI3EmitterOptions } from "./lib.js"; import { getDefaultValue, OpenAPI3SchemaEmitter } from "./schema-emitter.js"; import { OpenAPI3Document, @@ -99,7 +103,9 @@ import { OpenAPI3SecurityScheme, OpenAPI3Server, OpenAPI3ServerVariable, + OpenAPI3ServiceRecord, OpenAPI3StatusCode, + OpenAPI3VersionedServiceRecord, Refable, } from "./types.js"; import { deepEquals } from "./util.js"; @@ -118,6 +124,37 @@ export async function $onEmit(context: EmitContext) { await emitter.emitOpenAPI(); } +type IrrelevantOpenAPI3EmitterOptionsForObject = "file-type" | "output-file" | "new-line"; + +/** + * Get the OpenAPI 3 document records from the given program. The documents are + * returned as a JS object. + * + * @param program The program to emit to OpenAPI 3 + * @param options OpenAPI 3 emit options + * @returns An array of OpenAPI 3 document records. + */ +export async function getOpenAPI3( + program: Program, + options: Omit = {} +): Promise { + const context: EmitContext = { + program, + + // this value doesn't matter for getting the OpenAPI3 objects + emitterOutputDir: "tsp-output", + + options: options, + getAssetEmitter(TypeEmitterClass) { + return createAssetEmitter(program, TypeEmitterClass, this); + }, + }; + + const resolvedOptions = resolveOptions(context); + const emitter = createOAPIEmitter(context, resolvedOptions); + return emitter.getOpenAPI(); +} + function findFileTypeFromFilename(filename: string | undefined): FileType { if (filename === undefined) { return defaultFileType; @@ -167,6 +204,7 @@ function createOAPIEmitter( let schemaEmitter: AssetEmitter; let root: OpenAPI3Document; + let diagnostics: DiagnosticCollector; let currentService: Service; // Get the service namespace string for use in name shortening let serviceNamespaceName: string | undefined; @@ -196,7 +234,45 @@ function createOAPIEmitter( }, }; - return { emitOpenAPI }; + return { emitOpenAPI, getOpenAPI }; + + async function emitOpenAPI() { + const services = await getOpenAPI(); + // first, emit diagnostics + for (const serviceRecord of services) { + if (serviceRecord.versioned) { + for (const documentRecord of serviceRecord.versions) { + program.reportDiagnostics(documentRecord.diagnostics); + } + } else { + program.reportDiagnostics(serviceRecord.diagnostics); + } + } + + if (program.compilerOptions.noEmit || program.hasError()) { + return; + } + + const multipleService = services.length > 1; + + for (const serviceRecord of services) { + if (serviceRecord.versioned) { + for (const documentRecord of serviceRecord.versions) { + await emitFile(program, { + path: resolveOutputFile(serviceRecord.service, multipleService, documentRecord.version), + content: serializeDocument(documentRecord.document, options.fileType), + newLine: options.newLine, + }); + } + } else { + await emitFile(program, { + path: resolveOutputFile(serviceRecord.service, multipleService), + content: serializeDocument(serviceRecord.document, options.fileType), + newLine: options.newLine, + }); + } + } + } function initializeEmitter(service: Service, version?: string) { currentService = service; @@ -241,6 +317,7 @@ function createOAPIEmitter( securitySchemes: auth?.securitySchemes ?? {}, }, }; + diagnostics = createDiagnosticCollector(); const servers = getServers(program, service.type); if (servers) { root.servers = resolveServers(servers); @@ -282,11 +359,13 @@ function createOAPIEmitter( const isValid = isValidServerVariableType(program, prop.type); if (!isValid) { - reportDiagnostic(program, { - code: "invalid-server-variable", - format: { propName: prop.name }, - target: prop, - }); + diagnostics.add( + createDiagnostic({ + code: "invalid-server-variable", + format: { propName: prop.name }, + target: prop, + }) + ); } return isValid; } @@ -324,12 +403,57 @@ function createOAPIEmitter( }); } - async function emitOpenAPI() { + async function getOpenAPI(): Promise { + const serviceRecords: OpenAPI3ServiceRecord[] = []; const services = listServices(program); if (services.length === 0) { services.push({ type: program.getGlobalNamespaceType() }); } for (const service of services) { + const versions = buildVersionProjections(program, service.type); + if (versions.length === 1 && versions[0].version === undefined) { + // non-versioned spec + const document = await getProjectedOpenAPIDocument(service, versions[0]); + if (document === undefined) { + // an error occurred producing this document, so don't return it + return serviceRecords; + } + + serviceRecords.push({ + service, + versioned: false, + document: document[0], + diagnostics: document[1], + }); + } else { + // versioned spec + const serviceRecord: OpenAPI3VersionedServiceRecord = { + service, + versioned: true, + versions: [], + }; + serviceRecords.push(serviceRecord); + + for (const record of versions) { + const document = await getProjectedOpenAPIDocument(service, record); + if (document === undefined) { + // an error occurred producing this document + continue; + } + + serviceRecord.versions.push({ + service, + version: record.version!, + document: document[0], + diagnostics: document[1], + }); + } + } + } + + return serviceRecords; + + async function getProjectedOpenAPIDocument(service: Service, record: VersionProjections) { const commonProjections: ProjectionApplication[] = [ { projectionName: "target", @@ -337,24 +461,22 @@ function createOAPIEmitter( }, ]; const originalProgram = program; - const versions = buildVersionProjections(program, service.type); - for (const record of versions) { - const projectedProgram = (program = projectProgram(originalProgram, [ - ...commonProjections, - ...record.projections, - ])); - const projectedServiceNs: Namespace = projectedProgram.projector.projectedTypes.get( - service.type - ) as Namespace; - - await emitOpenAPIFromVersion( - projectedServiceNs === projectedProgram.getGlobalNamespaceType() - ? { type: projectedProgram.getGlobalNamespaceType() } - : getService(program, projectedServiceNs)!, - services.length > 1, - record.version - ); - } + const projectedProgram = (program = projectProgram(originalProgram, [ + ...commonProjections, + ...record.projections, + ])); + const projectedServiceNs: Namespace = projectedProgram.projector.projectedTypes.get( + service.type + ) as Namespace; + + const document = await getOpenApiFromVersion( + projectedServiceNs === projectedProgram.getGlobalNamespaceType() + ? { type: projectedProgram.getGlobalNamespaceType() } + : getService(program, projectedServiceNs)!, + record.version + ); + + return document; } } @@ -478,11 +600,13 @@ function createOAPIEmitter( diagnosticTarget: DiagnosticTarget ): OpenAPI3StatusCode[] { const reportInvalid = () => - reportDiagnostic(program, { - code: "unsupported-status-code-range", - format: { start: String(range.start), end: String(range.end) }, - target: diagnosticTarget, - }); + diagnostics.add( + createDiagnostic({ + code: "unsupported-status-code-range", + format: { start: String(range.start), end: String(range.end) }, + target: diagnosticTarget, + }) + ); const codes: OpenAPI3StatusCode[] = []; let start = range.start; @@ -601,11 +725,10 @@ function createOAPIEmitter( return result; } - async function emitOpenAPIFromVersion( + async function getOpenApiFromVersion( service: Service, - multipleService: boolean, version?: string - ) { + ): Promise<[OpenAPI3Document, Readonly] | undefined> { initializeEmitter(service, version); try { const httpService = ignoreDiagnostics(getHttpService(program, service.type)); @@ -631,15 +754,7 @@ function createOAPIEmitter( } } - if (!program.compilerOptions.noEmit && !program.hasError()) { - // Write out the OpenAPI document to the output path - - await emitFile(program, { - path: resolveOutputFile(service, multipleService, version), - content: serializeDocument(root, options.fileType), - newLine: options.newLine, - }); - } + return [root, diagnostics.diagnostics]; } catch (err) { if (err instanceof ErrorTypeFoundError) { // Return early, there must be a parse error if an ErrorType was @@ -707,10 +822,12 @@ function createOAPIEmitter( return resolveRequestVisibility(program, op, verb); }); if (visibilities.some((v) => v !== visibilities[0])) { - reportDiagnostic(program, { - code: "inconsistent-shared-route-request-visibility", - target: ops[0], - }); + diagnostics.add( + createDiagnostic({ + code: "inconsistent-shared-route-request-visibility", + target: ops[0], + }) + ); } const visibility = resolveRequestVisibility(program, shared.operations[0], verb); emitEndpointParameters(shared.parameters.parameters, visibility); @@ -734,7 +851,7 @@ function createOAPIEmitter( const { path: fullPath, operation: op, verb, parameters } = operation; // If path contains a query string, issue msg and don't emit this endpoint if (fullPath.indexOf("?") > 0) { - reportDiagnostic(program, { code: "path-query", target: op }); + diagnostics.add(createDiagnostic({ code: "path-query", target: op })); return; } if (!root.paths[fullPath]) { @@ -844,11 +961,13 @@ function createOAPIEmitter( const existing = obj.headers[key]; if (existing) { if (!deepEquals(existing, headerVal)) { - reportDiagnostic(program, { - code: "duplicate-header", - format: { header: key }, - target: target, - }); + diagnostics.add( + createDiagnostic({ + code: "duplicate-header", + format: { header: key }, + target: target, + }) + ); } continue; } @@ -916,11 +1035,13 @@ function createOAPIEmitter( case "declaration": return { $ref: `#/components/schemas/${result.name}` }; case "circular": - reportDiagnostic(program, { - code: "inline-cycle", - format: { type: getOpenAPITypeName(program, type, typeNameOptions) }, - target: type, - }); + diagnostics.add( + createDiagnostic({ + code: "inline-cycle", + format: { type: getOpenAPITypeName(program, type, typeNameOptions) }, + target: type, + }) + ); return {}; case "none": return {}; @@ -935,11 +1056,13 @@ function createOAPIEmitter( case "declaration": return result.value as any; case "circular": - reportDiagnostic(program, { - code: "inline-cycle", - format: { type: getOpenAPITypeName(program, type, typeNameOptions) }, - target: type, - }); + diagnostics.add( + createDiagnostic({ + code: "inline-cycle", + format: { type: getOpenAPITypeName(program, type, typeNameOptions) }, + target: type, + }) + ); return {}; case "none": return {}; @@ -1207,14 +1330,16 @@ function createOAPIEmitter( case "simple": return { style: "simple" }; default: - reportDiagnostic(program, { - code: "invalid-format", - format: { - paramType: "header", - value: parameter.format, - }, - target: parameter.param, - }); + diagnostics.add( + createDiagnostic({ + code: "invalid-format", + format: { + paramType: "header", + value: parameter.format, + }, + target: parameter.param, + }) + ); return undefined; } } @@ -1238,14 +1363,16 @@ function createOAPIEmitter( return { style: "pipeDelimited", explode: false }; default: - reportDiagnostic(program, { - code: "invalid-format", - format: { - paramType: "query", - value: parameter.format, - }, - target: parameter.param, - }); + diagnostics.add( + createDiagnostic({ + code: "invalid-format", + format: { + paramType: "query", + value: parameter.format, + }, + target: parameter.param, + }) + ); return undefined; } } @@ -1542,11 +1669,13 @@ function createOAPIEmitter( scopes: [], }; default: - reportDiagnostic(program, { - code: "unsupported-auth", - format: { authType: (auth as any).type }, - target: currentService.type, - }); + diagnostics.add( + createDiagnostic({ + code: "unsupported-auth", + format: { authType: (auth as any).type }, + target: currentService.type, + }) + ); return undefined; } } diff --git a/packages/openapi3/src/types.ts b/packages/openapi3/src/types.ts index c21fd0ead4..b9d90993c8 100644 --- a/packages/openapi3/src/types.ts +++ b/packages/openapi3/src/types.ts @@ -1,3 +1,4 @@ +import { Diagnostic, Service } from "@typespec/compiler"; import { ExtensionKey } from "@typespec/openapi"; export type Extensions = { @@ -39,6 +40,57 @@ export interface OpenAPI3Document extends Extensions { security?: Record[]; } +/** + * A record containing the the OpenAPI 3 documents corresponding to + * a particular service definition. + */ +export type OpenAPI3ServiceRecord = + | OpenAPI3UnversionedServiceRecord + | OpenAPI3VersionedServiceRecord; + +export interface OpenAPI3UnversionedServiceRecord { + /** The service that generated this OpenAPI document */ + readonly service: Service; + + /** Whether the service is versioned */ + readonly versioned: false; + + /** The OpenAPI 3 document */ + readonly document: OpenAPI3Document; + + /** The diagnostics created for this document */ + readonly diagnostics: readonly Diagnostic[]; +} + +export interface OpenAPI3VersionedServiceRecord { + /** The service that generated this OpenAPI document */ + readonly service: Service; + + /** Whether the service is versioned */ + readonly versioned: true; + + /** The OpenAPI 3 document records for each version of this service */ + readonly versions: OpenAPI3VersionedDocumentRecord[]; +} + +/** + * A record containing an unversioned OpenAPI document and associated metadata. + */ + +export interface OpenAPI3VersionedDocumentRecord { + /** The OpenAPI document*/ + readonly document: OpenAPI3Document; + + /** The service that generated this OpenAPI document. */ + readonly service: Service; + + /** The version of the service. Absent if the service is unversioned. */ + readonly version: string; + + /** The diagnostics created for this version. */ + readonly diagnostics: readonly Diagnostic[]; +} + export interface OpenAPI3Info extends Extensions { title: string; description?: string; diff --git a/packages/openapi3/test/get-openapi.test.ts b/packages/openapi3/test/get-openapi.test.ts new file mode 100644 index 0000000000..2c43604bd5 --- /dev/null +++ b/packages/openapi3/test/get-openapi.test.ts @@ -0,0 +1,64 @@ +import { expectDiagnostics } from "@typespec/compiler/testing"; +import { ok, strictEqual } from "assert"; +import { it } from "vitest"; +import { getOpenAPI3 } from "../src/openapi.js"; +import { createOpenAPITestHost } from "./test-host.js"; + +it("can get openapi as an object", async () => { + const host = await createOpenAPITestHost(); + host.addTypeSpecFile( + "./main.tsp", + `import "@typespec/http"; + import "@typespec/rest"; + import "@typespec/openapi"; + import "@typespec/openapi3"; + using TypeSpec.Rest; + using TypeSpec.Http; + using TypeSpec.OpenAPI; + + @service + namespace Foo; + + @get op get(): Item; + + model Item { x: true } + model Bar { }; // unreachable + ` + ); + await host.compile("main.tsp"); + const output = await getOpenAPI3(host.program, { "omit-unreachable-types": false }); + const documentRecord = output[0]; + ok(!documentRecord.versioned, "should not be versioned"); + strictEqual(documentRecord.document.components!.schemas!["Item"].type, "object"); +}); + +it("has diagnostics", async () => { + const host = await createOpenAPITestHost(); + host.addTypeSpecFile( + "./main.tsp", + `import "@typespec/http"; + import "@typespec/rest"; + import "@typespec/openapi"; + import "@typespec/openapi3"; + using TypeSpec.Rest; + using TypeSpec.Http; + using TypeSpec.OpenAPI; + + @service + namespace Foo; + + op read(): {@minValue(455) @maxValue(495) @statusCode _: int32, content: string}; + ` + ); + await host.compile("main.tsp"); + const output = await getOpenAPI3(host.program, { "omit-unreachable-types": false }); + const documentRecord = output[0]; + ok(!documentRecord.versioned, "should not be versioned"); + expectDiagnostics(documentRecord.diagnostics, [ + { + code: "@typespec/openapi3/unsupported-status-code-range", + message: + "Status code range '455 to '495' is not supported. OpenAPI 3.0 can only represent range 1XX, 2XX, 3XX, 4XX and 5XX. Example: `@minValue(400) @maxValue(499)` for 4XX.", + }, + ]); +}); diff --git a/packages/versioning/src/versioning.ts b/packages/versioning/src/versioning.ts index ae7a2155d2..128ec981ae 100644 --- a/packages/versioning/src/versioning.ts +++ b/packages/versioning/src/versioning.ts @@ -589,7 +589,7 @@ export function resolveVersions(program: Program, rootNs: Namespace): VersionRes /** * Represent the set of projections used to project to that version. */ -interface VersionProjections { +export interface VersionProjections { version: string | undefined; projections: ProjectionApplication[]; }