diff --git a/.chronus/changes/fix-go-path-api-version-override-2026-08-04-14-45-00.md b/.chronus/changes/fix-go-path-api-version-override-2026-08-04-14-45-00.md new file mode 100644 index 0000000000..21b50328e6 --- /dev/null +++ b/.chronus/changes/fix-go-path-api-version-override-2026-08-04-14-45-00.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@azure-tools/typespec-go" +--- + +Honor `ClientOptions.APIVersion` for API versions emitted in operation paths. diff --git a/packages/typespec-go/src/codegen/core/helpers.ts b/packages/typespec-go/src/codegen/core/helpers.ts index cf334e12fe..de81bda00d 100644 --- a/packages/typespec-go/src/codegen/core/helpers.ts +++ b/packages/typespec-go/src/codegen/core/helpers.ts @@ -87,6 +87,18 @@ export function canonicalizeHeaderName(name: string): string { return canonicalName; } +export function supportsPathAPIVersionOverride(client: go.Client): boolean { + return ( + client.instance?.kind === "constructable" && + client.parameters.some( + (param) => + param.kind === "pathScalarParam" && + param.isApiVersion && + go.isLiteralParameter(param.style), + ) + ); +} + /** * returns the parameter's type definition with a possible '*' prefix * diff --git a/packages/typespec-go/src/codegen/core/operations.ts b/packages/typespec-go/src/codegen/core/operations.ts index a21a0c58f8..6561dab3df 100644 --- a/packages/typespec-go/src/codegen/core/operations.ts +++ b/packages/typespec-go/src/codegen/core/operations.ts @@ -96,9 +96,13 @@ export function generateOperations( } const indent = new helpers.Indentation(); + const pathAPIVersionOverride = helpers.supportsPathAPIVersionOverride(client); clientText += `type ${client.name} struct {\n`; clientText += `${indent.get()}internal *${azureARM ? "arm" : "azcore"}.Client\n`; + if (pathAPIVersionOverride) { + clientText += `${indent.get()}apiVersion string\n`; + } // check for any optional host params const optionalParams = new Array(); @@ -137,7 +141,13 @@ export function generateOperations( // end of client definition clientText += "}\n\n"; - clientText += generateConstructors(client, target, imports, indent); + clientText += generateConstructors( + client, + target, + imports, + indent, + pathAPIVersionOverride, + ); // generate client accessors and operations let opText = ""; @@ -228,6 +238,7 @@ function generateConstructors( type: go.CodeModelType, imports: ImportManager, indent: helpers.Indentation, + pathAPIVersionOverride: boolean, ): string { if (client.instance?.kind !== "constructable") { return ""; @@ -236,6 +247,12 @@ function generateConstructors( const clientOptions = client.instance.options; let ctorText = ""; + const emitDefaultOptions = (optionsTypeName: string): string => { + let text = `${indent.get()}if options == nil {\n`; + text += `${indent.push().get()}options = &${optionsTypeName}{}\n`; + text += `${indent.pop().get()}}\n`; + return text; + }; if (clientOptions.kind === "clientOptions") { // for non-ARM, the options type will always be a parameter group @@ -297,9 +314,7 @@ function generateConstructors( plOpts?: string, ): string { imports.add("github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"); - let bodyText = `${indent.get()}if options == nil {\n`; - bodyText += `${indent.push().get()}options = &${optionsTypeName}{}\n`; - bodyText += `${indent.pop().get()}}\n`; + let bodyText = emitDefaultOptions(optionsTypeName); let apiVersionConfig = ""; // check if there's an api version parameter let apiVersionParam: @@ -315,7 +330,7 @@ function generateConstructors( case "queryScalarParam": case "uriParam": if (param.isApiVersion) { - apiVersionParam = param; + apiVersionParam ??= param; } } } @@ -412,7 +427,11 @@ function generateConstructors( case "armClientOptions": // this is the ARM case imports.add("github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"); - prolog = `${indent.get()}cl, err := arm.NewClient(moduleName, moduleVersion, credential, options)\n`; + prolog = ""; + if (pathAPIVersionOverride) { + prolog += emitDefaultOptions(go.getTypeDeclaration(clientOptions, client.pkg)); + } + prolog += `${indent.get()}cl, err := arm.NewClient(moduleName, moduleVersion, credential, options)\n`; break; } break; @@ -490,6 +509,9 @@ function generateConstructors( // as any supplemental endpoint params are ephemeral and // consumed during client construction. indent.push(); + if (pathAPIVersionOverride) { + ctorText += `${indent.get()}apiVersion: options.APIVersion,\n`; + } for (const parameter of client.parameters) { if (go.isLiteralParameter(parameter.style)) { continue; diff --git a/packages/typespec-go/src/codegen/core/request-handler.ts b/packages/typespec-go/src/codegen/core/request-handler.ts index ff490c518f..1a2db2b7a6 100644 --- a/packages/typespec-go/src/codegen/core/request-handler.ts +++ b/packages/typespec-go/src/codegen/core/request-handler.ts @@ -102,6 +102,17 @@ export function createRequestHandler( if (pp.style === "literal") { // literals are always scalar types and require no empty checks paramValue = helpers.formatParamValue(pp, imports, indent); + if ( + pp.kind === "pathScalarParam" && + pp.isApiVersion && + helpers.supportsPathAPIVersionOverride(method.receiver.type) + ) { + text += `${indent.get()}apiVersion := ${paramValue}\n`; + text += `${indent.get()}if client.apiVersion != "" {\n`; + text += `${indent.push().get()}apiVersion = client.apiVersion\n`; + text += `${indent.pop().get()}}\n`; + paramValue = "apiVersion"; + } } else if (pp.style === "required" || pp.location === "client") { // NOTE: we include client params here since they behave // like required params (i.e. not grouped). diff --git a/packages/typespec-go/test/azure-http-specs/azure/client-generator-core/api-version/apiversionpathgroup/path_client_test.go b/packages/typespec-go/test/azure-http-specs/azure/client-generator-core/api-version/apiversionpathgroup/path_client_test.go index 3c63b90d40..87dac2857a 100644 --- a/packages/typespec-go/test/azure-http-specs/azure/client-generator-core/api-version/apiversionpathgroup/path_client_test.go +++ b/packages/typespec-go/test/azure-http-specs/azure/client-generator-core/api-version/apiversionpathgroup/path_client_test.go @@ -4,9 +4,59 @@ package apiversionpathgroup_test import ( + "context" + "net/http" "testing" + + "apiversionpathgroup" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/stretchr/testify/require" ) +type captureTransport struct { + request *http.Request +} + +func (c *captureTransport) Do(request *http.Request) (*http.Response, error) { + c.request = request + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{}, + Body: http.NoBody, + Request: request, + }, nil +} + func TestPathClient_PathAPIVersion(t *testing.T) { - t.Skip("https://github.com/Azure/autorest.go/issues/1743") + client, err := apiversionpathgroup.NewPathClientWithNoCredential("http://localhost:3000", nil) + require.NoError(t, err) + + resp, err := client.PathAPIVersion(context.Background(), nil) + require.NoError(t, err) + require.Zero(t, resp) +} + +func TestPathClient_PathAPIVersionOverride(t *testing.T) { + const apiVersion = "2026-01-01-preview" + transport := &captureTransport{} + client, err := apiversionpathgroup.NewPathClientWithNoCredential( + "http://localhost:3000", + &apiversionpathgroup.PathClientOptions{ + ClientOptions: azcore.ClientOptions{ + APIVersion: apiVersion, + Transport: transport, + }, + }, + ) + require.NoError(t, err) + + resp, err := client.PathAPIVersion(context.Background(), nil) + require.NoError(t, err) + require.Zero(t, resp) + require.NotNil(t, transport.request) + require.Equal( + t, + "/azure/client-generator-core/api-version/path/"+apiVersion, + transport.request.URL.Path, + ) } diff --git a/packages/typespec-go/test/unittest/scenario-suites/arm-path-api-version-override.test.ts b/packages/typespec-go/test/unittest/scenario-suites/arm-path-api-version-override.test.ts new file mode 100644 index 0000000000..b8fd73b8f0 --- /dev/null +++ b/packages/typespec-go/test/unittest/scenario-suites/arm-path-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/arm-path-api-version-override.md"), +); diff --git a/packages/typespec-go/test/unittest/scenario-suites/path-api-version-override.test.ts b/packages/typespec-go/test/unittest/scenario-suites/path-api-version-override.test.ts new file mode 100644 index 0000000000..52e3bdb29c --- /dev/null +++ b/packages/typespec-go/test/unittest/scenario-suites/path-api-version-override.test.ts @@ -0,0 +1,5 @@ +// 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/path-api-version-override.md")); diff --git a/packages/typespec-go/test/unittest/scenarios/arm-path-api-version-override.md b/packages/typespec-go/test/unittest/scenarios/arm-path-api-version-override.md new file mode 100644 index 0000000000..a4180cfdbb --- /dev/null +++ b/packages/typespec-go/test/unittest/scenarios/arm-path-api-version-override.md @@ -0,0 +1,99 @@ +# An ARM path API version uses the client-level override + +## TypeSpec + +```tsp +@armProviderNamespace +@versioned(Versions) +namespace Microsoft.Test; + +enum Versions { + v2022_01_01: "2022-01-01", +} + +@get +@route("/api-version/{apiVersion}") +op get(@path apiVersion: string): void; +``` + +## The generated ARM client stores the optional override and falls back to the literal + +```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/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "net/http" + "net/url" + "strings" +) + +// TestClient contains the methods for the Test group. +// Don't use this type directly, use NewTestClient() instead. +// +// Generated from API version 2022-01-01 +type TestClient struct { + internal *arm.Client + apiVersion string +} + +// NewTestClient creates a new instance of TestClient with the specified values. +// - credential - used to authorize requests. Usually a credential from azidentity. +// - options - Contains optional client configuration. Pass nil to accept the default values. +func NewTestClient(credential azcore.TokenCredential, options *arm.ClientOptions) (*TestClient, error) { + if options == nil { + options = &arm.ClientOptions{} + } + cl, err := arm.NewClient(moduleName, moduleVersion, credential, options) + if err != nil { + return nil, err + } + client := &TestClient{ + apiVersion: options.APIVersion, + internal: cl, + } + return client, nil +} + +// Get - +// If the operation fails it returns an *azcore.ResponseError type. +// - options - TestClientGetOptions contains the optional parameters for the TestClient.Get method. +func (client *TestClient) Get(ctx context.Context, options *TestClientGetOptions) (TestClientGetResponse, error) { + var err error + req, err := client.getCreateRequest(ctx, options) + if err != nil { + return TestClientGetResponse{}, err + } + httpResp, err := client.internal.Pipeline().Do(req) + if err != nil { + return TestClientGetResponse{}, err + } + if !runtime.HasStatusCode(httpResp, http.StatusNoContent) { + return TestClientGetResponse{}, runtime.NewResponseError(httpResp) + } + return TestClientGetResponse{}, nil +} + +// getCreateRequest creates the Get request. +func (client *TestClient) getCreateRequest(ctx context.Context, _ *TestClientGetOptions) (*policy.Request, error) { + urlPath := "/api-version/{apiVersion}" + apiVersion := version20220101 + if client.apiVersion != "" { + apiVersion = client.apiVersion + } + urlPath = strings.ReplaceAll(urlPath, "{apiVersion}", url.PathEscape(apiVersion)) + req, err := runtime.NewRequest(ctx, http.MethodGet, runtime.JoinPaths(client.internal.Endpoint(), urlPath)) + if err != nil { + return nil, err + } + return req, nil +} +``` diff --git a/packages/typespec-go/test/unittest/scenarios/path-api-version-override.md b/packages/typespec-go/test/unittest/scenarios/path-api-version-override.md new file mode 100644 index 0000000000..0523bb122b --- /dev/null +++ b/packages/typespec-go/test/unittest/scenarios/path-api-version-override.md @@ -0,0 +1,117 @@ +# A path API version uses the client-level API version override + +## TypeSpec + +```tsp +@service +@versioned(Versions) +@server( + "{endpoint}", + "Test endpoint", + { + endpoint: url, + } +) +namespace Versioned; + +enum Versions { + v2022_12_01_preview: "2022-12-01-preview", +} + +@head +@route("/with-path-api-version/{apiVersion}") +op withPathApiVersion(@path apiVersion: string): void; +``` + +## The generated client stores and uses the configured API version for path parameters + +```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" + "net/url" + "strings" +) + +// VersionedClient contains the methods for the Versioned group. +// Don't use this type directly, use NewVersionedClientWithNoCredential() instead. +// +// Generated from API version 2022-12-01-preview +type VersionedClient struct { + internal *azcore.Client + apiVersion string + endpoint string +} + +// VersionedClientOptions contains the optional values for creating a [VersionedClient]. +type VersionedClientOptions struct { + azcore.ClientOptions +} + +// NewVersionedClientWithNoCredential creates a new instance of VersionedClient with the specified values. +// - endpoint - Service host +// - options - Contains optional client configuration. Pass nil to accept the default values. +func NewVersionedClientWithNoCredential(endpoint string, options *VersionedClientOptions) (*VersionedClient, error) { + if options == nil { + options = &VersionedClientOptions{} + } + cl, err := azcore.NewClient(moduleName, moduleVersion, runtime.PipelineOptions{ + APIVersion: runtime.APIVersionOptions{ + Location: runtime.APIVersionLocationPath, + }, + }, &options.ClientOptions) + if err != nil { + return nil, err + } + client := &VersionedClient{ + apiVersion: options.APIVersion, + endpoint: endpoint, + internal: cl, + } + return client, nil +} + +// WithPathAPIVersion - +// If the operation fails it returns an *azcore.ResponseError type. +// - options - VersionedClientWithPathAPIVersionOptions contains the optional parameters for the VersionedClient.WithPathAPIVersion +// method. +func (client *VersionedClient) WithPathAPIVersion(ctx context.Context, options *VersionedClientWithPathAPIVersionOptions) (VersionedClientWithPathAPIVersionResponse, error) { + var err error + req, err := client.withPathAPIVersionCreateRequest(ctx, options) + if err != nil { + return VersionedClientWithPathAPIVersionResponse{}, err + } + httpResp, err := client.internal.Pipeline().Do(req) + if err != nil { + return VersionedClientWithPathAPIVersionResponse{}, err + } + if !runtime.HasStatusCode(httpResp, http.StatusNoContent) { + return VersionedClientWithPathAPIVersionResponse{}, runtime.NewResponseError(httpResp) + } + return VersionedClientWithPathAPIVersionResponse{}, nil +} + +// withPathAPIVersionCreateRequest creates the WithPathAPIVersion request. +func (client *VersionedClient) withPathAPIVersionCreateRequest(ctx context.Context, _ *VersionedClientWithPathAPIVersionOptions) (*policy.Request, error) { + urlPath := "/with-path-api-version/{apiVersion}" + apiVersion := version20221201Preview + if client.apiVersion != "" { + apiVersion = client.apiVersion + } + urlPath = strings.ReplaceAll(urlPath, "{apiVersion}", url.PathEscape(apiVersion)) + req, err := runtime.NewRequest(ctx, http.MethodHead, runtime.JoinPaths(client.endpoint, urlPath)) + if err != nil { + return nil, err + } + return req, nil +} +```