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 getOpenAPI3 to OpenAPI3 emitter #2950

Merged
merged 15 commits into from
Feb 26, 2024
Merged
7 changes: 7 additions & 0 deletions .chronus/changes/openapi3-function-2024-1-23-14-2-48.md
Original file line number Diff line number Diff line change
@@ -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.
127 changes: 111 additions & 16 deletions packages/openapi3/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {
TypeNameOptions,
} from "@typespec/compiler";

import { AssetEmitter, EmitEntity } from "@typespec/compiler/emitter-framework";
import { AssetEmitter, createAssetEmitter, EmitEntity } from "@typespec/compiler/emitter-framework";
import {
createMetadataInfo,
getAuthentication,
Expand Down Expand Up @@ -99,7 +99,11 @@ import {
OpenAPI3SecurityScheme,
OpenAPI3Server,
OpenAPI3ServerVariable,
OpenAPI3ServiceRecord,
OpenAPI3StatusCode,
OpenAPI3UnversionedServiceRecord,
OpenAPI3VersionedDocumentRecord,
OpenAPI3VersionedServiceRecord,
Refable,
} from "./types.js";
import { deepEquals } from "./util.js";
Expand All @@ -118,6 +122,37 @@ export async function $onEmit(context: EmitContext<OpenAPI3EmitterOptions>) {
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<OpenAPI3EmitterOptions, IrrelevantOpenAPI3EmitterOptionsForObject> = {}
): Promise<OpenAPI3ServiceRecord[]> {
const context: EmitContext<any> = {
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;
Expand Down Expand Up @@ -196,7 +231,35 @@ function createOAPIEmitter(
},
};

return { emitOpenAPI };
return { emitOpenAPI, getOpenAPI };

async function emitOpenAPI() {
const services = await getOpenAPI();

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;
Expand Down Expand Up @@ -324,12 +387,17 @@ function createOAPIEmitter(
});
}

async function emitOpenAPI() {
async function getOpenAPI(): Promise<OpenAPI3ServiceRecord[]> {
const serviceRecords: OpenAPI3ServiceRecord[] = [];
const services = listServices(program);
if (services.length === 0) {
services.push({ type: program.getGlobalNamespaceType() });
}
for (const service of services) {
const serviceRecord: OpenAPI3ServiceRecord = {
service,
} as any;

const commonProjections: ProjectionApplication[] = [
{
projectionName: "target",
Expand All @@ -347,15 +415,43 @@ function createOAPIEmitter(
service.type
) as Namespace;

await emitOpenAPIFromVersion(
const document = await getOpenApiFromVersion(
projectedServiceNs === projectedProgram.getGlobalNamespaceType()
? { type: projectedProgram.getGlobalNamespaceType() }
: getService(program, projectedServiceNs)!,
services.length > 1,
record.version
);

if (document === undefined) {
// an error occurred producing this document
continue;
}

if (record.version === undefined) {
compilerAssert(
versions.length === 1,
"Expected only one version when service is unversioned"
);
serviceRecord.versioned = false;
(serviceRecord as OpenAPI3UnversionedServiceRecord).document =
document as OpenAPI3Document;
} else {
serviceRecord.versioned = true;
(serviceRecord as OpenAPI3VersionedServiceRecord).versions ??= [];
compilerAssert(
(document as OpenAPI3VersionedDocumentRecord).version,
"Expected a versioned document from a versioned service"
);
(serviceRecord as OpenAPI3VersionedServiceRecord).versions.push(
document as OpenAPI3VersionedDocumentRecord
);
}
}

serviceRecords.push(serviceRecord);
}

return serviceRecords;
}

function resolveOutputFile(service: Service, multipleService: boolean, version?: string): string {
Expand Down Expand Up @@ -601,11 +697,10 @@ function createOAPIEmitter(
return result;
}

async function emitOpenAPIFromVersion(
async function getOpenApiFromVersion(
service: Service,
multipleService: boolean,
version?: string
) {
): Promise<OpenAPI3VersionedDocumentRecord | OpenAPI3Document | undefined> {
initializeEmitter(service, version);
try {
const httpService = ignoreDiagnostics(getHttpService(program, service.type));
Expand All @@ -631,14 +726,14 @@ 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,
});
if (version) {
return {
document: root,
service,
version,
};
} else {
return root;
}
} catch (err) {
if (err instanceof ErrorTypeFoundError) {
Expand Down
42 changes: 42 additions & 0 deletions packages/openapi3/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Service } from "@typespec/compiler";
import { ExtensionKey } from "@typespec/openapi";

export type Extensions = {
Expand Down Expand Up @@ -39,6 +40,47 @@ export interface OpenAPI3Document extends Extensions {
security?: Record<string, string[]>[];
}

/**
* A record containing the the OpenAPI 3 documents corresponding to
* a particular service definition.
*/

export type OpenAPI3ServiceRecord =
| OpenAPI3UnversionedServiceRecord
| OpenAPI3VersionedServiceRecord;

export interface OpenAPI3UnversionedServiceRecord {
service: Service;
versioned: false;
document: OpenAPI3Document;
}

export interface OpenAPI3VersionedServiceRecord {
service: Service;
versioned: true;
versions: OpenAPI3VersionedDocumentRecord[];
}

/**
* A record containing an unversioned OpenAPI document and associated metadata.
*/

export interface OpenAPI3VersionedDocumentRecord {
/** The OpenAPI document*/
document: OpenAPI3Document;

/**
* The service that generated this OpenAPI document. When this is a versioned
* service, this service references the projected namespace. Otherwise, it
* will be the canonical service namespace and be identical to the service in
* the outer service record.
* */
service: Service;

/** The version of the service. Absent if the service is unversioned. */
version: string;
}

export interface OpenAPI3Info extends Extensions {
title: string;
description?: string;
Expand Down
30 changes: 30 additions & 0 deletions packages/openapi3/test/get-openapi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { 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 });
strictEqual((output[0] as any).document.components!.schemas!["Item"].type, "object");
});
Loading