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

Fix bug: Unable to use {service-name} interpolation for filenames with openapi3-emitter when only one service is defined #6182

Merged
merged 12 commits into from
Mar 3, 2025
Next Next commit
fix
  • Loading branch information
albertxavier100 committed Feb 27, 2025
commit fe9ec20e4e82487be04384d2901c339cd9294819
10 changes: 6 additions & 4 deletions packages/openapi3/src/lib.ts
Original file line number Diff line number Diff line change
@@ -13,10 +13,11 @@ export interface OpenAPI3EmitterOptions {
/**
* Name of the output file.
* Output file will interpolate the following values:
* - service-name: Name of the service if multiple
* - service-name: Name of the service
* - service-name-if-multiple: Name of the service if multiple
* - version: Version of the service if multiple
*
* @default `{service-name}.{version}.openapi.yaml` or `.json` if {@link OpenAPI3EmitterOptions["file-type"]} is `"json"`
* @default `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if {@link OpenAPI3EmitterOptions["file-type"]} is `"json"`
*
* @example Single service no versioning
* - `openapi.yaml`
@@ -99,10 +100,11 @@ const EmitterOptionsSchema: JSONSchemaType<OpenAPI3EmitterOptions> = {
description: [
"Name of the output file.",
" Output file will interpolate the following values:",
" - service-name: Name of the service if multiple",
" - service-name: Name of the service",
" - service-name-if-multiple: Name of the service if multiple",
" - version: Version of the service if multiple",
"",
' Default: `{service-name}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"`',
' Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"`',
"",
" Example Single service no versioning",
" - `openapi.yaml`",
5 changes: 3 additions & 2 deletions packages/openapi3/src/openapi.ts
Original file line number Diff line number Diff line change
@@ -200,7 +200,7 @@ export function resolveOptions(
resolvedOptions["file-type"] ?? findFileTypeFromFilename(resolvedOptions["output-file"]);

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

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

@@ -533,7 +533,8 @@ 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,
"service-name-if-multiple": multipleService ? getNamespaceFullName(service.type) : undefined,
"service-name": getNamespaceFullName(service.type),
version,
});
}
200 changes: 126 additions & 74 deletions packages/openapi3/test/emit-openapi.test.ts
Original file line number Diff line number Diff line change
@@ -1,79 +1,131 @@
import { resolveVirtualPath } from "@typespec/compiler/testing";
import { describe, expect, it } from "vitest";
import { emitOpenApiWithDiagnostics } from "./test-host.js";
import { EmitterOptions } from "../../compiler/src/config/types.js";
import { OpenAPI3EmitterOptions } from "../src/lib.js";
import { worksFor } from "./works-for.js";

describe("Scalar formats of serialized document in YAML", () => {
it("should add single quote for y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF", async () => {
const [_, __, content] = await emitOpenApiWithDiagnostics(`
enum TestEnum {
y: "y",
Y: "Y",
yes: "yes",
Yes: "Yes",
YES: "YES",
yEs: "yEs",
n: "n",
N: "N",
no: "no",
No: "No",
NO: "NO",
nO: "nO",
"true": "true",
True: "True",
TRUE: "TRUE",
tRUE: "tRUE",
"false": "false",
False: "False",
FALSE: "FALSE",
fALSE: "fALSE",
on: "on",
On: "On",
ON: "ON",
oN: "oN",
off: "off",
Off: "Off",
OFF: "OFF",
oFF: "oFF"
}
`);
expect(content).toBe(`openapi: 3.0.0
info:
title: (title)
version: 0.0.0
tags: []
paths: {}
components:
schemas:
TestEnum:
type: string
enum:
- 'y'
- 'Y'
- 'yes'
- 'Yes'
- 'YES'
- yEs
- 'n'
- 'N'
- 'no'
- 'No'
- 'NO'
- nO
- 'true'
- 'True'
- 'TRUE'
- tRUE
- 'false'
- 'False'
- 'FALSE'
- fALSE
- 'on'
- 'On'
- 'ON'
- oN
- 'off'
- 'Off'
- 'OFF'
- oFF
`);
worksFor(["3.0.0", "3.1.0"], (helper) => {
it("should add single quote for y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF", async () => {
const [_, __, content] = await helper.emitOpenApiWithDiagnostics(`
enum TestEnum {
y: "y",
Y: "Y",
yes: "yes",
Yes: "Yes",
YES: "YES",
yEs: "yEs",
n: "n",
N: "N",
no: "no",
No: "No",
NO: "NO",
nO: "nO",
"true": "true",
True: "True",
TRUE: "TRUE",
tRUE: "tRUE",
"false": "false",
False: "False",
FALSE: "FALSE",
fALSE: "fALSE",
on: "on",
On: "On",
ON: "ON",
oN: "oN",
off: "off",
Off: "Off",
OFF: "OFF",
oFF: "oFF"
}
`);
expect(content).toBe(`openapi: 3.0.0
info:
title: (title)
version: 0.0.0
tags: []
paths: {}
components:
schemas:
TestEnum:
type: string
enum:
- 'y'
- 'Y'
- 'yes'
- 'Yes'
- 'YES'
- yEs
- 'n'
- 'N'
- 'no'
- 'No'
- 'NO'
- nO
- 'true'
- 'True'
- 'TRUE'
- tRUE
- 'false'
- 'False'
- 'FALSE'
- fALSE
- 'on'
- 'On'
- 'ON'
- oN
- 'off'
- 'Off'
- 'OFF'
- oFF
`);
});

interface ServiceNameCase {
description: string;
code: string;
outputFilePattern: string;
expectedOutputFiles: string[];
}
it.each([
// {service-name} cases
{
description: "{service-name} for one service",
code: "@service namespace AAA { model M {a: string} }",
outputFilePattern: "{service-name}.yaml",
expectedOutputFiles: [resolveVirtualPath("AAA.yaml")],
},
{
description: "{service-name} for multiple services",
code:
"@service namespace AAA { model M {a: string} }" +
"@service namespace BBB { model N {b: string} }",
outputFilePattern: "{service-name}.yaml",
expectedOutputFiles: [resolveVirtualPath("AAA.yaml"), resolveVirtualPath("BBB.yaml")],
},
// {service-name-if-multiple} cases
{
description: "{service-name-if-multiple} for one service",
code: "@service namespace AAA { model M {a: string} }",
outputFilePattern: "{service-name-if-multiple}.yaml",
expectedOutputFiles: [resolveVirtualPath("yaml")],
},
{
description: "{service-name-if-multiple} for multiple services",
code:
"@service namespace AAA { model M {a: string} }" +
"@service namespace BBB { model N {b: string} }",
outputFilePattern: "{service-name-if-multiple}.yaml",
expectedOutputFiles: [resolveVirtualPath("AAA.yaml"), resolveVirtualPath("BBB.yaml")],
},
])("$description", async (c: ServiceNameCase) => {
const options: OpenAPI3EmitterOptions & EmitterOptions = {
"output-file": c.outputFilePattern,
"emitter-output-dir": "{output-dir}",
};
const [diag, load, _] = await helper.emitOpenApi(c.code, options);
expect(diag.length).toBe(0);
for (const outputFile of c.expectedOutputFiles) expect(load(outputFile)).toBeDefined();
});
});
});
38 changes: 36 additions & 2 deletions packages/openapi3/test/test-host.ts
Original file line number Diff line number Diff line change
@@ -12,7 +12,8 @@ import { RestTestLibrary } from "@typespec/rest/testing";
import { VersioningTestLibrary } from "@typespec/versioning/testing";
import { XmlTestLibrary } from "@typespec/xml/testing";
import { ok } from "assert";
import { parse } from "yaml";
import { parse as yamlParse } from "yaml";
import { EmitterOptions } from "../../compiler/src/config/types.js";
import { OpenAPI3EmitterOptions } from "../src/lib.js";
import { OpenAPI3TestLibrary } from "../src/testing/index.js";
import { OpenAPI3Document } from "../src/types.js";
@@ -77,10 +78,43 @@ export async function emitOpenApiWithDiagnostics(
});
const content = runner.fs.get(outputFile);
ok(content, "Expected to have found openapi output");
const doc = fileType === "json" ? JSON.parse(content) : parse(content);
const doc = fileType === "json" ? JSON.parse(content) : yamlParse(content);
return [doc, diagnostics, content];
}

export async function emitOpenApi(
code: string,
options: OpenAPI3EmitterOptions & EmitterOptions = {},
): Promise<
[
readonly Diagnostic[],
(outputFile: string) => string | undefined,
(outputFile: string) => OpenAPI3Document | undefined,
]
> {
const runner = await createOpenAPITestRunner();
const fileType = options["file-type"] || "yaml";
const diagnostics = await runner.diagnose(code, {
noEmit: false,
emit: ["@typespec/openapi3"],
options: {
"@typespec/openapi3": options,
},
});
return [diagnostics, load, parse];

function load(outputFile: string): string | undefined {
const absoluteOutputFile = resolveVirtualPath(outputFile);
return runner.fs.get(absoluteOutputFile);
}

function parse(outputFile: string): OpenAPI3Document | undefined {
const content = load(outputFile);
if (!content) return;
return fileType === "json" ? JSON.parse(content) : yamlParse(content);
}
}

export async function diagnoseOpenApiFor(code: string, options: OpenAPI3EmitterOptions = {}) {
const runner = await createOpenAPITestRunner();
const diagnostics = await runner.diagnose(code, {
4 changes: 4 additions & 0 deletions packages/openapi3/test/works-for.ts
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ import { OpenAPIVersion } from "../src/lib.js";
import {
checkFor,
diagnoseOpenApiFor,
emitOpenApi,
emitOpenApiWithDiagnostics,
oapiForModel,
openApiFor,
@@ -24,6 +25,7 @@ export type SpecHelper = {
checkFor: typeof checkFor;
diagnoseOpenApiFor: typeof diagnoseOpenApiFor;
emitOpenApiWithDiagnostics: typeof emitOpenApiWithDiagnostics;
emitOpenApi: typeof emitOpenApi;
objectSchemaIndexer: ObjectSchemaIndexer;
};

@@ -45,6 +47,8 @@ function createSpecHelpers(version: OpenAPIVersion): SpecHelper {
emitOpenApiWithDiagnostics: (
...[code, options]: Parameters<typeof emitOpenApiWithDiagnostics>
) => emitOpenApiWithDiagnostics(code, { ...options, "openapi-versions": [version] }),
emitOpenApi: (...[code, options]: Parameters<typeof emitOpenApi>) =>
emitOpenApi(code, { ...options, "openapi-versions": [version] }),
objectSchemaIndexer: version === "3.0.0" ? "additionalProperties" : "unevaluatedProperties",
};
}
Original file line number Diff line number Diff line change
@@ -42,10 +42,11 @@ If the content should be serialized as YAML or JSON. Default 'yaml', it not spec
Name of the output file.
Output file will interpolate the following values:

- service-name: Name of the service if multiple
- service-name: Name of the service
- service-name-if-multiple: Name of the service if multiple
- version: Version of the service if multiple

Default: `{service-name}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"`
Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"`

Example Single service no versioning

Original file line number Diff line number Diff line change
@@ -99,17 +99,17 @@ Here's what would be produced:
| ------------------ | ------------- | ------------------------- |
| `"PetStore"` | `"v1"` | `PetStore/output.v1.json` |
| `"PetStore"` | `undefined` | `PetStore/output.json` |
| `undefined` | `"v1"` | `output.v1.json` |
| `undefined` | `undefined` | `output.json` |

#### Built-in variables

| Variable name | Scope | Description |
| -------------- | --------------- | ------------------------------------------------------------------------------------ |
| `cwd` | \* | Points to the current working directory |
| `project-root` | \* | Points to the the tspconfig.yaml file containing folder. |
| `output-dir` | emitter options | Common `output-dir` See [output-dir](#output-dir---configure-the-default-output-dir) |
| `emitter-name` | emitter options | Name of the emitter |
| Variable name | Scope | Description |
| -------------------------- | -------------------------- | ------------------------------------------------------------------------------------ |
| `cwd` | \* | Points to the current working directory |
| `project-root` | \* | Points to the the tspconfig.yaml file containing folder. |
| `output-dir` | emitter options | Common `output-dir` See [output-dir](#output-dir---configure-the-default-output-dir) |
| `emitter-name` | emitter options | Name of the emitter |
| `service-name` | service name | Name of the service |
| `service-name-if-multiple` | service name if multiple | Name of the service if multiple, undefined for single service |

#### Project Parameters