Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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/glecaros-file-types-2026-2-3-22-49-17.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/openapi3"
---

`file-type` can now receive an array to allow emitting both `json` and `yaml` output in the same run.
7 changes: 7 additions & 0 deletions .chronus/changes/glecaros-file-types-2026-2-3-23-44-19.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/playground"
---

Add support for oneOf option schemas.
6 changes: 4 additions & 2 deletions packages/openapi3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ See [Configuring output directory for more info](https://typespec.io/docs/handbo

### `file-type`

**Type:** `"yaml" | "json"`
**Type:** `string,array`

If the content should be serialized as YAML or JSON. Default 'yaml', it not specified infer from the `output-file` extension
If the content should be serialized as YAML or JSON. Can be a single value or an array to emit multiple formats. Default 'yaml', if not specified infer from the `output-file` extension

### `output-file`

Expand All @@ -58,8 +58,10 @@ Output file will interpolate the following values:
- service-name: Name of the service
- service-name-if-multiple: Name of the service if multiple
- version: Version of the service if multiple
- file-type: The file type being emitted (json or yaml). Useful when `file-type` is an array.

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

Example Single service no versioning

Expand Down
29 changes: 23 additions & 6 deletions packages/openapi3/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ export type OpenAPIVersion = "3.0.0" | "3.1.0" | "3.2.0";
export type ExperimentalParameterExamplesStrategy = "data" | "serialized";
export interface OpenAPI3EmitterOptions {
/**
* If the content should be serialized as YAML or JSON.
* If the content should be serialized as YAML or JSON. Can be a single value or an array to emit multiple file types.
* When an array is provided, the `{file-type}` variable can be used in `output-file` to produce distinct filenames.
* @default yaml, it not specified infer from the `output-file` extension
*/

"file-type"?: FileType;
"file-type"?: FileType | FileType[];

/**
* Name of the output file.
Expand All @@ -18,7 +19,7 @@ export interface OpenAPI3EmitterOptions {
* - service-name-if-multiple: Name of the service if multiple
* - version: Version of the service if multiple
*
* @default `{service-name-if-multiple}.{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"`. When `file-type` is an array, uses `{file-type}` variable.
*
* @example Single service no versioning
* - `openapi.yaml`
Expand Down Expand Up @@ -129,11 +130,25 @@ const EmitterOptionsSchema: JSONSchemaType<OpenAPI3EmitterOptions> = {
additionalProperties: false,
properties: {
"file-type": {
type: "string",
enum: ["yaml", "json"],
type: ["string", "array"],
nullable: true,
oneOf: [
{
type: "string",
enum: ["yaml", "json"],
},
{
type: "array",
items: {
type: "string",
enum: ["yaml", "json"],
},
uniqueItems: true,
minItems: 1,
},
],
description:
"If the content should be serialized as YAML or JSON. Default 'yaml', it not specified infer from the `output-file` extension",
"If the content should be serialized as YAML or JSON. Can be a single value or an array to emit multiple formats. Default 'yaml', if not specified infer from the `output-file` extension",
},
"output-file": {
type: "string",
Expand All @@ -144,8 +159,10 @@ const EmitterOptionsSchema: JSONSchemaType<OpenAPI3EmitterOptions> = {
" - service-name: Name of the service",
" - service-name-if-multiple: Name of the service if multiple",
" - version: Version of the service if multiple",
" - file-type: The file type being emitted (json or yaml). Useful when `file-type` is an array.",
"",
' Default: `{service-name-if-multiple}.{version}.openapi.yaml` or `.json` if `file-type` is `"json"`',
" When `file-type` is an array: `{service-name-if-multiple}.{version}.openapi.{file-type}`",
"",
" Example Single service no versioning",
" - `openapi.yaml`",
Expand Down
50 changes: 34 additions & 16 deletions packages/openapi3/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,17 +206,22 @@ export function resolveOptions(
): ResolvedOpenAPI3EmitterOptions {
const resolvedOptions = { ...defaultOptions, ...context.options };

const fileType =
resolvedOptions["file-type"] ?? findFileTypeFromFilename(resolvedOptions["output-file"]);
const rawFileType = resolvedOptions["file-type"];
const fileTypes: FileType[] = Array.isArray(rawFileType)
? rawFileType
: [rawFileType ?? findFileTypeFromFilename(resolvedOptions["output-file"])];

const outputFile =
resolvedOptions["output-file"] ?? `openapi.{service-name-if-multiple}.{version}.${fileType}`;
resolvedOptions["output-file"] ??
(fileTypes.length > 1
? `openapi.{service-name-if-multiple}.{version}.{file-type}`
: `openapi.{service-name-if-multiple}.{version}.${fileTypes[0]}`);

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

const specDir = openapiVersions.length > 1 ? "{openapi-version}" : "";
return {
fileType,
fileTypes,
newLine: resolvedOptions["new-line"],
omitUnreachableTypes: resolvedOptions["omit-unreachable-types"],
includeXTypeSpecName: resolvedOptions["include-x-typespec-name"],
Expand Down Expand Up @@ -257,7 +262,7 @@ function resolveOperationIdDefaultStrategySeparator(strategy: OperationIdStrateg
}

export interface ResolvedOpenAPI3EmitterOptions {
fileType: FileType;
fileTypes: FileType[];
outputFile: string;
openapiVersions: OpenAPIVersion[];
newLine: NewLine;
Expand Down Expand Up @@ -366,20 +371,27 @@ function createOAPIEmitter(
const multipleService = services.length > 1;
const writeTimer = perf.startTimer();
for (const serviceRecord of services) {
if (serviceRecord.versioned) {
for (const documentRecord of serviceRecord.versions) {
for (const fileType of options.fileTypes) {
if (serviceRecord.versioned) {
for (const documentRecord of serviceRecord.versions) {
await emitFile(program, {
path: resolveOutputFile(
serviceRecord.service,
multipleService,
fileType,
documentRecord.version,
),
content: serializeDocument(documentRecord.document, fileType),
newLine: options.newLine,
});
}
} else {
await emitFile(program, {
path: resolveOutputFile(serviceRecord.service, multipleService, documentRecord.version),
content: serializeDocument(documentRecord.document, options.fileType),
path: resolveOutputFile(serviceRecord.service, multipleService, fileType),
content: serializeDocument(serviceRecord.document, fileType),
newLine: options.newLine,
});
}
} else {
await emitFile(program, {
path: resolveOutputFile(serviceRecord.service, multipleService),
content: serializeDocument(serviceRecord.document, options.fileType),
newLine: options.newLine,
});
}
}
const writeTime = writeTimer.end();
Expand Down Expand Up @@ -598,11 +610,17 @@ function createOAPIEmitter(
return document;
}

function resolveOutputFile(service: Service, multipleService: boolean, version?: string): string {
function resolveOutputFile(
service: Service,
multipleService: boolean,
fileType: FileType,
version?: string,
): string {
return interpolatePath(options.outputFile, {
"openapi-version": specVersion,
"service-name-if-multiple": multipleService ? getNamespaceFullName(service.type) : undefined,
"service-name": getNamespaceFullName(service.type),
"file-type": fileType,
version,
});
}
Expand Down
55 changes: 55 additions & 0 deletions packages/openapi3/test/output-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,61 @@ describe("openapi3: output file", () => {
});
});

describe("multiple file types", () => {
it("emit both json and yaml when file-type is an array", async () => {
await compileOpenAPI({ "file-type": ["json", "yaml"] });
expectOutput("openapi.json", expectedJsonEmptySpec);
expectOutput("openapi.yaml", expectedYamlEmptySpec);
});

it("emit both formats with custom output-file using {file-type}", async () => {
await compileOpenAPI({
"file-type": ["json", "yaml"],
"output-file": "my.spec.{file-type}",
});
expectOutput("my.spec.json", expectedJsonEmptySpec);
expectOutput("my.spec.yaml", expectedYamlEmptySpec);
});

it("emit both formats for multiple services", async () => {
await compileOpenAPI(
{ "file-type": ["json", "yaml"] },
`
@service namespace Service1 {}
@service namespace Service2 {}
`,
);
expectHasOutput("openapi.Service1.json");
expectHasOutput("openapi.Service2.json");
expectHasOutput("openapi.Service1.yaml");
expectHasOutput("openapi.Service2.yaml");
});

it("emit both formats for versioned services", async () => {
await compileOpenAPI(
{ "file-type": ["json", "yaml"] },
`
using Versioning;
@versioned(Versions) @service namespace Service1 {
enum Versions {v1, v2}
}
`,
);
expectHasOutput("openapi.v1.json");
expectHasOutput("openapi.v2.json");
expectHasOutput("openapi.v1.yaml");
expectHasOutput("openapi.v2.yaml");
});

it("{file-type} variable works with single file-type string", async () => {
await compileOpenAPI({
"file-type": "json",
"output-file": "my.spec.{file-type}",
});
expectOutput("my.spec.json", expectedJsonEmptySpec);
});
});

describe("Predefined variable name behavior", () => {
interface ServiceNameCase {
description: string;
Expand Down
35 changes: 31 additions & 4 deletions packages/playground/src/react/settings/emitter-options-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,23 @@ export const EmitterOptionsForm: FunctionComponent<EmitterOptionsFormProps> = ({
return (
<div className={style["form"]}>
{entries.map(([key, value]) => {
const resolved = (value as any).oneOf
? resolveOneOfProperty(value as JsonSchemaOneOfProperty)
: value;
return (
<div key={key} className={style["form-item"]}>
{(value as any).type === "array" ? (
{(resolved as any).type === "array" ? (
<JsonSchemaArrayPropertyInput
emitterOptions={options[library.name] ?? {}}
name={key}
prop={value as any}
prop={resolved as any}
onChange={handleChange}
/>
) : (
<JsonSchemaPropertyInput
emitterOptions={options[library.name] ?? {}}
name={key}
prop={value as any}
prop={resolved as any}
onChange={handleChange}
/>
)}
Expand All @@ -89,6 +92,28 @@ interface JsonSchemaArrayProperty {
readonly items: JsonSchemaScalarProperty;
}

interface JsonSchemaOneOfProperty {
readonly oneOf: ReadonlyArray<JsonSchemaScalarProperty | JsonSchemaArrayProperty>;
readonly description?: string;
}

/**
* Resolve a `oneOf` schema to the most appropriate single schema for rendering.
* Prefers the array branch (if present) since it supports both single and multi-select.
*/
function resolveOneOfProperty(
prop: JsonSchemaOneOfProperty,
): JsonSchemaScalarProperty | JsonSchemaArrayProperty {
const arrayBranch = prop.oneOf.find(
(branch): branch is JsonSchemaArrayProperty => (branch as any).type === "array",
);
if (arrayBranch) {
return { ...arrayBranch, description: arrayBranch.description ?? prop.description };
}
const first = prop.oneOf[0] as JsonSchemaScalarProperty;
return { ...first, description: first.description ?? prop.description };
}

type JsonSchemaArrayPropertyInputProps = Omit<JsonSchemaPropertyInputProps, "prop"> & {
readonly prop: JsonSchemaArrayProperty;
};
Expand All @@ -100,7 +125,9 @@ const JsonSchemaArrayPropertyInput: FunctionComponent<JsonSchemaArrayPropertyInp
onChange,
}) => {
const itemsSchema = prop.items;
const value = emitterOptions[name] ?? itemsSchema.default;
const rawValue = emitterOptions[name] ?? itemsSchema.default;
// Normalize to array: handles cases where a oneOf-resolved property stored a single string
const value = Array.isArray(rawValue) ? rawValue : rawValue != null ? [rawValue] : [];
const prettyName = useMemo(
() => name[0].toUpperCase() + name.slice(1).replace(/-/g, " "),
[name],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ See [Configuring output directory for more info](https://typespec.io/docs/handbo

### `file-type`

**Type:** `"yaml" | "json"`
**Type:** `string,array`

If the content should be serialized as YAML or JSON. Default 'yaml', it not specified infer from the `output-file` extension
If the content should be serialized as YAML or JSON. Can be a single value or an array to emit multiple formats. Default 'yaml', if not specified infer from the `output-file` extension

### `output-file`

Expand All @@ -52,8 +52,10 @@ Output file will interpolate the following values:
- service-name: Name of the service
- service-name-if-multiple: Name of the service if multiple
- version: Version of the service if multiple
- file-type: The file type being emitted (json or yaml). Useful when `file-type` is an array.

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

Example Single service no versioning

Expand Down
Loading