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
15 changes: 15 additions & 0 deletions .chronus/changes/license-identifier-2026-7-20.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
changeKind: feature
packages:
- "@typespec/openapi"
- "@typespec/openapi3"
---

Add `identifier` field to the `License` model in `@typespec/openapi`. This is an SPDX license expression for the API (e.g. `"MIT"`, `"Apache-2.0"`). The `identifier` and `url` fields are mutually exclusive. For OpenAPI 3.1+, `identifier` is emitted as-is; for OpenAPI 3.0, it is emitted as the `x-oai-license-identifier` extension. Importing an OpenAPI document also supports reading back `identifier` (or `x-oai-license-identifier` for 3.0 documents).

```typespec
@info(#{
license: #{ name: "MIT", identifier: "MIT" },
})
namespace MyService;
```
1 change: 1 addition & 0 deletions packages/openapi/generated-defs/TypeSpec.OpenAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export interface License {
readonly [key: string]: unknown;
readonly name: string;
readonly url?: string;
readonly identifier?: string;
}

export interface ExternalDocs {
Expand Down
5 changes: 4 additions & 1 deletion packages/openapi/lib/decorators.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,12 @@ model License {
/** The license name used for the API. */
name: string;

/** A URL to the license used for the API. MUST be in the format of a URL. */
/** A URL to the license used for the API. MUST be in the format of a URL. Mutually exclusive with `identifier`. */
url?: url;

/** An SPDX license expression for the API. Mutually exclusive with `url`. Only supported in OpenAPI 3.1+. For OpenAPI 3.0, this will be emitted as `x-oai-license-identifier`. */
identifier?: string;

...Record<unknown>;
}

Expand Down
10 changes: 10 additions & 0 deletions packages/openapi/src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,16 @@ export const $info: InfoDecorator = (
return;
}
}

// Validate license: url and identifier are mutually exclusive
if (data.license?.url !== undefined && data.license?.identifier !== undefined) {
reportDiagnostic(context.program, {
code: "license-url-identifier-conflict",
target: context.getArgumentTarget(0)!,
});
return;
}

setInfo(context.program, entity, data);
};

