diff --git a/.chronus/changes/openapi-license-identifier-2026-7-17-21-15-0.md b/.chronus/changes/openapi-license-identifier-2026-7-17-21-15-0.md new file mode 100644 index 00000000000..b64dc859c70 --- /dev/null +++ b/.chronus/changes/openapi-license-identifier-2026-7-17-21-15-0.md @@ -0,0 +1,16 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi" +--- + +Add support for the OpenAPI `info.license.identifier` field (an SPDX license expression) on the `License` model used with `@info`. The `identifier` and `url` fields are mutually exclusive; specifying both now results in a diagnostic. + +```tsp +@info(#{ + license: #{ + name: "Apache 2.0", + identifier: "Apache-2.0", + }, +}) +``` diff --git a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts index dbcde2e60c8..c37e32894b4 100644 --- a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts +++ b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts @@ -46,6 +46,7 @@ export interface Contact { export interface License { readonly [key: string]: unknown; readonly name: string; + readonly identifier?: string; readonly url?: string; } diff --git a/packages/openapi/lib/decorators.tsp b/packages/openapi/lib/decorators.tsp index d52921307ae..357291979e4 100644 --- a/packages/openapi/lib/decorators.tsp +++ b/packages/openapi/lib/decorators.tsp @@ -103,7 +103,14 @@ 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. */ + /** + * An [SPDX](https://spdx.org/licenses/) license expression for the API. The `identifier` field is mutually exclusive of the `url` field. + */ + identifier?: string; + + /** + * A URL to the license used for the API. MUST be in the format of a URL. The `url` field is mutually exclusive of the `identifier` field. + */ url?: url; ...Record; diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index e32ab14a7cf..ee36ea2047d 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -274,6 +274,16 @@ export const $info: InfoDecorator = ( return; } } + + // Validate that license `url` and `identifier` are mutually exclusive + if (data.license?.url && data.license?.identifier) { + reportDiagnostic(context.program, { + code: "license-url-and-identifier", + target: context.getArgumentTarget(0)!, + }); + return; + } + setInfo(context.program, entity, data); }; diff --git a/packages/openapi/src/lib.ts b/packages/openapi/src/lib.ts index a62e2dd2f87..5e4266ce345 100644 --- a/packages/openapi/src/lib.ts +++ b/packages/openapi/src/lib.ts @@ -22,6 +22,12 @@ export const $lib = createTypeSpecLibrary({ default: paramMessage`${"property"}: ${"value"} is not a valid URL.`, }, }, + "license-url-and-identifier": { + severity: "error", + messages: { + default: `License "url" and "identifier" are mutually exclusive. Specify only one of them.`, + }, + }, "duplicate-tag": { severity: "error", messages: { diff --git a/packages/openapi/src/types.ts b/packages/openapi/src/types.ts index ce04d561c76..1e891403213 100644 --- a/packages/openapi/src/types.ts +++ b/packages/openapi/src/types.ts @@ -51,7 +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. */ + /** + * An SPDX license expression for the API. Mutually exclusive of the `url` field. + */ + identifier?: string; + + /** + * A URL to the license used for the API. MUST be in the format of a URL. Mutually exclusive of the `identifier` field. + */ url?: string; } diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index 6adbeeb2503..2d3da2122d9 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -248,6 +248,48 @@ describe("@info", () => { }); }); + it("set license identifier", async () => { + const { program, Service } = await Tester.compile(t.code` + @info(#{ + title: "My API", + version: "1.0.0", + license: #{ + name: "Apache 2.0", + identifier: "Apache-2.0" + }, + }) + namespace ${t.namespace("Service")} {} + `); + + deepStrictEqual(getInfo(program, Service), { + title: "My API", + version: "1.0.0", + license: { + name: "Apache 2.0", + identifier: "Apache-2.0", + }, + }); + }); + + it("emit diagnostic if license has both url and identifier", async () => { + const diagnostics = await Tester.diagnose(` + @info(#{ + title: "My API", + version: "1.0.0", + license: #{ + name: "Apache 2.0", + identifier: "Apache-2.0", + url: "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + }) + namespace Service {} + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/openapi/license-url-and-identifier", + }); + }); + it("resolveInfo() merge with data from @service and @summary", async () => { const { program, Service } = await Tester.compile(t.code` @service(#{ diff --git a/packages/openapi3/test/info.test.ts b/packages/openapi3/test/info.test.ts index 0211617a3e9..f190487d0be 100644 --- a/packages/openapi3/test/info.test.ts +++ b/packages/openapi3/test/info.test.ts @@ -79,4 +79,25 @@ worksFor(supportedVersions, ({ openApiFor }) => { }, }); }); + + it("set the license identifier with @info decorator", async () => { + const res = await openApiFor( + ` + @service + @info(#{ + license: #{ + name: "Apache 2.0", + identifier: "Apache-2.0" + }, + }) + namespace Foo { + op test(): string; + } + `, + ); + deepStrictEqual(res.info.license, { + name: "Apache 2.0", + identifier: "Apache-2.0", + }); + }); }); diff --git a/website/src/content/docs/docs/libraries/openapi/reference/data-types.md b/website/src/content/docs/docs/libraries/openapi/reference/data-types.md index 82287602fdf..1e92ea9e910 100644 --- a/website/src/content/docs/docs/libraries/openapi/reference/data-types.md +++ b/website/src/content/docs/docs/libraries/openapi/reference/data-types.md @@ -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. | +| identifier? | `string` | An [SPDX](https://spdx.org/licenses/) license expression for the API. The `identifier` field is mutually exclusive of the `url` field. | +| url? | `url` | A URL to the license used for the API. MUST be in the format of a URL. The `url` field is mutually exclusive of the `identifier` field. | +| | `unknown` | Additional properties | ### `TagMetadata` {#TypeSpec.OpenAPI.TagMetadata}