diff --git a/.chronus/changes/autorest-client-api-version-override-2026-08-04-13-00-00.md b/.chronus/changes/autorest-client-api-version-override-2026-08-04-13-00-00.md new file mode 100644 index 0000000000..48e5aca2e1 --- /dev/null +++ b/.chronus/changes/autorest-client-api-version-override-2026-08-04-13-00-00.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-autorest" +--- + +Honor inherited Azure Core API-version overrides in emitted OpenAPI documents and warn when a document has inconsistent overrides. diff --git a/.chronus/changes/feature-file-client-version-2026-08-04-13-00-00.md b/.chronus/changes/feature-file-client-version-2026-08-04-13-00-00.md new file mode 100644 index 0000000000..f86cdff2d7 --- /dev/null +++ b/.chronus/changes/feature-file-client-version-2026-08-04-13-00-00.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-azure-resource-manager" +--- + +Add a `version` option to `@featureFileOptions` for overriding the generated client API version. diff --git a/.chronus/changes/go-client-api-version-override-2026-08-04-13-00-00.md b/.chronus/changes/go-client-api-version-override-2026-08-04-13-00-00.md new file mode 100644 index 0000000000..f7cd30ad59 --- /dev/null +++ b/.chronus/changes/go-client-api-version-override-2026-08-04-13-00-00.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-go" +--- + +Generate Go clients with the opaque API-version default configured for each client. diff --git a/.chronus/changes/override-api-version-azure-core-2026-08-06-13-29-45.md b/.chronus/changes/override-api-version-azure-core-2026-08-06-13-29-45.md new file mode 100644 index 0000000000..b3a3d8f430 --- /dev/null +++ b/.chronus/changes/override-api-version-azure-core-2026-08-06-13-29-45.md @@ -0,0 +1,15 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-azure-core" +--- + +Add the legacy `@Azure.Core.Legacy.overrideApiVersion` decorator for overriding inherited and +optionally language-scoped API-version wire defaults on namespaces and interfaces. + +```typespec +@Azure.Core.Legacy.overrideApiVersion("2021-11-01") +interface Widgets { + get(): void; +} +``` diff --git a/.chronus/changes/override-client-api-version-tcgc-2026-08-04-13-00-00.md b/.chronus/changes/override-client-api-version-tcgc-2026-08-04-13-00-00.md new file mode 100644 index 0000000000..e184134440 --- /dev/null +++ b/.chronus/changes/override-client-api-version-tcgc-2026-08-04-13-00-00.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-client-generator-core" +--- + +Populate per-client API-version defaults from `Azure.Core.Legacy.overrideApiVersion` for downstream emitters. diff --git a/.chronus/changes/typescript-client-api-version-override-2026-08-04-13-00-00.md b/.chronus/changes/typescript-client-api-version-override-2026-08-04-13-00-00.md new file mode 100644 index 0000000000..9e1a4d299b --- /dev/null +++ b/.chronus/changes/typescript-client-api-version-override-2026-08-04-13-00-00.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-ts" +--- + +Generate TypeScript clients with the opaque API-version default configured for each client. diff --git a/packages/typespec-autorest/src/lib.ts b/packages/typespec-autorest/src/lib.ts index 78077145c7..ef560252ad 100644 --- a/packages/typespec-autorest/src/lib.ts +++ b/packages/typespec-autorest/src/lib.ts @@ -464,6 +464,12 @@ export const $lib = createTypeSpecLibrary({ "Cannot emit service.yaml because the project defines multiple services. Only the first service will be included.", }, }, + "inconsistent-client-api-version-override": { + severity: "warning", + messages: { + default: paramMessage`Operations emitted to the same OpenAPI document must specify one consistent \`@overrideApiVersion\` value. Found values: ${"values"}. The normal document version ${"fallback"} will be retained.`, + }, + }, }, emitter: { options: EmitterOptionsSchema as JSONSchemaType, diff --git a/packages/typespec-autorest/src/openapi.ts b/packages/typespec-autorest/src/openapi.ts index 633f122554..e9c7dfe75f 100644 --- a/packages/typespec-autorest/src/openapi.ts +++ b/packages/typespec-autorest/src/openapi.ts @@ -5,12 +5,13 @@ import { extractLroStates, getArmResourceIdentifierConfig, getAsEmbeddingVector, + getEffectiveApiVersionOverride, getLroMetadata, getUnionAsEnum, hasUniqueItems, } from "@azure-tools/typespec-azure-core"; import { - type ArmFeatureOptions, + type ArmFeatureFileOptions, getArmCommonTypeOpenAPIRef, getArmIdentifiers, getArmKeyIdentifiers, @@ -3035,6 +3036,7 @@ export function createDefaultDocumentProxy( const definitions = new Map(); const parameters: Map = new Map(); const operationIds = new DuplicateTracker(); + const operations: HttpOperation[] = []; let examples: Map> = new Map(); let operationIdsWithExamples: Set = new Set(); return { @@ -3066,6 +3068,7 @@ export function createDefaultDocumentProxy( }, createOrGetEndpoint(op: HttpOperation, context: AutorestEmitterContext): OpenAPI2Operation { + operations.push(op); const pathItem = initPathItem(program, op, root); if (!pathItem[op.verb]) { pathItem[op.verb] = { parameters: [] }; @@ -3109,6 +3112,7 @@ export function createDefaultDocumentProxy( }, resolveDocuments(context: AutorestEmitterContext) { reportDuplicateOperationIds(program, operationIds); + applyClientApiVersionOverride(root, operations, context, service.type); root.definitions = {}; for (const [name, schema] of definitions) { root.definitions[name] = schema; @@ -3158,8 +3162,9 @@ export function createDefaultDocumentProxy( interface OpenAPI2DocumentItem { document: OpenAPI2Document; operationExamples: Map; + operations: HttpOperation[]; tags: Set; - options: ArmFeatureOptions; + options: ArmFeatureFileOptions; } function createFeatureDocumentProxy( @@ -3221,6 +3226,7 @@ function createFeatureDocumentProxy( createOrGetEndpoint(op: HttpOperation, context: AutorestEmitterContext): OpenAPI2Operation { const options = getFeature(program, op.operation); const item = root.get(options.featureName.toLowerCase())!; + item.operations.push(op); const pathItem = initPathItem(program, op, item.document); if (!pathItem[op.verb]) { pathItem[op.verb] = { parameters: [] }; @@ -3281,6 +3287,13 @@ function createFeatureDocumentProxy( reportDuplicateOperationIds(program, tracker); } for (const [featureName, featureItem] of root.entries()) { + applyClientApiVersionOverride( + featureItem.document, + featureItem.operations, + context, + service.type, + featureItem.options.version, + ); const exampleIds = operationFeatures.get(featureName) || new Set(); const featureExamples = [...exampleIds] .filter((id) => operationIdsWithExamples.has(id)) @@ -3377,17 +3390,57 @@ function reportDuplicateOperationIds( function initializeOpenAPIDocumentItem( program: Program, service: Service, - options: ArmFeatureOptions, + options: ArmFeatureFileOptions, version?: string, ): OpenAPI2DocumentItem { return { document: initializeOpenApi2Document(program, service, version), operationExamples: new Map(), + operations: [], tags: new Set(), options, }; } +function applyClientApiVersionOverride( + document: OpenAPI2Document, + operations: HttpOperation[], + context: AutorestEmitterContext, + diagnosticTarget: Namespace, + explicitVersion?: string, +): void { + if (explicitVersion !== undefined) { + document.info.version = explicitVersion; + return; + } + if (operations.length === 0) return; + + const overrides = operations.map((operation) => + getEffectiveApiVersionOverride( + context.program, + operation.operation, + "@azure-tools/typespec-autorest", + ), + ); + if (overrides.every((value) => value === undefined)) return; + + const first = overrides[0]; + if (first !== undefined && overrides.every((value) => value === first)) { + document.info.version = first; + return; + } + + const values = [...new Set(overrides.map((value) => value ?? ""))]; + reportDiagnostic(context.program, { + code: "inconsistent-client-api-version-override", + format: { + values: values.join(", "), + fallback: document.info.version, + }, + target: diagnosticTarget, + }); +} + function initializeOpenApi2Document( program: Program, service: Service, diff --git a/packages/typespec-autorest/test/arm/client-api-version.test.ts b/packages/typespec-autorest/test/arm/client-api-version.test.ts new file mode 100644 index 0000000000..12c35a5879 --- /dev/null +++ b/packages/typespec-autorest/test/arm/client-api-version.test.ts @@ -0,0 +1,299 @@ +import type { Diagnostic } from "@typespec/compiler"; +import { + expectDiagnosticEmpty, + expectDiagnostics, + resolveVirtualPath, +} from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import type { OpenAPI2Document } from "../../src/openapi2-document.js"; +import { AzureTester, compileVersionedOpenAPI } from "../test-host.js"; + +const emitterOptions = { + "emitter-output-dir": resolveVirtualPath("./tsp-output"), + "output-file": "{emitter-output-dir}/openapi.json", +}; + +async function emitDefault(code: string): Promise<[OpenAPI2Document, readonly Diagnostic[]]> { + const tester = await AzureTester.createInstance(); + const [{ outputs }, diagnostics] = await tester.compileAndDiagnose(code, { + compilerOptions: { + options: { + "@azure-tools/typespec-autorest": emitterOptions, + }, + }, + }); + + expect(outputs["openapi.json"]).toBeDefined(); + return [JSON.parse(outputs["openapi.json"]), diagnostics]; +} + +async function emitFeatures( + code: string, +): Promise<[Record, readonly Diagnostic[]]> { + const tester = await AzureTester.createInstance(); + const [{ outputs }, diagnostics] = await tester.compileAndDiagnose(code, { + compilerOptions: { + options: { + "@azure-tools/typespec-autorest": { + ...emitterOptions, + "output-splitting": "legacy-feature-files", + "output-file": "{emitter-output-dir}/{feature}.json", + }, + }, + }, + }); + + return [ + Object.fromEntries( + Object.entries(outputs).map(([name, content]) => [ + name.replace(/\.json$/, ""), + JSON.parse(content), + ]), + ), + diagnostics, + ]; +} + +const suppressStandardOperations = + '#suppress "@azure-tools/typespec-azure-core/use-standard-operations" "Test operation."'; + +describe("client API version inference", () => { + it("infers a default document version from an enclosing namespace override", async () => { + const [openapi, diagnostics] = await emitDefault(` + @service + @info(#{version: "fallback-version"}) + @Azure.Core.Legacy.overrideApiVersion("2020-01-01") + namespace Microsoft.Test { + interface Widgets { + ${suppressStandardOperations} + @get @route("/widgets") op list(): string[]; + } + + interface Gadgets { + ${suppressStandardOperations} + @get @route("/gadgets") op list(): string[]; + } + } + `); + + expectDiagnosticEmpty(diagnostics); + expect(openapi.info.version).toBe("2020-01-01"); + }); + + it("ignores overrides scoped to another emitter", async () => { + const [openapi, diagnostics] = await emitDefault(` + @service + @info(#{version: "fallback-version"}) + @Azure.Core.Legacy.overrideApiVersion("2020-01-01", "python") + namespace Microsoft.Test { + interface Widgets { + ${suppressStandardOperations} + @get @route("/widgets") op list(): string[]; + } + } + `); + + expectDiagnosticEmpty(diagnostics); + expect(openapi.info.version).toBe("fallback-version"); + }); + + it("infers each feature document independently and leaves common on the fallback", async () => { + const [openapi, diagnostics] = await emitFeatures(` + @service + @info(#{version: "fallback-version"}) + @Azure.ResourceManager.featureFiles(Features) + @armProviderNamespace("Microsoft.Test") + namespace Microsoft.Test; + + enum Features { + FeatureA, + FeatureB, + Common, + } + + @Azure.ResourceManager.featureFile(Features.FeatureA) + @Azure.Core.Legacy.overrideApiVersion("2020-01-01") + interface FeatureAOperations { + ${suppressStandardOperations} + @get @route("/feature-a") op get(): string; + } + + @Azure.ResourceManager.featureFile(Features.FeatureB) + @Azure.Core.Legacy.overrideApiVersion("2021-02-02") + interface FeatureBOperations { + ${suppressStandardOperations} + @get @route("/feature-b") op get(): string; + } + `); + + expectDiagnosticEmpty(diagnostics); + expect(openapi.featureA.info.version).toBe("2020-01-01"); + expect(openapi.featureB.info.version).toBe("2021-02-02"); + expect(openapi.common.info.version).toBe("fallback-version"); + }); + + it("warns once for a mixture of overridden and ordinary clients and retains fallback", async () => { + const [openapi, diagnostics] = await emitDefault(` + @service + @info(#{version: "fallback-version"}) + namespace Microsoft.Test { + @Azure.Core.Legacy.overrideApiVersion("2020-01-01") + interface Overridden { + ${suppressStandardOperations} + @get @route("/overridden") op get(): string; + } + + interface Ordinary { + ${suppressStandardOperations} + @get @route("/ordinary") op get(): string; + } + } + `); + + expectDiagnostics(diagnostics, { + code: "@azure-tools/typespec-autorest/inconsistent-client-api-version-override", + severity: "warning", + message: + "Operations emitted to the same OpenAPI document must specify one consistent `@overrideApiVersion` value. Found values: 2020-01-01, . The normal document version fallback-version will be retained.", + }); + expect(diagnostics[0].target.kind).toBe("Namespace"); + expect(openapi.info.version).toBe("fallback-version"); + }); + + it("warns once for two distinct overrides and retains fallback", async () => { + const [openapi, diagnostics] = await emitDefault(` + @service + @info(#{version: "fallback-version"}) + namespace Microsoft.Test { + @Azure.Core.Legacy.overrideApiVersion("2020-01-01") + interface First { + ${suppressStandardOperations} + @get @route("/first") op get(): string; + } + + @Azure.Core.Legacy.overrideApiVersion("2021-02-02") + interface Second { + ${suppressStandardOperations} + @get @route("/second") op get(): string; + } + } + `); + + expectDiagnostics(diagnostics, { + code: "@azure-tools/typespec-autorest/inconsistent-client-api-version-override", + severity: "warning", + message: + "Operations emitted to the same OpenAPI document must specify one consistent `@overrideApiVersion` value. Found values: 2020-01-01, 2021-02-02. The normal document version fallback-version will be retained.", + }); + expect(diagnostics[0].target.kind).toBe("Namespace"); + expect(openapi.info.version).toBe("fallback-version"); + }); + + it("gives explicit feature versions highest precedence and suppresses file mismatch warnings", async () => { + const [openapi, diagnostics] = await emitFeatures(` + @service + @info(#{version: "fallback-version"}) + @Azure.ResourceManager.featureFiles(Features) + @armProviderNamespace("Microsoft.Test") + namespace Microsoft.Test; + + enum Features { + @Azure.ResourceManager.featureFileOptions(#{ + featureName: "FeatureA", + fileName: "feature-a", + description: "Feature A", + version: "explicit-feature-version" + }) + FeatureA, + FeatureB, + Common, + } + + @Azure.ResourceManager.featureFile(Features.FeatureA) + @Azure.Core.Legacy.overrideApiVersion("2020-01-01") + interface First { + ${suppressStandardOperations} + @get @route("/first") op get(): string; + } + + @Azure.ResourceManager.featureFile(Features.FeatureA) + @Azure.Core.Legacy.overrideApiVersion("2021-02-02") + interface Second { + ${suppressStandardOperations} + @get @route("/second") op get(): string; + } + `); + + expectDiagnosticEmpty(diagnostics); + expect(openapi["feature-a"].info.version).toBe("explicit-feature-version"); + expect(openapi.featureB.info.version).toBe("fallback-version"); + expect(openapi.common.info.version).toBe("fallback-version"); + }); + + it("preserves existing fallback behavior when overrides and explicit feature versions are omitted", async () => { + const [openapi, diagnostics] = await emitFeatures(` + @service + @info(#{version: "fallback-version"}) + @Azure.ResourceManager.featureFiles(Features) + @armProviderNamespace("Microsoft.Test") + namespace Microsoft.Test; + + enum Features { + @Azure.ResourceManager.featureFileOptions(#{ + featureName: "FeatureA", + fileName: "feature-a", + description: "Feature A" + }) + FeatureA, + FeatureB, + Common, + } + + @Azure.ResourceManager.featureFile(Features.FeatureA) + interface FeatureAOperations { + ${suppressStandardOperations} + @get @route("/feature-a") op get(): string; + } + `); + + expectDiagnosticEmpty(diagnostics); + expect(openapi["feature-a"].info.version).toBe("fallback-version"); + expect(openapi.featureB.info.version).toBe("fallback-version"); + expect(openapi.common.info.version).toBe("fallback-version"); + }); + + it("uses replacement interface overrides in their corresponding service projections", async () => { + const documents = await compileVersionedOpenAPI( + ` + @service + @versioned(Versions) + namespace Microsoft.Test; + + enum Versions { + v1: "2024-01-01", + v2: "2025-01-01", + } + + @removed(Versions.v2) + @renamedFrom(Versions.v2, "Operations") + @Azure.Core.Legacy.overrideApiVersion("legacy-version") + interface OperationsV1 { + @sharedRoute + @get @route("/widgets") op get(): string; + } + + @added(Versions.v2) + @Azure.Core.Legacy.overrideApiVersion("replacement-version") + interface Operations { + @sharedRoute + @get @route("/widgets") op get(): string; + } + `, + ["2024-01-01", "2025-01-01"], + { tester: await AzureTester.createInstance() }, + ); + + expect(documents["2024-01-01"].info.version).toBe("legacy-version"); + expect(documents["2025-01-01"].info.version).toBe("replacement-version"); + }); +}); diff --git a/packages/typespec-autorest/test/test-host.ts b/packages/typespec-autorest/test/test-host.ts index 25a52d524c..4c4fd0da43 100644 --- a/packages/typespec-autorest/test/test-host.ts +++ b/packages/typespec-autorest/test/test-host.ts @@ -100,7 +100,9 @@ export async function compileMultipleOpenAPI( files: Record, options: CompileOpenAPIOptions = {}, ): Promise> { - const [{ outputs }, diagnostics] = await Tester.compileAndDiagnose(code, { + const tester = + options?.tester ?? (await (options.preset === "azure" ? AzureTester : Tester).createInstance()); + const [{ outputs }, diagnostics] = await tester.compileAndDiagnose(code, { compilerOptions: options?.options ? { options: { diff --git a/packages/typespec-azure-core/README.md b/packages/typespec-azure-core/README.md index 7ef9e3b2c9..8f64770cc9 100644 --- a/packages/typespec-azure-core/README.md +++ b/packages/typespec-azure-core/README.md @@ -434,6 +434,48 @@ Identifies a property on _all_ non-error response models that serve as a linked | ---- | ---------------- | --------------------------- | | name | `valueof string` | Property name on the target | +### Azure.Core.Legacy + +- [`@overrideApiVersion`](#@overrideapiversion) + +#### `@overrideApiVersion` + +Overrides the API-version wire value used for operations within a namespace or interface. + +The value is opaque and does not need to be declared by the service version enum. The override +is inherited by enclosed namespaces, interfaces, and operations, with the nearest override taking +precedence. + +This decorator is considered legacy functionality and should only be used to preserve +compatibility with an existing SDK. + +```typespec +@Azure.Core.Legacy.overrideApiVersion(version: valueof string, scope?: valueof string) +``` + +##### Target + +The namespace or interface whose operations use the API-version override. +`Namespace | Interface` + +##### Parameters + +| Name | Type | Description | +| ------- | ---------------- | ---------------------------------------------------- | +| version | `valueof string` | The non-empty API-version wire value. | +| scope | `valueof string` | The language emitters to which the override applies. | + +##### Examples + +###### Override an interface API version + +```typespec +@Azure.Core.Legacy.overrideApiVersion("2021-11-01") +interface Widgets { + get(): void; +} +``` + ### Azure.Core.Traits - [`@trait`](#@trait) diff --git a/packages/typespec-azure-core/generated-defs/Azure.Core.Legacy.ts b/packages/typespec-azure-core/generated-defs/Azure.Core.Legacy.ts new file mode 100644 index 0000000000..64853cd6b7 --- /dev/null +++ b/packages/typespec-azure-core/generated-defs/Azure.Core.Legacy.ts @@ -0,0 +1,38 @@ +import type { + DecoratorContext, + DecoratorValidatorCallbacks, + Interface, + Namespace, +} from "@typespec/compiler"; + +/** + * Overrides the API-version wire value used for operations within a namespace or interface. + * + * The value is opaque and does not need to be declared by the service version enum. The override + * is inherited by enclosed namespaces, interfaces, and operations, with the nearest override taking + * precedence. + * + * This decorator is considered legacy functionality and should only be used to preserve + * compatibility with an existing SDK. + * + * @param target The namespace or interface whose operations use the API-version override. + * @param version The non-empty API-version wire value. + * @param scope The language emitters to which the override applies. + * @example Override an interface API version + * ```typespec + * @Azure.Core.Legacy.overrideApiVersion("2021-11-01") + * interface Widgets { + * get(): void; + * } + * ``` + */ +export type OverrideApiVersionDecorator = ( + context: DecoratorContext, + target: Namespace | Interface, + version: string, + scope?: string, +) => DecoratorValidatorCallbacks | void; + +export type AzureCoreLegacyDecorators = { + overrideApiVersion: OverrideApiVersionDecorator; +}; diff --git a/packages/typespec-azure-core/generated-defs/Azure.Core.Legacy.ts-test.ts b/packages/typespec-azure-core/generated-defs/Azure.Core.Legacy.ts-test.ts new file mode 100644 index 0000000000..57b84c244a --- /dev/null +++ b/packages/typespec-azure-core/generated-defs/Azure.Core.Legacy.ts-test.ts @@ -0,0 +1,10 @@ +// An error in the imports would mean that the decorator is not exported or +// doesn't have the right name. + +import { $decorators } from "@azure-tools/typespec-azure-core"; +import type { AzureCoreLegacyDecorators } from "./Azure.Core.Legacy.js"; + +/** + * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... + */ +const _decs: AzureCoreLegacyDecorators = $decorators["Azure.Core.Legacy"]; diff --git a/packages/typespec-azure-core/lib/legacy.tsp b/packages/typespec-azure-core/lib/legacy.tsp index 7c7f46bb79..fd2ec36f63 100644 --- a/packages/typespec-azure-core/lib/legacy.tsp +++ b/packages/typespec-azure-core/lib/legacy.tsp @@ -2,6 +2,34 @@ using TypeSpec.Reflection; namespace Azure.Core.Legacy; +/** + * Overrides the API-version wire value used for operations within a namespace or interface. + * + * The value is opaque and does not need to be declared by the service version enum. The override + * is inherited by enclosed namespaces, interfaces, and operations, with the nearest override taking + * precedence. + * + * This decorator is considered legacy functionality and should only be used to preserve + * compatibility with an existing SDK. + * + * @param target The namespace or interface whose operations use the API-version override. + * @param version The non-empty API-version wire value. + * @param scope The language emitters to which the override applies. + * + * @example Override an interface API version + * ```typespec + * @Azure.Core.Legacy.overrideApiVersion("2021-11-01") + * interface Widgets { + * get(): void; + * } + * ``` + */ +extern dec overrideApiVersion( + target: Namespace | Interface, + version: valueof string, + scope?: valueof string +); + /** * A scalar type representing a next link that requires formatting with parameters to be used. * diff --git a/packages/typespec-azure-core/src/decorators/override-api-version.test.ts b/packages/typespec-azure-core/src/decorators/override-api-version.test.ts new file mode 100644 index 0000000000..08a5615e2d --- /dev/null +++ b/packages/typespec-azure-core/src/decorators/override-api-version.test.ts @@ -0,0 +1,156 @@ +import { getServiceForVersion, Tester } from "#test/test-host.js"; +import { expectDiagnostics, t } from "@typespec/compiler/testing"; +import { expect, it } from "vitest"; +import { getApiVersionOverride, getEffectiveApiVersionOverride } from "./override-api-version.js"; + +const decorator = "Azure.Core.Legacy.overrideApiVersion"; + +it("returns values applied directly to namespaces and interfaces", async () => { + const { program, Service, Widgets } = await Tester.compile(t.code` + @${decorator}("namespace-version") + namespace ${t.namespace("Service")} { + @${decorator}("interface-version") + interface ${t.interface("Widgets")} { + op get(): void; + } + } + `); + + expect(getApiVersionOverride(program, Service)).toBe("namespace-version"); + expect(getApiVersionOverride(program, Widgets)).toBe("interface-version"); +}); + +it("inherits the nearest override through operations, interfaces, and namespaces", async () => { + const { program, Service, Administration, Leaf, Widgets, Reports } = await Tester.compile(t.code` + @${decorator}("service-version") + namespace ${t.namespace("Service")} { + @${decorator}("administration-version") + namespace ${t.namespace("Administration")} { + op list(): void; + + namespace ${t.namespace("Leaf")} {} + + interface ${t.interface("Widgets")} { + op get(): void; + } + + @${decorator}("reports-version") + interface ${t.interface("Reports")} { + op get(): void; + } + } + } + `); + + expect(getEffectiveApiVersionOverride(program, Service)).toBe("service-version"); + expect(getEffectiveApiVersionOverride(program, Administration)).toBe("administration-version"); + expect(getEffectiveApiVersionOverride(program, Leaf)).toBe("administration-version"); + expect(getEffectiveApiVersionOverride(program, Administration.operations.get("list")!)).toBe( + "administration-version", + ); + expect(getEffectiveApiVersionOverride(program, Widgets)).toBe("administration-version"); + expect(getEffectiveApiVersionOverride(program, Widgets.operations.get("get")!)).toBe( + "administration-version", + ); + expect(getEffectiveApiVersionOverride(program, Reports)).toBe("reports-version"); + expect(getEffectiveApiVersionOverride(program, Reports.operations.get("get")!)).toBe( + "reports-version", + ); +}); + +it("preserves overrides on projected interfaces", async () => { + const { program } = await Tester.compile(` + @service + @versioned(Versions) + namespace Service { + enum Versions { + v1, + v2, + } + + @${decorator}("projected-version") + @added(Versions.v2) + interface Widgets { + op get(): void; + } + } + `); + + const projectedService = getServiceForVersion(program, "v2"); + const widgets = projectedService.interfaces.get("Widgets"); + + expect(widgets).toBeDefined(); + expect(getApiVersionOverride(program, widgets!)).toBe("projected-version"); + expect(getEffectiveApiVersionOverride(program, widgets!.operations.get("get")!)).toBe( + "projected-version", + ); +}); + +it("selects overrides by emitter scope", async () => { + const { program, Service, Widgets, ExcludingPython } = await Tester.compile(t.code` + @${decorator}("default-version") + @${decorator}("python-version", "python") + namespace ${t.namespace("Service")} { + @${decorator}("javascript-version", "javascript") + interface ${t.interface("Widgets")} { + op get(): void; + } + } + + @${decorator}("non-python-version", "!python") + namespace ${t.namespace("ExcludingPython")} {} + `); + + expect(getApiVersionOverride(program, Service)).toBe("default-version"); + expect(getApiVersionOverride(program, Service, "python")).toBe("python-version"); + expect(getApiVersionOverride(program, Service, "@azure-tools/typespec-python")).toBe( + "python-version", + ); + expect(getApiVersionOverride(program, Service, "csharp")).toBe("default-version"); + expect(getEffectiveApiVersionOverride(program, Widgets, "python")).toBe("python-version"); + expect(getEffectiveApiVersionOverride(program, Widgets, "javascript")).toBe("javascript-version"); + expect( + getEffectiveApiVersionOverride( + program, + Widgets.operations.get("get")!, + "@azure-tools/typespec-typescript", + ), + ).toBe("javascript-version"); + expect(getEffectiveApiVersionOverride(program, Widgets, "csharp")).toBe("default-version"); + expect(getApiVersionOverride(program, ExcludingPython, "python")).toBeUndefined(); + expect(getApiVersionOverride(program, ExcludingPython, "csharp")).toBe("non-python-version"); +}); + +it("returns undefined when no override applies", async () => { + const { program, Service, Widgets } = await Tester.compile(t.code` + namespace ${t.namespace("Service")} { + op list(): void; + + interface ${t.interface("Widgets")} { + op get(): void; + } + } + `); + + expect(getApiVersionOverride(program, Service)).toBeUndefined(); + expect(getApiVersionOverride(program, Widgets)).toBeUndefined(); + expect(getEffectiveApiVersionOverride(program, Service)).toBeUndefined(); + expect(getEffectiveApiVersionOverride(program, Service.operations.get("list")!)).toBeUndefined(); + expect(getEffectiveApiVersionOverride(program, Widgets)).toBeUndefined(); + expect(getEffectiveApiVersionOverride(program, Widgets.operations.get("get")!)).toBeUndefined(); +}); + +it.each(["", " ", " \t\r\n "])( + "rejects an empty or whitespace-only API version %j", + async (version) => { + const diagnostics = await Tester.diagnose(` + @${decorator}(${JSON.stringify(version)}) + namespace Service {} + `); + + expectDiagnostics(diagnostics, { + code: "@azure-tools/typespec-azure-core/invalid-api-version-override", + message: "The API version override must be a non-empty string.", + }); + }, +); diff --git a/packages/typespec-azure-core/src/decorators/override-api-version.ts b/packages/typespec-azure-core/src/decorators/override-api-version.ts new file mode 100644 index 0000000000..8f49043cd5 --- /dev/null +++ b/packages/typespec-azure-core/src/decorators/override-api-version.ts @@ -0,0 +1,183 @@ +import type { + DecoratorContext, + Interface, + Namespace, + Operation, + Program, +} from "@typespec/compiler"; +import { useStateMap } from "@typespec/compiler/utils"; +import type { OverrideApiVersionDecorator } from "../../generated-defs/Azure.Core.Legacy.js"; +import { AzureCoreStateKeys, reportDiagnostic } from "../lib.js"; + +const allScopes = Symbol.for("@azure-tools/typespec-azure-core/all-scopes"); +const negationScopesKey = Symbol.for("@azure-tools/typespec-azure-core/negation-scopes"); + +type ScopedApiVersionOverride = Record; + +const [getApiVersionOverrideState, setApiVersionOverrideState] = useStateMap< + Namespace | Interface, + ScopedApiVersionOverride +>(AzureCoreStateKeys.apiVersionOverride); + +export const $overrideApiVersion: OverrideApiVersionDecorator = ( + context: DecoratorContext, + target: Namespace | Interface, + version: string, + scope?: string, +) => { + if (version.trim().length === 0) { + reportDiagnostic(context.program, { + code: "invalid-api-version-override", + target: context.decoratorTarget, + }); + return; + } + + setScopedApiVersionOverride(context.program, target, version, scope); +}; + +/** + * Returns the API-version override configured directly on a namespace or interface. + * + * @param program The TypeSpec program. + * @param target The directly decorated namespace or interface. + * @param emitterName The emitter language scope, such as `python`. + */ +export function getApiVersionOverride( + program: Program, + target: Namespace | Interface, + emitterName?: string, +): string | undefined { + const values = getApiVersionOverrideState(program, target); + if (values === undefined) { + return undefined; + } + + if (emitterName !== undefined) { + const scope = normalizeEmitterName(emitterName); + const scopedValue = values[scope]; + if (typeof scopedValue === "string") { + return scopedValue; + } + + const negationScopes = values[negationScopesKey]; + if (Array.isArray(negationScopes) && negationScopes.includes(scope)) { + return undefined; + } + } + + const defaultValue = values[allScopes]; + return typeof defaultValue === "string" ? defaultValue : undefined; +} + +/** + * Returns the API-version override effective for a namespace, interface, or operation. + * + * @param program The TypeSpec program. + * @param target The namespace, interface, or operation to resolve. + * @param emitterName The emitter language scope, such as `python`. + */ +export function getEffectiveApiVersionOverride( + program: Program, + target: Namespace | Interface | Operation, + emitterName?: string, +): string | undefined { + let namespace: Namespace | undefined; + + switch (target.kind) { + case "Namespace": + namespace = target; + break; + case "Interface": { + const override = getApiVersionOverride(program, target, emitterName); + if (override !== undefined) { + return override; + } + namespace = target.namespace; + break; + } + case "Operation": { + if (target.interface) { + const override = getApiVersionOverride(program, target.interface, emitterName); + if (override !== undefined) { + return override; + } + } + namespace = target.interface?.namespace ?? target.namespace; + break; + } + } + + while (namespace) { + const override = getApiVersionOverride(program, namespace, emitterName); + if (override !== undefined) { + return override; + } + namespace = namespace.namespace; + } + + return undefined; +} + +function setScopedApiVersionOverride( + program: Program, + target: Namespace | Interface, + version: string, + scope?: string, +): void { + const current = getApiVersionOverrideState(program, target) ?? {}; + if (!scope) { + setApiVersionOverrideState(program, target, { ...current, [allScopes]: version }); + return; + } + + const [negationScopes, scopes] = parseScopes(scope); + if (negationScopes.length > 0) { + const values: ScopedApiVersionOverride = { + [allScopes]: version, + [negationScopesKey]: negationScopes, + }; + for (const language of scopes) { + values[language] = version; + } + for (const language of negationScopes) { + if (typeof current[language] === "string") { + values[language] = current[language]; + } + } + setApiVersionOverrideState(program, target, values); + return; + } + + const values = { ...current }; + for (const language of scopes) { + values[language] = version; + } + setApiVersionOverrideState(program, target, values); +} + +function parseScopes(scope: string): [negationScopes: string[], scopes: string[]] { + const groupedNegation = scope.match(/!\((.*?)\)/); + if (groupedNegation) { + return [groupedNegation[1].split(",").map((x) => x.trim()), []]; + } + + const negationScopes: string[] = []; + const scopes: string[] = []; + for (const value of scope.split(",").map((x) => x.trim())) { + if (value.startsWith("!")) { + negationScopes.push(value.slice(1)); + } else { + scopes.push(value); + } + } + return [negationScopes, scopes]; +} + +function normalizeEmitterName(emitterName: string): string { + const match = emitterName.match(/(?:cadl|typespec|client|server)-([^\\/-]*)/); + if (!match || match.length < 2) { + return emitterName; + } + return ["typescript", "ts"].includes(match[1]) ? "javascript" : match[1]; +} diff --git a/packages/typespec-azure-core/src/diagnostics/invalid-api-version-override.md b/packages/typespec-azure-core/src/diagnostics/invalid-api-version-override.md new file mode 100644 index 0000000000..464e5df943 --- /dev/null +++ b/packages/typespec-azure-core/src/diagnostics/invalid-api-version-override.md @@ -0,0 +1,22 @@ +The value passed to `@Azure.Core.Legacy.overrideApiVersion` must contain at least one +non-whitespace character. + +## Incorrect usage + +```typespec +@Azure.Core.Legacy.overrideApiVersion("") +namespace Service { + +} +``` + +## How to fix + +Provide the opaque API-version wire value expected by the generated client. + +```typespec +@Azure.Core.Legacy.overrideApiVersion("2021-11-01") +namespace Service { + +} +``` diff --git a/packages/typespec-azure-core/src/index.ts b/packages/typespec-azure-core/src/index.ts index f256791393..a853ee3d02 100644 --- a/packages/typespec-azure-core/src/index.ts +++ b/packages/typespec-azure-core/src/index.ts @@ -10,6 +10,10 @@ export { type OperationLink, type OperationLinkMetadata, } from "./decorators/operation-link.js"; +export { + getApiVersionOverride, + getEffectiveApiVersionOverride, +} from "./decorators/override-api-version.js"; export { isPreviewVersion } from "./decorators/preview-version.js"; export { getArmResourceIdentifierConfig, diff --git a/packages/typespec-azure-core/src/lib.ts b/packages/typespec-azure-core/src/lib.ts index eb774ab50f..d0d8cdc197 100644 --- a/packages/typespec-azure-core/src/lib.ts +++ b/packages/typespec-azure-core/src/lib.ts @@ -1,4 +1,4 @@ -import { createTypeSpecLibrary, paramMessage } from "@typespec/compiler"; +import { createTypeSpecLibrary, fileRef, paramMessage } from "@typespec/compiler"; export const $lib = createTypeSpecLibrary({ name: "@azure-tools/typespec-azure-core", @@ -263,6 +263,13 @@ export const $lib = createTypeSpecLibrary({ default: `@uniqueItems can only be applied to arrays and array-valued model properties.`, }, }, + "invalid-api-version-override": { + docs: fileRef.fromPackageRoot("src/diagnostics/invalid-api-version-override.md"), + severity: "error", + messages: { + default: "The API version override must be a non-empty string.", + }, + }, "experimental-feature": { severity: "warning", messages: { @@ -305,6 +312,9 @@ export const $lib = createTypeSpecLibrary({ previewVersion: { description: "Data for `@previewVersion` decorator", }, + apiVersionOverride: { + description: "Data for `@overrideApiVersion` decorator", + }, }, // AzureCoreStateKeys.traitLocation }); diff --git a/packages/typespec-azure-core/src/tsp-index.ts b/packages/typespec-azure-core/src/tsp-index.ts index 6e875a37d8..e5118e70dd 100644 --- a/packages/typespec-azure-core/src/tsp-index.ts +++ b/packages/typespec-azure-core/src/tsp-index.ts @@ -1,6 +1,7 @@ import type { AzureCoreFoundationsDecorators } from "../generated-defs/Azure.Core.Foundations.js"; import type { AzureCoreFoundationsPrivateDecorators } from "../generated-defs/Azure.Core.Foundations.Private.js"; import type { AzureCoreDecorators } from "../generated-defs/Azure.Core.js"; +import type { AzureCoreLegacyDecorators } from "../generated-defs/Azure.Core.Legacy.js"; import type { AzureCoreTraitsDecorators } from "../generated-defs/Azure.Core.Traits.js"; import type { AzureCoreTraitsPrivateDecorators } from "../generated-defs/Azure.Core.Traits.Private.js"; import { $requestParameter, $responseProperty } from "./decorators.js"; @@ -14,6 +15,7 @@ import { $lroResult } from "./decorators/lro-result.js"; import { $lroStatus } from "./decorators/lro-status.js"; import { $lroSucceeded } from "./decorators/lro-succeeded.js"; import { $operationLink } from "./decorators/operation-link.js"; +import { $overrideApiVersion } from "./decorators/override-api-version.js"; import { $pollingLocation } from "./decorators/polling-location.js"; import { $pollingOperationParameter } from "./decorators/polling-operation-parameter.js"; import { $pollingOperation } from "./decorators/polling-operation.js"; @@ -82,6 +84,10 @@ export const $decorators = { parameterizedNextLinkConfig: parameterizedNextLinkConfigDecorator, } satisfies AzureCoreFoundationsPrivateDecorators, + "Azure.Core.Legacy": { + overrideApiVersion: $overrideApiVersion, + } satisfies AzureCoreLegacyDecorators, + "Azure.Core.Traits": { trait: $trait, traitAdded: $traitAdded, diff --git a/packages/typespec-azure-resource-manager/generated-defs/Azure.ResourceManager.ts b/packages/typespec-azure-resource-manager/generated-defs/Azure.ResourceManager.ts index 6ab882108b..c7c375e031 100644 --- a/packages/typespec-azure-resource-manager/generated-defs/Azure.ResourceManager.ts +++ b/packages/typespec-azure-resource-manager/generated-defs/Azure.ResourceManager.ts @@ -24,6 +24,7 @@ export interface ArmFeatureFileOptions { readonly description: string; readonly title?: string; readonly termsOfService?: string; + readonly version?: string; } /** diff --git a/packages/typespec-azure-resource-manager/lib/decorators.tsp b/packages/typespec-azure-resource-manager/lib/decorators.tsp index 66ec31b001..a11c71fa2d 100644 --- a/packages/typespec-azure-resource-manager/lib/decorators.tsp +++ b/packages/typespec-azure-resource-manager/lib/decorators.tsp @@ -322,6 +322,9 @@ model ArmFeatureFileOptions { /** The feature terms of service in Swagger */ termsOfService?: string; + + /** The API version to use for clients generated from this feature file */ + version?: string; } /** diff --git a/packages/typespec-azure-resource-manager/src/index.ts b/packages/typespec-azure-resource-manager/src/index.ts index 65e7f97878..b42aae5425 100644 --- a/packages/typespec-azure-resource-manager/src/index.ts +++ b/packages/typespec-azure-resource-manager/src/index.ts @@ -1,5 +1,6 @@ export const namespace = "Azure.ResourceManager"; +export type { ArmFeatureFileOptions } from "../generated-defs/Azure.ResourceManager.js"; export type { ArmFeatureOptions } from "../generated-defs/Azure.ResourceManager.Legacy.js"; export { $armCommonTypesVersion, diff --git a/packages/typespec-azure-resource-manager/src/lib.ts b/packages/typespec-azure-resource-manager/src/lib.ts index eabe7bee86..f0276723a6 100644 --- a/packages/typespec-azure-resource-manager/src/lib.ts +++ b/packages/typespec-azure-resource-manager/src/lib.ts @@ -131,6 +131,12 @@ export const $lib = createTypeSpecLibrary({ default: paramMessage`The specified common-types version '${"version"}' is not valid for ${"resourceName"} resources. Please use version ${"requiredVersion"} or later of common-types.`, }, }, + "invalid-feature-file-version": { + severity: "error", + messages: { + default: "The version in @featureFileOptions must not be empty or contain only whitespace.", + }, + }, "basetypes-experimental": { severity: "warning", messages: { diff --git a/packages/typespec-azure-resource-manager/src/resource.ts b/packages/typespec-azure-resource-manager/src/resource.ts index c21414f7bf..ec01c0f191 100644 --- a/packages/typespec-azure-resource-manager/src/resource.ts +++ b/packages/typespec-azure-resource-manager/src/resource.ts @@ -29,6 +29,7 @@ import { $autoRoute, getParentResource, getSegment } from "@typespec/rest"; import { camelCase, pascalCase } from "change-case"; import type { + ArmFeatureFileOptions, ArmProviderNameValueDecorator, ArmResourceOperationsDecorator, ArmVirtualResourceDecorator, @@ -1521,7 +1522,7 @@ export const [getResourceFeature, setResourceFeature] = useStateMap< export const [getResourceFeatureSet, setResourceFeatureSet] = useStateMap< Namespace, - Map + Map >(ArmStateKeys.armFeatureSet); export const [getFeatureFileSet, setFeatureFileSet] = useStateMap( @@ -1530,17 +1531,17 @@ export const [getFeatureFileSet, setFeatureFileSet] = useStateMap(ArmStateKeys.armFeatureOptions); -const commonFeatureOptions: ArmFeatureOptions = { +const commonFeatureOptions: ArmFeatureFileOptions = { featureName: "Common", fileName: "common", description: "", }; -export function getFeatureOptions(program: Program, feature: EnumMember): ArmFeatureOptions { +export function getFeatureOptions(program: Program, feature: EnumMember): ArmFeatureFileOptions { const defaultFeatureName: string = (feature.value ?? feature.name) as string; - const defaultOptions: ArmFeatureOptions = { + const defaultOptions: ArmFeatureFileOptions = { featureName: defaultFeatureName, fileName: camelCase(defaultFeatureName), description: "", @@ -1552,9 +1553,9 @@ export function getFeatureOptions(program: Program, feature: EnumMember): ArmFea * Get the FeatureOptions for a given type, these could be inherited from the namespace or parent type * @param program - The program to process. * @param entity - The type entity to get feature options for. - * @returns The ArmFeatureOptions if found, otherwise undefined. + * @returns The feature file options if found, otherwise the common feature options. */ -export function getFeature(program: Program, entity: Type): ArmFeatureOptions { +export function getFeature(program: Program, entity: Type): ArmFeatureFileOptions { switch (entity.kind) { case "Namespace": { const feature = getResourceFeature(program, entity); @@ -1641,14 +1642,14 @@ export const $features: FeaturesDecorator = ( features: Enum, ) => { const { program } = context; - let featureMap: Map | undefined = getResourceFeatureSet( + let featureMap: Map | undefined = getResourceFeatureSet( program, entity, ); if (featureMap !== undefined) { return; } - featureMap = new Map(); + featureMap = new Map(); for (const member of features.members.values()) { const options = getFeatureOptions(program, member); // Ensure defaults are created @@ -1670,7 +1671,13 @@ export const $featureOptions: FeatureOptionsDecorator = ( }; // New Azure.ResourceManager namespace decorators -export const $featureFile: FeatureFileDecorator = $feature as unknown as FeatureFileDecorator; +export const $featureFile: FeatureFileDecorator = ( + context: DecoratorContext, + entity: Model | Operation | Interface | Namespace, + featureName: EnumMember, +) => { + setResourceFeature(context.program, entity, featureName); +}; export const $featureFiles: FeatureFilesDecorator = ( context: DecoratorContext, entity: Namespace, @@ -1679,5 +1686,17 @@ export const $featureFiles: FeatureFilesDecorator = ( setFeatureFileSet(context.program, entity, true); $features(context, entity, features); }; -export const $featureFileOptions: FeatureFileOptionsDecorator = - $featureOptions as unknown as FeatureFileOptionsDecorator; +export const $featureFileOptions: FeatureFileOptionsDecorator = ( + context: DecoratorContext, + entity: EnumMember, + options: ArmFeatureFileOptions, +) => { + if (options.version !== undefined && options.version.trim().length === 0) { + reportDiagnostic(context.program, { + code: "invalid-feature-file-version", + target: entity, + }); + return; + } + setResourceFeatureOptions(context.program, entity, options); +}; diff --git a/packages/typespec-azure-resource-manager/test/feature-file-options.test.ts b/packages/typespec-azure-resource-manager/test/feature-file-options.test.ts new file mode 100644 index 0000000000..ace1ba8cce --- /dev/null +++ b/packages/typespec-azure-resource-manager/test/feature-file-options.test.ts @@ -0,0 +1,98 @@ +import { expectDiagnosticEmpty, expectDiagnostics, t } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { getFeatureOptions, getResourceFeatureSet } from "../src/resource.js"; +import { Tester } from "./tester.js"; + +describe("@featureFileOptions version", () => { + it.each(["", " "])("rejects an empty or whitespace-only version %j", async (version) => { + const diagnostics = await Tester.diagnose(` + @Azure.ResourceManager.featureFiles(Features) + @armProviderNamespace("Microsoft.Test") + namespace Microsoft.Test; + + enum Features { + @Azure.ResourceManager.featureFileOptions(#{ + featureName: "FeatureA", + fileName: "feature-a", + description: "Feature A", + version: "${version}" + }) + FeatureA, + } + `); + + expectDiagnostics(diagnostics, { + code: "@azure-tools/typespec-azure-resource-manager/invalid-feature-file-version", + severity: "error", + message: "The version in @featureFileOptions must not be empty or contain only whitespace.", + }); + }); + + it("preserves the configured version in feature state", async () => { + const [result, diagnostics] = await Tester.compileAndDiagnose(t.code` + @Azure.ResourceManager.featureFiles(Features) + @armProviderNamespace("Microsoft.Test") + namespace ${t.namespace("MSTest")}; + + enum ${t.enum("Features")} { + @Azure.ResourceManager.featureFileOptions(#{ + featureName: "FeatureA", + fileName: "feature-a", + description: "Feature A", + version: " 2025-01-01 " + }) + FeatureA, + } + `); + + expectDiagnosticEmpty(diagnostics); + const feature = result.Features.members.get("FeatureA")!; + expect(getFeatureOptions(result.program, feature).version).toBe(" 2025-01-01 "); + expect(getResourceFeatureSet(result.program, result.MSTest)?.get("FeatureA")?.version).toBe( + " 2025-01-01 ", + ); + }); + + it("uses the last-applied feature file options", async () => { + const { program, Features } = await Tester.compile(t.code` + enum ${t.enum("Features")} { + @Azure.ResourceManager.featureFileOptions(#{ + featureName: "FeatureA", + fileName: "feature-a", + description: "Second", + version: "2025-01-01" + }) + @Azure.ResourceManager.featureFileOptions(#{ + featureName: "FeatureA", + fileName: "feature-a", + description: "First", + version: "2024-01-01" + }) + FeatureA, + } + `); + + expect(getFeatureOptions(program, Features.members.get("FeatureA")!)).toMatchObject({ + description: "Second", + version: "2025-01-01", + }); + }); + + it("does not add version to the deprecated ArmFeatureOptions model", async () => { + const diagnostics = await Tester.diagnose(` + enum Features { + @Azure.ResourceManager.Legacy.featureOptions(#{ + featureName: "FeatureA", + fileName: "feature-a", + description: "Feature A", + version: "2025-01-01" + }) + FeatureA, + } + `); + + expectDiagnostics(diagnostics, { + code: "invalid-argument", + }); + }); +}); diff --git a/packages/typespec-client-generator-core/src/clients.ts b/packages/typespec-client-generator-core/src/clients.ts index 8c282a682d..e8f5f277c5 100644 --- a/packages/typespec-client-generator-core/src/clients.ts +++ b/packages/typespec-client-generator-core/src/clients.ts @@ -1,3 +1,4 @@ +import { getEffectiveApiVersionOverride } from "@azure-tools/typespec-azure-core"; import { createDiagnosticCollector, type Diagnostic, getDoc, getSummary } from "@typespec/compiler"; import { $ } from "@typespec/compiler/typekit"; import { getServers, type HttpServer } from "@typespec/http"; @@ -203,6 +204,11 @@ export function createSdkClientType = { __raw: client, kind: "client", @@ -213,6 +219,7 @@ export function createSdkClientType[], + name: string, +): SdkClientType | undefined { + for (const client of clients) { + if (client.name === name) { + return client; + } + const child = findClient(client.children ?? [], name); + if (child) { + return child; + } + } + return undefined; +} + +function requireClient( + clients: SdkClientType[], + name: string, +): SdkClientType { + const client = findClient(clients, name); + ok(client, `Expected client ${name}`); + return client; +} + +function getApiVersionDefault(client: SdkClientType): unknown { + return client.clientInitialization.parameters.find((x) => x.isApiVersionParam) + ?.clientDefaultValue; +} + +describe("@overrideApiVersion", () => { + it("uses a direct interface override without changing version metadata", async () => { + const { program } = await AzureCoreTester.compile(` + @service + @versioned(Versions) + namespace WidgetService { + enum Versions { + v1: "2024-01-01", + v2: "2025-01-01", + } + + @route("/root") + op root(@query("api-version") apiVersion: string): void; + + @${decorator}("2099-01-01") + interface Widgets { + @route("/widgets") + op get(@query("api-version") apiVersion: string): void; + } + + interface Gadgets { + @route("/gadgets") + op get(@query("api-version") apiVersion: string): void; + } + } + `); + + const context = await createSdkContextForTester(program); + const { clients, enums } = context.sdkPackage; + const root = requireClient(clients, "WidgetServiceClient"); + const widgets = requireClient(clients, "Widgets"); + const gadgets = requireClient(clients, "Gadgets"); + + strictEqual(getApiVersionDefault(widgets), "2099-01-01"); + strictEqual(getApiVersionDefault(root), "2025-01-01"); + strictEqual(getApiVersionDefault(gadgets), "2025-01-01"); + strictEqual(widgets.apiVersionDefaultValue, "2099-01-01"); + strictEqual(root.apiVersionDefaultValue, undefined); + deepStrictEqual(widgets.apiVersions, ["2024-01-01", "2025-01-01"]); + deepStrictEqual(root.apiVersions, ["2024-01-01", "2025-01-01"]); + + ok(widgets.versionsEnum); + deepStrictEqual( + widgets.versionsEnum.values.map((x) => x.value), + ["2024-01-01", "2025-01-01"], + ); + strictEqual( + enums.some((x) => x.values.some((value) => value.value === "2099-01-01")), + false, + ); + }); + + it("inherits an override from the enclosing service namespace", async () => { + const { program } = await AzureCoreTester.compile(` + @service + @versioned(Versions) + @${decorator}("2099-01-01") + namespace WidgetService { + enum Versions { + v1: "2024-01-01", + v2: "2025-01-01", + } + + @route("/root") + op root(@query("api-version") apiVersion: string): void; + + interface Widgets { + @route("/widgets") + op get(@query("api-version") apiVersion: string): void; + } + } + `); + + const context = await createSdkContextForTester(program); + const root = requireClient(context.sdkPackage.clients, "WidgetServiceClient"); + const widgets = requireClient(context.sdkPackage.clients, "Widgets"); + + strictEqual(getApiVersionDefault(root), "2099-01-01"); + strictEqual(getApiVersionDefault(widgets), "2099-01-01"); + }); + + it("uses the nearest nested namespace override", async () => { + const { program } = await AzureCoreTester.compile(` + @service + @versioned(Versions) + namespace WidgetService { + enum Versions { + v1: "2024-01-01", + v2: "2025-01-01", + } + + @${decorator}("2088-01-01") + namespace Administration { + @${decorator}("2099-01-01") + namespace Widgets { + op get(@query("api-version") apiVersion: string): void; + } + } + } + `); + + const context = await createSdkContextForTester(program); + const administration = requireClient(context.sdkPackage.clients, "Administration"); + const widgets = requireClient(context.sdkPackage.clients, "Widgets"); + + strictEqual(getApiVersionDefault(administration), "2088-01-01"); + strictEqual(getApiVersionDefault(widgets), "2099-01-01"); + }); + + it("gives an interface override precedence over its namespace", async () => { + const { program } = await AzureCoreTester.compile(` + @service + @versioned(Versions) + namespace WidgetService { + enum Versions { + v1: "2024-01-01", + v2: "2025-01-01", + } + + @${decorator}("2088-01-01") + namespace Administration { + @${decorator}("2099-01-01") + interface Widgets { + op get(@query("api-version") apiVersion: string): void; + } + } + } + `); + + const context = await createSdkContextForTester(program); + const administration = requireClient(context.sdkPackage.clients, "Administration"); + const widgets = requireClient(context.sdkPackage.clients, "Widgets"); + + strictEqual(getApiVersionDefault(administration), "2088-01-01"); + strictEqual(getApiVersionDefault(widgets), "2099-01-01"); + }); + + it("applies to an explicit root interface client", async () => { + const { program } = await AzureCoreTester.compile(` + @service + @versioned(Versions) + namespace WidgetService { + enum Versions { + v1: "2024-01-01", + v2: "2025-01-01", + } + + @client({ name: "WidgetsClient", service: WidgetService }) + @${decorator}("2099-01-01") + interface WidgetsClient { + op get(@query("api-version") apiVersion: string): void; + } + } + `); + + const context = await createSdkContextForTester(program); + deepStrictEqual( + context.sdkPackage.clients.map((client) => client.name), + ["WidgetsClient"], + ); + const client = requireClient(context.sdkPackage.clients, "WidgetsClient"); + + strictEqual(getApiVersionDefault(client), "2099-01-01"); + strictEqual(client.apiVersionDefaultValue, "2099-01-01"); + }); + + it("selects the override by emitter scope", async () => { + const source = ` + @service + @versioned(Versions) + namespace WidgetService { + enum Versions { + v1: "2024-01-01", + v2: "2025-01-01", + } + + @${decorator}("2099-01-01", "python") + interface Widgets { + op get(@query("api-version") apiVersion: string): void; + } + } + `; + + const { program: pythonProgram } = await AzureCoreTester.compile(source); + const pythonContext = await createSdkContextForTester(pythonProgram, { + emitterName: "@azure-tools/typespec-python", + }); + const pythonClient = requireClient(pythonContext.sdkPackage.clients, "Widgets"); + strictEqual(getApiVersionDefault(pythonClient), "2099-01-01"); + + const { program: csharpProgram } = await AzureCoreTester.compile(source); + const csharpContext = await createSdkContextForTester(csharpProgram, { + emitterName: "@azure-tools/typespec-csharp", + }); + const csharpClient = requireClient(csharpContext.sdkPackage.clients, "Widgets"); + strictEqual(getApiVersionDefault(csharpClient), "2025-01-01"); + }); + + it("does not synthesize an API-version parameter when the client has none", async () => { + const { program } = await AzureCoreTester.compile(` + @service + @versioned(Versions) + namespace WidgetService { + enum Versions { + v1: "2024-01-01", + v2: "2025-01-01", + } + + @${decorator}("2099-01-01") + interface Widgets { + op get(): void; + } + } + `); + + const context = await createSdkContextForTester(program); + const client = requireClient(context.sdkPackage.clients, "Widgets"); + + strictEqual( + client.clientInitialization.parameters.some((x) => x.isApiVersionParam), + false, + ); + strictEqual(client.apiVersionDefaultValue, "2099-01-01"); + deepStrictEqual(client.apiVersions, ["2024-01-01", "2025-01-01"]); + ok(client.versionsEnum); + strictEqual( + client.versionsEnum.values.some((x) => x.value === "2099-01-01"), + false, + ); + }); + + it("survives projected replacement interfaces", async () => { + const { program } = await AzureCoreTester.compile(` + @service + @versioned(Versions) + namespace WidgetService { + enum Versions { + v1: "2024-01-01", + v2: "2025-01-01", + v3: "2026-01-01", + } + + @${decorator}("2099-01-01") + @added(Versions.v2) + @removed(Versions.v3) + @renamedFrom(Versions.v2, "LegacyWidgets") + interface Widgets { + op get(@query("api-version") apiVersion: string): void; + } + } + `); + const context = await createSdkContextForTester(program, { + "api-version": "2025-01-01", + }); + const client = requireClient(context.sdkPackage.clients, "Widgets"); + + strictEqual(getApiVersionDefault(client), "2099-01-01"); + deepStrictEqual(client.apiVersions, ["2025-01-01"]); + ok(client.versionsEnum); + deepStrictEqual( + client.versionsEnum.values.map((x) => x.value), + ["2024-01-01", "2025-01-01"], + ); + }); +}); diff --git a/packages/typespec-go/src/tcgcadapter/clients.ts b/packages/typespec-go/src/tcgcadapter/clients.ts index bc239aa0e2..bea96c5e41 100644 --- a/packages/typespec-go/src/tcgcadapter/clients.ts +++ b/packages/typespec-go/src/tcgcadapter/clients.ts @@ -36,10 +36,13 @@ export class ClientAdapter { // to avoid duplicates when the same parameter group is used in multiple methods private readonly parameterGroups: Map; + private readonly clientApiVersionOverrides: Map; + constructor(ta: TypeAdapter) { this.ta = ta; this.clientParams = new Map(); this.parameterGroups = new Map(); + this.clientApiVersionOverrides = new Map(); } /** @@ -125,6 +128,9 @@ export class ClientAdapter { const goClient = new go.Client(this.ta.getPkg(), clientName, docs); goClient.parent = parent; + if (sdkClient.apiVersionDefaultValue !== undefined) { + this.clientApiVersionOverrides.set(goClient, sdkClient.apiVersionDefaultValue); + } // NOTE: per tcgc convention, if there is no param of kind credential // it means that the client doesn't require any kind of authentication. @@ -1321,16 +1327,17 @@ export class ClientAdapter { // the ClientOptions.APIVersion setting is used to change the version. let paramType: go.Literal | go.String; let paramStyle: go.ParameterStyle; - if (opParam.clientDefaultValue) { - const client = method.receiver.type; + const client = method.receiver.type; + const clientDefaultValue = + this.clientApiVersionOverrides.get(client) ?? + (typeof opParam.clientDefaultValue === "string" ? opParam.clientDefaultValue : undefined); + if (clientDefaultValue) { // check if we already have a ConstantDef for this API version. - let versionConst = client.apiVersions.find( - (e) => e.literal.literal === opParam.clientDefaultValue, - ); + let versionConst = client.apiVersions.find((e) => e.literal.literal === clientDefaultValue); if (!versionConst) { - const literalValue = new go.Literal(this.ta.getStringType(), opParam.clientDefaultValue); + const literalValue = new go.Literal(this.ta.getStringType(), clientDefaultValue); versionConst = new go.ConstantDef( - `version${ensureNameCase(opParam.clientDefaultValue)}`, + `version${ensureNameCase(clientDefaultValue)}`, literalValue, ); client.apiVersions.push(versionConst); diff --git a/packages/typespec-go/test/unittest/scenario-suites/client-api-version-override.test.ts b/packages/typespec-go/test/unittest/scenario-suites/client-api-version-override.test.ts new file mode 100644 index 0000000000..796e670711 --- /dev/null +++ b/packages/typespec-go/test/unittest/scenario-suites/client-api-version-override.test.ts @@ -0,0 +1,7 @@ +// Generated by `pnpm gen:scenario-suites`. Do not edit by hand. +import { resolvePath } from "@typespec/compiler"; +import { describeScenarioFile } from "../scenario-runner.js"; + +describeScenarioFile( + resolvePath(import.meta.dirname, "../scenarios/client-api-version-override.md"), +); diff --git a/packages/typespec-go/test/unittest/scenarios/client-api-version-override.md b/packages/typespec-go/test/unittest/scenarios/client-api-version-override.md new file mode 100644 index 0000000000..44616f2f5c --- /dev/null +++ b/packages/typespec-go/test/unittest/scenarios/client-api-version-override.md @@ -0,0 +1,285 @@ +# An explicit interface child can use a distinct client API version + +## TypeSpec + +```tsp +@service(#{ title: "Versioned Service" }) +@versioned(Versions) +@client({ + name: "VersionedServiceClient", + service: VersionedService, +}) +namespace VersionedService { + enum Versions { + v1: "2024-01-01", + v2: "2025-01-01", + } + + @route("/parent") + op getParent(@query("api-version") @apiVersion apiVersion: Versions): void; + + @route("/legacy") + @client({ + name: "LegacyOperationsClient", + service: VersionedService, + }) + @Azure.Core.Legacy.overrideApiVersion("opaque-legacy-version") + interface LegacyOperations { + getLegacy(@query("api-version") @apiVersion apiVersion: Versions): void; + } + + @route("/current") + @client({ + name: "CurrentOperationsClient", + service: VersionedService, + }) + interface CurrentOperations { + getCurrent(@query("api-version") @apiVersion apiVersion: Versions): void; + } +} +``` + +## Generated client + +```go client +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// Code generated by Microsoft (R) Go Code Generator. DO NOT EDIT. + +package testmodule + +import ( + "context" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "net/http" + "strings" +) + +// VersionedServiceClient contains the methods for the VersionedService group. +// Don't use this type directly, use NewVersionedServiceClientWithNoCredential() instead. +// +// Generated from API version 2025-01-01 +type VersionedServiceClient struct { + internal *azcore.Client + endpoint string +} + +// VersionedServiceClientOptions contains the optional values for creating a [VersionedServiceClient]. +type VersionedServiceClientOptions struct { + azcore.ClientOptions +} + +// NewVersionedServiceClientWithNoCredential creates a new instance of VersionedServiceClient with the specified values. +// - endpoint - Service host +// - options - Contains optional client configuration. Pass nil to accept the default values. +func NewVersionedServiceClientWithNoCredential(endpoint string, options *VersionedServiceClientOptions) (*VersionedServiceClient, error) { + if options == nil { + options = &VersionedServiceClientOptions{} + } + cl, err := azcore.NewClient(moduleName, moduleVersion, runtime.PipelineOptions{ + APIVersion: runtime.APIVersionOptions{ + Name: "api-version", + Location: runtime.APIVersionLocationQueryParam, + }, + }, &options.ClientOptions) + if err != nil { + return nil, err + } + client := &VersionedServiceClient{ + endpoint: endpoint, + internal: cl, + } + return client, nil +} + +// NewVersionedServiceCurrentOperationsClient creates a new instance of [VersionedServiceCurrentOperationsClient]. +func (client *VersionedServiceClient) NewVersionedServiceCurrentOperationsClient() *VersionedServiceCurrentOperationsClient { + return &VersionedServiceCurrentOperationsClient{ + endpoint: client.endpoint, + internal: client.internal, + } +} + +// NewVersionedServiceLegacyOperationsClient creates a new instance of [VersionedServiceLegacyOperationsClient]. +func (client *VersionedServiceClient) NewVersionedServiceLegacyOperationsClient() *VersionedServiceLegacyOperationsClient { + return &VersionedServiceLegacyOperationsClient{ + endpoint: client.endpoint, + internal: client.internal, + } +} + +// GetParent - +// If the operation fails it returns an *azcore.ResponseError type. +// - options - VersionedServiceClientGetParentOptions contains the optional parameters for the VersionedServiceClient.GetParent +// method. +func (client *VersionedServiceClient) GetParent(ctx context.Context, options *VersionedServiceClientGetParentOptions) (VersionedServiceClientGetParentResponse, error) { + var err error + req, err := client.getParentCreateRequest(ctx, options) + if err != nil { + return VersionedServiceClientGetParentResponse{}, err + } + httpResp, err := client.internal.Pipeline().Do(req) + if err != nil { + return VersionedServiceClientGetParentResponse{}, err + } + if !runtime.HasStatusCode(httpResp, http.StatusNoContent) { + err = runtime.NewResponseError(httpResp) + return VersionedServiceClientGetParentResponse{}, err + } + return VersionedServiceClientGetParentResponse{}, nil +} + +// getParentCreateRequest creates the GetParent request. +func (client *VersionedServiceClient) getParentCreateRequest(ctx context.Context, _ *VersionedServiceClientGetParentOptions) (*policy.Request, error) { + urlPath := "/parent" + req, err := runtime.NewRequest(ctx, http.MethodGet, runtime.JoinPaths(client.endpoint, urlPath)) + if err != nil { + return nil, err + } + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", version20250101) + req.Raw().URL.RawQuery = strings.ReplaceAll(reqQP.Encode(), "+", "%20") + return req, nil +} +``` + +## Generated legacy child + +```go versionedservicelegacyoperations_client +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// Code generated by Microsoft (R) Go Code Generator. DO NOT EDIT. + +package testmodule + +import ( + "context" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "net/http" + "strings" +) + +// VersionedServiceLegacyOperationsClient contains the methods for the VersionedServiceLegacyOperations group. +// Don't use this type directly, use [VersionedServiceClient.NewVersionedServiceLegacyOperationsClient] instead. +// +// Generated from API version opaque-legacy-version +type VersionedServiceLegacyOperationsClient struct { + internal *azcore.Client + endpoint string +} + +// GetLegacy - +// If the operation fails it returns an *azcore.ResponseError type. +// - options - VersionedServiceLegacyOperationsClientGetLegacyOptions contains the optional parameters for the VersionedServiceLegacyOperationsClient.GetLegacy +// method. +func (client *VersionedServiceLegacyOperationsClient) GetLegacy(ctx context.Context, options *VersionedServiceLegacyOperationsClientGetLegacyOptions) (VersionedServiceLegacyOperationsClientGetLegacyResponse, error) { + var err error + req, err := client.getLegacyCreateRequest(ctx, options) + if err != nil { + return VersionedServiceLegacyOperationsClientGetLegacyResponse{}, err + } + httpResp, err := client.internal.Pipeline().Do(req) + if err != nil { + return VersionedServiceLegacyOperationsClientGetLegacyResponse{}, err + } + if !runtime.HasStatusCode(httpResp, http.StatusNoContent) { + err = runtime.NewResponseError(httpResp) + return VersionedServiceLegacyOperationsClientGetLegacyResponse{}, err + } + return VersionedServiceLegacyOperationsClientGetLegacyResponse{}, nil +} + +// getLegacyCreateRequest creates the GetLegacy request. +func (client *VersionedServiceLegacyOperationsClient) getLegacyCreateRequest(ctx context.Context, _ *VersionedServiceLegacyOperationsClientGetLegacyOptions) (*policy.Request, error) { + urlPath := "/legacy" + req, err := runtime.NewRequest(ctx, http.MethodGet, runtime.JoinPaths(client.endpoint, urlPath)) + if err != nil { + return nil, err + } + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", versionOpaqueLegacyVersion) + req.Raw().URL.RawQuery = strings.ReplaceAll(reqQP.Encode(), "+", "%20") + return req, nil +} +``` + +## Generated current child + +```go versionedservicecurrentoperations_client +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// Code generated by Microsoft (R) Go Code Generator. DO NOT EDIT. + +package testmodule + +import ( + "context" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "net/http" + "strings" +) + +// VersionedServiceCurrentOperationsClient contains the methods for the VersionedServiceCurrentOperations group. +// Don't use this type directly, use [VersionedServiceClient.NewVersionedServiceCurrentOperationsClient] instead. +// +// Generated from API version 2025-01-01 +type VersionedServiceCurrentOperationsClient struct { + internal *azcore.Client + endpoint string +} + +// GetCurrent - +// If the operation fails it returns an *azcore.ResponseError type. +// - options - VersionedServiceCurrentOperationsClientGetCurrentOptions contains the optional parameters for the VersionedServiceCurrentOperationsClient.GetCurrent +// method. +func (client *VersionedServiceCurrentOperationsClient) GetCurrent(ctx context.Context, options *VersionedServiceCurrentOperationsClientGetCurrentOptions) (VersionedServiceCurrentOperationsClientGetCurrentResponse, error) { + var err error + req, err := client.getCurrentCreateRequest(ctx, options) + if err != nil { + return VersionedServiceCurrentOperationsClientGetCurrentResponse{}, err + } + httpResp, err := client.internal.Pipeline().Do(req) + if err != nil { + return VersionedServiceCurrentOperationsClientGetCurrentResponse{}, err + } + if !runtime.HasStatusCode(httpResp, http.StatusNoContent) { + err = runtime.NewResponseError(httpResp) + return VersionedServiceCurrentOperationsClientGetCurrentResponse{}, err + } + return VersionedServiceCurrentOperationsClientGetCurrentResponse{}, nil +} + +// getCurrentCreateRequest creates the GetCurrent request. +func (client *VersionedServiceCurrentOperationsClient) getCurrentCreateRequest(ctx context.Context, _ *VersionedServiceCurrentOperationsClientGetCurrentOptions) (*policy.Request, error) { + urlPath := "/current" + req, err := runtime.NewRequest(ctx, http.MethodGet, runtime.JoinPaths(client.endpoint, urlPath)) + if err != nil { + return nil, err + } + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", version20250101) + req.Raw().URL.RawQuery = strings.ReplaceAll(reqQP.Encode(), "+", "%20") + return req, nil +} +``` + +## Generated constants + +```go constants +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// Code generated by Microsoft (R) Go Code Generator. DO NOT EDIT. + +package testmodule + +const ( + version20250101 string = "2025-01-01" + versionOpaqueLegacyVersion string = "opaque-legacy-version" +) +``` diff --git a/packages/typespec-ts/src/modular/build-client-context.ts b/packages/typespec-ts/src/modular/build-client-context.ts index 91cdd3ca5e..327f58da2c 100644 --- a/packages/typespec-ts/src/modular/build-client-context.ts +++ b/packages/typespec-ts/src/modular/build-client-context.ts @@ -83,7 +83,7 @@ export function buildClientContext( .map((p) => { return { name: getClientParameterName(p), - type: getTypeExpression(dpgContext, p.type), + type: shouldUseStringApiVersionType(p) ? "string" : getTypeExpression(dpgContext, p.type), hasQuestionToken: false, docs: getDocsWithKnownVersion(dpgContext, p), }; @@ -108,7 +108,7 @@ export function buildClientContext( .map((p) => { return { name: getClientParameterName(p), - type: getTypeExpression(dpgContext, p.type), + type: shouldUseStringApiVersionType(p) ? "string" : getTypeExpression(dpgContext, p.type), hasQuestionToken: true, docs: getDocsWithKnownVersion(dpgContext, p), }; @@ -129,8 +129,7 @@ export function buildClientContext( .map((p) => { return { name: getClientParameterName(p), - type: - p.name.toLowerCase() === "apiversion" ? "string" : getTypeExpression(dpgContext, p.type), + type: shouldUseStringApiVersionType(p) ? "string" : getTypeExpression(dpgContext, p.type), hasQuestionToken: true, docs: getDocsWithKnownVersion(dpgContext, p), }; @@ -235,7 +234,10 @@ export function buildClientContext( : []; const apiVersionInEndpoint = templateArguments && templateArguments.find((p) => p.isApiVersionParam); - if (!apiVersionInEndpoint && apiVersionParam.clientDefaultValue) { + if ( + !apiVersionInEndpoint && + (apiVersionParam.optional || apiVersionParam.clientDefaultValue !== undefined) + ) { apiVersionStatement += `const ${apiVersionParamName} = options.${apiVersionParamName};`; } } else { @@ -298,6 +300,16 @@ export function buildClientContext( return clientContextFile; } +function shouldUseStringApiVersionType( + parameter: SdkMethodParameter | SdkHttpParameter | SdkEndpointParameter | SdkCredentialParameter, +): boolean { + if (!parameter.isApiVersionParam) { + return false; + } + const name = parameter.name.toLowerCase(); + return name === "apiversion" || name === "serviceversion"; +} + function getDocsWithKnownVersion( dpgContext: SdkContext, param: SdkMethodParameter | SdkEndpointParameter | SdkCredentialParameter | SdkHttpParameter, diff --git a/packages/typespec-ts/src/modular/helpers/operation-helpers.ts b/packages/typespec-ts/src/modular/helpers/operation-helpers.ts index 57471eb23d..e80c0bf6bf 100644 --- a/packages/typespec-ts/src/modular/helpers/operation-helpers.ts +++ b/packages/typespec-ts/src/modular/helpers/operation-helpers.ts @@ -129,7 +129,7 @@ export function getSendPrivateFunction( let pathStr = `"${operationPath}"`; const urlTemplateParams = [ ...getPathParameters(operation), - ...getQueryParameters(dpgContext, operation), + ...getQueryParameters(dpgContext, operation, client), ]; if (urlTemplateParams.length > 0) { // Generate a unique local variable name that doesn't conflict with parameter names @@ -1619,6 +1619,11 @@ export function getParameterMap( context: SdkContext, param: SdkHttpParameter, paramAccessor: string, + apiVersionOptions?: { + contextParamName?: string; + defaultValue?: string; + useDefaultOnly: boolean; + }, ): string { // Use lowercase for header names since HTTP headers are case-insensitive const serializedName = @@ -1629,12 +1634,18 @@ export function getParameterMap( } // Special case for api-version parameters with default values - if (param.isApiVersionParam && param.clientDefaultValue) { - // For multi-service, use only the default value (don't reference context.apiVersion) - if (context.emitterOptions?.isMultiService) { - return `"${serializedName}": "${param.clientDefaultValue}"`; + if ( + param.isApiVersionParam && + (param.clientDefaultValue !== undefined || apiVersionOptions?.defaultValue !== undefined) + ) { + const defaultValue = apiVersionOptions?.defaultValue ?? param.clientDefaultValue; + // A parent client's runtime API-version option must not cross into a child + // client with a distinct API-version default. + if (context.emitterOptions?.isMultiService || apiVersionOptions?.useDefaultOnly) { + return `"${serializedName}": "${defaultValue}"`; } - return `"${serializedName}": ${param.onClient ? "context." : ""}${param.name} ?? "${param.clientDefaultValue}"`; + const paramName = apiVersionOptions?.contextParamName ?? param.name; + return `"${serializedName}": ${param.onClient ? "context." : ""}${paramName} ?? "${defaultValue}"`; } if (hasCollectionFormatInfo(param.kind, (param as any).collectionFormat)) { @@ -1851,7 +1862,11 @@ function getPathParameters(operation: ServiceOperation, optionalParamName: strin /** * Extract the query parameters */ -function getQueryParameters(dpgContext: SdkContext, operation: ServiceOperation): string[] { +function getQueryParameters( + dpgContext: SdkContext, + operation: ServiceOperation, + client?: SdkClientType, +): string[] { if (!operation.parameters) { return []; } @@ -1860,6 +1875,18 @@ function getQueryParameters(dpgContext: SdkContext, operation: ServiceOperation) { query: [], }; + const owner = client ? findClientForOperation(client, operation) : undefined; + const apiVersionContextParam = client?.clientInitialization.parameters.find( + (p) => p.isApiVersionParam, + ); + const apiVersionOptions = { + contextParamName: apiVersionContextParam?.name, + defaultValue: owner?.apiVersionDefaultValue, + useDefaultOnly: + owner !== undefined && + owner !== client && + owner.apiVersionDefaultValue !== client?.apiVersionDefaultValue, + }; for (const param of operationParameters) { if (param.kind === "query") { @@ -1876,6 +1903,7 @@ function getQueryParameters(dpgContext: SdkContext, operation: ServiceOperation) serializedName: getUriTemplateQueryParamName(param.serializedName), }, paramAccessor, + apiVersionOptions, ), param, }); @@ -1888,6 +1916,22 @@ function getQueryParameters(dpgContext: SdkContext, operation: ServiceOperation) return paramStr; } +function findClientForOperation( + client: SdkClientType, + operation: ServiceOperation, +): SdkClientType | undefined { + if (client.methods.includes(operation)) { + return client; + } + for (const child of client.children ?? []) { + const owner = findClientForOperation(child, operation); + if (owner) { + return owner; + } + } + return undefined; +} + function getUriTemplateQueryParamName(name: string) { return `${escapeUriTemplateParamName(name)}`; } diff --git a/packages/typespec-ts/test/modular-unit/client-api-version-override.test.ts b/packages/typespec-ts/test/modular-unit/client-api-version-override.test.ts new file mode 100644 index 0000000000..9b0cff46f7 --- /dev/null +++ b/packages/typespec-ts/test/modular-unit/client-api-version-override.test.ts @@ -0,0 +1,100 @@ +import { expect, it } from "vitest"; +import { + emitModularClientContextFromTypeSpec, + emitModularOperationsFromTypeSpec, +} from "../util/emit-util.js"; + +it("isolates a child API-version override from parent options", async () => { + const operations = await emitModularOperationsFromTypeSpec( + ` + @service(#{ title: "Versioned Service" }) + @versioned(Versions) + @client({ name: "VersionedServiceClient", service: VersionedService }) + namespace VersionedService { + enum Versions { + v1: "2024-01-01", + v2: "2025-01-01", + } + + @route("/parent") + op getParent(@query("api-version") @apiVersion apiVersion: Versions = Versions.v2): void; + + @route("/legacy") + @client({ name: "LegacyOperationsClient", service: VersionedService }) + @Azure.Core.Legacy.overrideApiVersion("opaque-legacy-version") + interface LegacyOperations { + getLegacy(@query("api-version") @apiVersion apiVersion: Versions = Versions.v2): void; + } + } + `, + { needAzureCore: true, needTCGC: true }, + ); + + expect(operations).toBeDefined(); + const text = [...operations!].map((file) => file.getFullText()).join("\n"); + expect(text).toContain('"api%2Dversion": "opaque-legacy-version"'); + expect(text).toContain('"api%2Dversion": context.apiVersion ?? "2025-01-01"'); +}); + +it("initializes an optional custom-named API-version context property", async () => { + const context = await emitModularClientContextFromTypeSpec( + ` + @service(#{ title: "Versioned Service" }) + @versioned(Versions) + @client({ name: "VersionedServiceClient", service: VersionedService }) + namespace VersionedService { + enum Versions { + v1: "2024-01-01", + v2: "2025-01-01", + } + + @route("/parent") + op getParent( + @query("api-version") @apiVersion serviceVersion?: Versions + ): void; + + @Azure.Core.Legacy.overrideApiVersion("opaque-legacy-version") + namespace Legacy { + @route("/legacy") + @client({ name: "LegacyOperationsClient", service: VersionedService }) + interface LegacyOperations { + getLegacy( + @query("api-version") @apiVersion apiVersion: Versions = Versions.v2 + ): void; + } + } + } + `, + { needAzureCore: true, needTCGC: true }, + ); + + const text = context!.getFullText(); + expect(text).toContain("serviceVersion?: string"); + const declaration = "const serviceVersion = options.serviceVersion;"; + expect(text).toContain(declaration); + expect(text).toMatch(/return\s*\{\s*\.\.\.clientContext,\s*serviceVersion\s*\}/); + expect(text.indexOf(declaration)).toBeLessThan(text.indexOf("return")); +}); + +it("preserves enum typing for normal version options", async () => { + const context = await emitModularClientContextFromTypeSpec( + ` + @service(#{ title: "Versioned Service" }) + @versioned(Versions) + namespace VersionedService { + enum Versions { + v1: "2024-01-01", + v2: "2025-01-01", + } + + @route("/items") + op getItem(@query("api-version") @apiVersion version: Versions = Versions.v2): void; + } + `, + { needAzureCore: true, needTCGC: true }, + ); + + const text = context!.getFullText(); + expect(text).toContain("version?: Versions"); + expect(text).not.toContain("version?: string"); +}); diff --git a/packages/typespec-ts/test/modular-unit/scenario-suites/client-version-override.test.ts b/packages/typespec-ts/test/modular-unit/scenario-suites/client-version-override.test.ts new file mode 100644 index 0000000000..b493b3558a --- /dev/null +++ b/packages/typespec-ts/test/modular-unit/scenario-suites/client-version-override.test.ts @@ -0,0 +1,7 @@ +// Generated by `pnpm gen:scenario-suites`. Do not edit by hand. +import { describe } from "vitest"; +import { describeScenarioDir } from "../scenario-runner.js"; + +describe("Scenarios: client-version-override", function () { + describeScenarioDir("./test/modular-unit/scenarios/client-version-override"); +}); diff --git a/packages/typespec-ts/test/modular-unit/scenarios/client-version-override/enclosing-namespace.md b/packages/typespec-ts/test/modular-unit/scenarios/client-version-override/enclosing-namespace.md new file mode 100644 index 0000000000..516d2840c8 --- /dev/null +++ b/packages/typespec-ts/test/modular-unit/scenarios/client-version-override/enclosing-namespace.md @@ -0,0 +1,192 @@ +# An enclosing namespace can set a distinct client API version + +## TypeSpec + +```tsp +@service(#{ title: "Versioned Service" }) +@versioned(Versions) +@client({ + name: "VersionedServiceClient", + service: VersionedService, +}) +namespace VersionedService { + enum Versions { + v1: "2024-01-01", + v2: "2025-01-01", + } + + @route("/parent") + op getParent(@query("api-version") @apiVersion serviceVersion: Versions = Versions.v2): void; + + @Azure.Core.Legacy.overrideApiVersion("opaque-legacy-version") + namespace Legacy { + @route("/legacy") + @client({ + name: "LegacyOperationsClient", + service: VersionedService, + }) + interface LegacyOperations { + getLegacy(@query("api-version") @apiVersion apiVersion: Versions = Versions.v2): void; + } + } + + @route("/current") + @client({ + name: "CurrentOperationsClient", + service: VersionedService, + }) + interface CurrentOperations { + getCurrent(@query("api-version") @apiVersion apiVersion: Versions = Versions.v2): void; + } +} +``` + +## Config + +```yaml +needAzureCore: true +needTCGC: true +``` + +## Client context + +```ts clientContext +import { logger } from "../../logger.js"; +import { Client, ClientOptions, getClient } from "@azure-rest/core-client"; + +export interface VersionedServiceContext extends Client { + serviceVersion?: string; +} + +/** Optional parameters for the client. */ +export interface VersionedServiceClientOptionalParams extends ClientOptions { + serviceVersion?: string; +} + +export function createVersionedService( + endpointParam: string, + options: VersionedServiceClientOptionalParams = {}, +): VersionedServiceContext { + const endpointUrl = options.endpoint ?? String(endpointParam); + const { serviceVersion: _, ...updatedOptions } = { + ...options, + loggingOptions: { logger: options.loggingOptions?.logger ?? logger.info }, + }; + const clientContext = getClient(endpointUrl, undefined, updatedOptions); + return { ...clientContext, serviceVersion } as VersionedServiceContext; +} +``` + +## Operations + +```ts operations +import { VersionedServiceContext as Client } from "./index.js"; +import { expandUrlTemplate } from "../static-helpers/urlTemplate.js"; +import { + GetParentOptionalParams, + GetLegacyOptionalParams, + GetCurrentOptionalParams, +} from "./options.js"; +import { + StreamableMethod, + PathUncheckedResponse, + createRestError, + operationOptionsToRequestParameters, +} from "@azure-rest/core-client"; + +export function _getParentSend( + context: Client, + options: GetParentOptionalParams = { requestOptions: {} }, +): StreamableMethod { + const path = expandUrlTemplate( + "/parent{?api%2Dversion}", + { + "api%2Dversion": context.serviceVersion ?? "2025-01-01", + }, + { + allowReserved: options?.requestOptions?.skipUrlEncoding, + }, + ); + return context.path(path).get({ ...operationOptionsToRequestParameters(options) }); +} + +export async function _getParentDeserialize(result: PathUncheckedResponse): Promise { + const expectedStatuses = ["204"]; + if (!expectedStatuses.includes(result.status)) { + throw createRestError(result); + } + + return; +} +export async function getParent( + context: Client, + options: GetParentOptionalParams = { requestOptions: {} }, +): Promise { + const result = await _getParentSend(context, options); + return _getParentDeserialize(result); +} + +export function _getLegacySend( + context: Client, + options: GetLegacyOptionalParams = { requestOptions: {} }, +): StreamableMethod { + const path = expandUrlTemplate( + "/legacy{?api%2Dversion}", + { + "api%2Dversion": "opaque-legacy-version", + }, + { + allowReserved: options?.requestOptions?.skipUrlEncoding, + }, + ); + return context.path(path).get({ ...operationOptionsToRequestParameters(options) }); +} + +export async function _getLegacyDeserialize(result: PathUncheckedResponse): Promise { + const expectedStatuses = ["204"]; + if (!expectedStatuses.includes(result.status)) { + throw createRestError(result); + } + + return; +} +export async function getLegacy( + context: Client, + options: GetLegacyOptionalParams = { requestOptions: {} }, +): Promise { + const result = await _getLegacySend(context, options); + return _getLegacyDeserialize(result); +} + +export function _getCurrentSend( + context: Client, + options: GetCurrentOptionalParams = { requestOptions: {} }, +): StreamableMethod { + const path = expandUrlTemplate( + "/current{?api%2Dversion}", + { + "api%2Dversion": context.serviceVersion ?? "2025-01-01", + }, + { + allowReserved: options?.requestOptions?.skipUrlEncoding, + }, + ); + return context.path(path).get({ ...operationOptionsToRequestParameters(options) }); +} + +export async function _getCurrentDeserialize(result: PathUncheckedResponse): Promise { + const expectedStatuses = ["204"]; + if (!expectedStatuses.includes(result.status)) { + throw createRestError(result); + } + + return; +} +export async function getCurrent( + context: Client, + options: GetCurrentOptionalParams = { requestOptions: {} }, +): Promise { + const result = await _getCurrentSend(context, options); + return _getCurrentDeserialize(result); +} +``` diff --git a/website/src/content/docs/docs/libraries/azure-core/reference/decorators.md b/website/src/content/docs/docs/libraries/azure-core/reference/decorators.md index c62be276ad..8dbc9b5c14 100644 --- a/website/src/content/docs/docs/libraries/azure-core/reference/decorators.md +++ b/website/src/content/docs/docs/libraries/azure-core/reference/decorators.md @@ -347,6 +347,46 @@ Identifies a property on _all_ non-error response models that serve as a linked | ---- | ---------------- | --------------------------- | | name | `valueof string` | Property name on the target | +## Azure.Core.Legacy + +### `@overrideApiVersion` {#@Azure.Core.Legacy.overrideApiVersion} + +Overrides the API-version wire value used for operations within a namespace or interface. + +The value is opaque and does not need to be declared by the service version enum. The override +is inherited by enclosed namespaces, interfaces, and operations, with the nearest override taking +precedence. + +This decorator is considered legacy functionality and should only be used to preserve +compatibility with an existing SDK. + +```typespec +@Azure.Core.Legacy.overrideApiVersion(version: valueof string, scope?: valueof string) +``` + +#### Target + +The namespace or interface whose operations use the API-version override. +`Namespace | Interface` + +#### Parameters + +| Name | Type | Description | +| ------- | ---------------- | ---------------------------------------------------- | +| version | `valueof string` | The non-empty API-version wire value. | +| scope | `valueof string` | The language emitters to which the override applies. | + +#### Examples + +##### Override an interface API version + +```typespec +@Azure.Core.Legacy.overrideApiVersion("2021-11-01") +interface Widgets { + get(): void; +} +``` + ## Azure.Core.Traits ### `@trait` {#@Azure.Core.Traits.trait} diff --git a/website/src/content/docs/docs/libraries/azure-core/reference/index.mdx b/website/src/content/docs/docs/libraries/azure-core/reference/index.mdx index 1cddcb0100..54de753eab 100644 --- a/website/src/content/docs/docs/libraries/azure-core/reference/index.mdx +++ b/website/src/content/docs/docs/libraries/azure-core/reference/index.mdx @@ -149,6 +149,10 @@ npm install --save-peer @azure-tools/typespec-azure-core ## Azure.Core.Legacy +### Decorators + +- [`@overrideApiVersion`](./decorators.md#@Azure.Core.Legacy.overrideApiVersion) + ## Azure.Core.Traits ### Decorators diff --git a/website/src/content/docs/docs/libraries/azure-resource-manager/reference/data-types.md b/website/src/content/docs/docs/libraries/azure-resource-manager/reference/data-types.md index e5170fe890..e2bcb629c0 100644 --- a/website/src/content/docs/docs/libraries/azure-resource-manager/reference/data-types.md +++ b/website/src/content/docs/docs/libraries/azure-resource-manager/reference/data-types.md @@ -270,13 +270,14 @@ model Azure.ResourceManager.ArmFeatureFileOptions #### Properties -| Name | Type | Description | -| --------------- | -------- | ----------------------------------------- | -| featureName | `string` | The feature name | -| fileName | `string` | The associated file name for the features | -| description | `string` | The feature description in Swagger | -| title? | `string` | The feature title in Swagger | -| termsOfService? | `string` | The feature terms of service in Swagger | +| Name | Type | Description | +| --------------- | -------- | ------------------------------------------------------------------- | +| featureName | `string` | The feature name | +| fileName | `string` | The associated file name for the features | +| description | `string` | The feature description in Swagger | +| title? | `string` | The feature title in Swagger | +| termsOfService? | `string` | The feature terms of service in Swagger | +| version? | `string` | The API version to use for clients generated from this feature file | ### `ArmFilterParameter` {#Azure.ResourceManager.ArmFilterParameter}