Expand Down
6 changes: 6 additions & 0 deletions packages/openapi/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ export const $lib = createTypeSpecLibrary({
error: `@defaultResponse should not be used on a model that is marked with @error. Use either @defaultResponse or @error, not both.`,
},
},
"license-url-identifier-conflict": {
severity: "error",
messages: {
default: `License 'url' and 'identifier' are mutually exclusive. Specify only one of them.`,
},
},
},
state: {
tagsMetadata: { description: "State for the @tagMetadata decorator." },
Expand Down
8 changes: 7 additions & 1 deletion packages/openapi/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,14 @@ export interface License {
/** The license name used for the API. */
name: string;

/** A URL to the license used for the API. MUST be in the format of a URL. */
/** A URL to the license used for the API. MUST be in the format of a URL. Mutually exclusive with `identifier`. */
url?: string;

/** An SPDX license expression for the API. Mutually exclusive with `url`. Only supported in OpenAPI 3.1+. For OpenAPI 3.0, this will be emitted as `x-oai-license-identifier`. */
identifier?: string;
Comment thread
baywet marked this conversation as resolved.
Resolved

/** Additional extension data. Keys must start with `x-`. */
[extensionKey: string]: unknown;
}

/**
Expand Down
36 changes: 36 additions & 0 deletions packages/openapi/test/decorators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,42 @@ describe("@info", () => {
});
});

it("set license identifier", async () => {
const { program, Service } = await Tester.compile(t.code`
@info(#{
license: #{
name: "MIT",
identifier: "MIT",
},
})
namespace ${t.namespace("Service")} {}
`);

deepStrictEqual(getInfo(program, Service), {
license: {
name: "MIT",
identifier: "MIT",
},
});
});

it("emit diagnostic when both license url and identifier are set", async () => {
const diagnostics = await Tester.diagnose(`
@info(#{
license: #{
name: "Apache 2.0",
url: "http://www.apache.org/licenses/LICENSE-2.0.html",
identifier: "Apache-2.0",
},
})
namespace Service {}
`);

expectDiagnostics(diagnostics, {
code: "@typespec/openapi/license-url-identifier-conflict",
});
});

it("emit diagnostic if termsOfService is not a valid url", async () => {
const diagnostics = await Tester.diagnose(`
@info(#{termsOfService:"notvalidurl"})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
import type { License } from "@typespec/openapi";
import { OpenAPI3Info } from "../../../../types.js";
import { TypeSpecServiceInfo } from "../interfaces.js";

export function transformServiceInfo(info: OpenAPI3Info): TypeSpecServiceInfo {
let license = info.license;
if (license) {
// Handle x-oai-license-identifier extension from OpenAPI 3.0
const licenseRecord = license as unknown as Record<string, unknown>;
const xOaiIdentifier = licenseRecord["x-oai-license-identifier"];
if (typeof xOaiIdentifier === "string") {
const { "x-oai-license-identifier": _, ...rest } = licenseRecord;
license = { ...(rest as unknown as License), identifier: xOaiIdentifier };
}
Comment thread
Copilot marked this conversation as resolved.
Resolved
Comment thread
baywet marked this conversation as resolved.
Resolved
}

return {
name: info.title,
doc: info.description,
version: info.version,
contact: info.contact,
license: info.license,
license,
termsOfService: info.termsOfService,
summary: info.summary,
};
Expand Down
12 changes: 11 additions & 1 deletion packages/openapi3/src/openapi-spec-mappings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,21 @@ function createRoot(
): SupportedOpenAPIDocuments {
const info = resolveInfo(program, serviceType);

// Handle license identifier: for OpenAPI 3.0, use x-oai-license-identifier extension
let processedInfo = info;
if (info?.license?.identifier !== undefined && specVersion === "3.0.0") {
const { identifier, ...restLicense } = info.license;
processedInfo = {
...info,
license: { ...restLicense, "x-oai-license-identifier": identifier },
};
}

return {
openapi: specVersion,
info: {
title: "(title)",
...info,
...processedInfo,
version: serviceVersion ?? info?.version ?? "0.0.0",
},
externalDocs: getExternalDocs(program, serviceType),
Expand Down
69 changes: 67 additions & 2 deletions packages/openapi3/test/info.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { deepStrictEqual, strictEqual } from "assert";
import { it } from "vitest";
import { supportedVersions, worksFor } from "./works-for.js";
import { describe, it } from "vitest";
import { OpenAPISpecHelpers, supportedVersions, worksFor } from "./works-for.js";

worksFor(supportedVersions, ({ openApiFor }) => {
it("set the service title with @service", async () => {
Expand Down Expand Up @@ -80,3 +80,68 @@ worksFor(supportedVersions, ({ openApiFor }) => {
});
});
});

describe("license identifier", () => {
it("OpenAPI 3.1+ emits license identifier as 'identifier' field", async () => {
const res = await OpenAPISpecHelpers["3.1.0"].openApiFor(
`
@service
@info(#{
license: #{
name: "MIT",
identifier: "MIT",
},
})
namespace Foo {
op test(): string;
}
`,
);
deepStrictEqual(res.info.license, {
name: "MIT",
identifier: "MIT",
});
});

it("OpenAPI 3.2 emits license identifier as 'identifier' field", async () => {
const res = await OpenAPISpecHelpers["3.2.0"].openApiFor(
`
@service
@info(#{
license: #{
name: "MIT",
identifier: "MIT",
},
})
namespace Foo {
op test(): string;
}
`,
);
deepStrictEqual(res.info.license, {
name: "MIT",
identifier: "MIT",
});
});

it("OpenAPI 3.0 emits license identifier as 'x-oai-license-identifier' extension", async () => {
const res = await OpenAPISpecHelpers["3.0.0"].openApiFor(
`
@service
@info(#{
license: #{
name: "MIT",
identifier: "MIT",
},
})
namespace Foo {
op test(): string;
}
`,
);
deepStrictEqual(res.info.license, {
name: "MIT",
"x-oai-license-identifier": "MIT",
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -1146,4 +1146,72 @@ describe("convertOpenAPI3Document tag metadata", () => {
);
}
});

it("converts OpenAPI 3.1 license with identifier field", async () => {
const tsp = await convertOpenAPI3Document({
info: {
title: "My API",
version: "1.0.0",
license: {
name: "MIT",
identifier: "MIT",
},
},
openapi: "3.1.0",
paths: {},
});

strictEqual(
tsp,
await formatTypeSpec(
`
import "@typespec/http";
import "@typespec/openapi";
import "@typespec/openapi3";

using Http;
using OpenAPI;

@service(#{ title: "My API" })
@info(#{ version: "1.0.0", license: #{ name: "MIT", identifier: "MIT" } })
namespace MyAPI;
`,
{ printWidth: 100, tabWidth: 2 },
),
);
});

it("converts OpenAPI 3.0 license with x-oai-license-identifier extension to identifier field", async () => {
const tsp = await convertOpenAPI3Document({
info: {
title: "My API",
version: "1.0.0",
license: {
name: "MIT",
"x-oai-license-identifier": "MIT",
} as any,
},
openapi: "3.0.0",
paths: {},
});

strictEqual(
tsp,
await formatTypeSpec(
`
import "@typespec/http";
import "@typespec/openapi";
import "@typespec/openapi3";

using Http;
using OpenAPI;

@service(#{ title: "My API" })
@info(#{ version: "1.0.0", license: #{ name: "MIT", identifier: "MIT" } })
namespace MyAPI;
`,
{ printWidth: 100, tabWidth: 2 },
),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,12 @@ model TypeSpec.OpenAPI.License

#### Properties

| Name | Type | Description |
| ---- | --------- | ---------------------------------------------------------------------- |
| name | `string` | The license name used for the API. |
| url? | `url` | A URL to the license used for the API. MUST be in the format of a URL. |
| | `unknown` | Additional properties |
| Name | Type | Description |
| ----------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| name | `string` | The license name used for the API. |
| url? | `url` | A URL to the license used for the API. MUST be in the format of a URL. Mutually exclusive with `identifier`. |
| identifier? | `string` | An SPDX license expression for the API. Mutually exclusive with `url`. Only supported in OpenAPI 3.1+. For OpenAPI 3.0, this will be emitted as `x-oai-license-identifier`. |
| | `unknown` | Additional properties |

### `TagMetadata` {#TypeSpec.OpenAPI.TagMetadata}

Expand Down
Loading