Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
125 changes: 108 additions & 17 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 @@ -90,6 +90,7 @@ import { FileType, OpenAPI3EmitterOptions, reportDiagnostic } from "./lib.js";
import { getDefaultValue, OpenAPI3SchemaEmitter } from "./schema-emitter.js";
import {
OpenAPI3Document,
OpenAPI3DocumentRecord,
OpenAPI3Header,
OpenAPI3OAuthFlows,
OpenAPI3Operation,
Expand All @@ -99,7 +100,11 @@ import {
OpenAPI3SecurityScheme,
OpenAPI3Server,
OpenAPI3ServerVariable,
OpenAPI3ServiceRecord,
OpenAPI3StatusCode,
OpenAPI3UnversionedServiceRecord,
OpenAPI3VersionedDocumentRecord,
OpenAPI3VersionedServiceRecord,
Refable,
} from "./types.js";
import { deepEquals } from "./util.js";
Expand All @@ -118,6 +123,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> = {}
) {
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 +232,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.document, options.fileType),
newLine: options.newLine,
});
}
}
}

function initializeEmitter(service: Service, version?: string) {
currentService = service;
Expand Down Expand Up @@ -324,12 +388,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 +416,42 @@ 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;
} 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<OpenAPI3DocumentRecord | undefined> {
initializeEmitter(service, version);
try {
const httpService = ignoreDiagnostics(getHttpService(program, service.type));
Expand All @@ -631,15 +726,11 @@ 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 {
document: root,
service,
version,
};
} catch (err) {
if (err instanceof ErrorTypeFoundError) {
// Return early, there must be a parse error if an ErrorType was
Expand Down
47 changes: 47 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,52 @@ 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: OpenAPI3UnversionedDocumentRecord;
}

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

/**
* A record containing an unversioned OpenAPI document and associated metadata.
*/
export type OpenAPI3DocumentRecord =
| OpenAPI3UnversionedDocumentRecord
| OpenAPI3VersionedDocumentRecord;

export interface OpenAPI3UnversionedDocumentRecord {
/** 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;
}

export interface OpenAPI3VersionedDocumentRecord extends OpenAPI3UnversionedDocumentRecord {
/** 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.document.components!.schemas!["Item"].type, "object");
});