Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .chronus/changes/fix-js-option-forwarding-2026-08-17.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
changeKind: fix
packages:
- "@azure-tools/typespec-ts"
---

Forward legacy headers, credential scopes, and logging options in modular clients, including a
deprecated package-local `credentialScopes` alias for clients that use OAuth scopes.
11 changes: 11 additions & 0 deletions packages/typespec-ts/src/modular/build-client-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,17 @@ export function buildClientContext(
docs: getDocsWithKnownVersion(dpgContext, p),
};
});
if (
emitterOptions.options.addCredentials &&
emitterOptions.options.credentialScopes !== undefined
) {
propertiesInOptions.push({
name: "credentialScopes",
type: "string | string[]",
hasQuestionToken: true,
docs: ["@deprecated Use `credentials.scopes` instead."],
});
}
if (dpgContext.arm) {
propertiesInOptions.push({
name: "cloudSetting",
Expand Down
6 changes: 4 additions & 2 deletions packages/typespec-ts/src/modular/helpers/client-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,9 @@ function buildCredentials(
const scopesString = credentialScopes
? credentialScopes.map((cs) => `"${cs}"`).join(", ") || `\`\${${endpointParam}}/.default\``
: "";
const scopes = scopesString ? `scopes: options.credentials?.scopes ?? [${scopesString}],` : "";
const scopes = scopesString
? `scopes: options.credentials?.scopes ?? (typeof options.credentialScopes === "string" ? [options.credentialScopes] : options.credentialScopes) ?? [${scopesString}],`
: "";

const apiKeyHeaderName = credentialKeyHeaderName
? `apiKeyHeaderName: options.credentials?.apiKeyHeaderName ?? "${credentialKeyHeaderName}",`
Expand All @@ -312,7 +314,7 @@ function buildCredentials(
}

function buildLoggingOptions(): string | undefined {
return `{ logger: options.loggingOptions?.logger ?? logger.info }`;
return `{ ...options.loggingOptions, logger: options.loggingOptions?.logger ?? logger.info }`;
}

/**
Expand Down
37 changes: 26 additions & 11 deletions packages/typespec-ts/src/modular/helpers/operation-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,15 @@ export function getSendPrivateFunction(
const operationMethod = operation.operation.verb.toLowerCase();
const optionalParamName = getOptionalParamsName(parameters);
const statements: string[] = [];
const parameterNames = new Set(parameters.map((p) => p.name));
let pathStr = `"${operationPath}"`;
const urlTemplateParams = [
...getPathParameters(operation),
...getQueryParameters(dpgContext, operation),
];
if (urlTemplateParams.length > 0) {
// Generate a unique local variable name that doesn't conflict with parameter names
const paramNames = new Set(parameters.map((p) => p.name));
const pathVarName = generateLocallyUniqueName("path", paramNames);
const pathVarName = generateLocallyUniqueName("path", parameterNames);
const includeRootSlash = client ? getClientOptions(client, "includeRootSlash") !== false : true;

const uriTemplate = includeRootSlash
Expand All @@ -149,12 +149,26 @@ export function getSendPrivateFunction(
pathStr = pathVarName;
}

const requestParametersName = generateLocallyUniqueName("requestParameters", parameterNames);
const headerAndBodyParameters = getHeaderAndBodyParameters(
dpgContext,
operation,
optionalParamName,
requestParametersName,
);
const operationOptionsExpression = `${resolveReference(
dependencies.operationOptionsToRequestParameters,
)}(${optionalParamName})`;
const requestParametersExpression = headerAndBodyParameters.hasHeaders
? requestParametersName
: operationOptionsExpression;

if (headerAndBodyParameters.hasHeaders) {
statements.push(`const ${requestParametersName} = ${operationOptionsExpression};`);
}

statements.push(
`return context.path(${pathStr}).${operationMethod}({...${resolveReference(dependencies.operationOptionsToRequestParameters)}(${optionalParamName}), ${getHeaderAndBodyParameters(
dpgContext,
operation,
optionalParamName,
)}});`,
`return context.path(${pathStr}).${operationMethod}({...${requestParametersExpression}, ${headerAndBodyParameters.value}});`,
);

return {
Expand Down Expand Up @@ -1413,9 +1427,10 @@ function getHeaderAndBodyParameters(
dpgContext: SdkContext,
operation: ServiceOperation,
optionalParamName: string = "options",
): string {
requestParametersName: string = "requestParameters",
): { value: string; hasHeaders: boolean } {
if (!operation.operation.parameters) {
return "";
return { value: "", hasHeaders: false };
}
const operationParameters = operation.operation.parameters.filter((p) => !isContentType(p));

Expand Down Expand Up @@ -1464,7 +1479,7 @@ function getHeaderAndBodyParameters(
if (parametersImplementation.header.length) {
paramStr = `${paramStr}\nheaders: {${parametersImplementation.header
.map((i) => buildHeaderParameter(dpgContext.program, i.paramMap, i.param, i.paramAccessor))
.join(",\n")}, ...${optionalParamName}.requestOptions?.headers },`;
.join(",\n")}, ...${requestParametersName}.headers },`;
}
if (operation.operation.bodyParam === undefined && parametersImplementation.body.length) {
paramStr = `${paramStr}\nbody: {${parametersImplementation.body
Expand All @@ -1473,7 +1488,7 @@ function getHeaderAndBodyParameters(
} else if (operation.operation.bodyParam !== undefined) {
paramStr = `${paramStr}${buildBodyParameter(dpgContext, operation.operation.bodyParam)}`;
}
return paramStr;
return { value: paramStr, hasHeaders: parametersImplementation.header.length > 0 };
}

// Specially handle the type for headers because we only allow string/number/boolean values
Expand Down
120 changes: 120 additions & 0 deletions packages/typespec-ts/test/modular-unit/client-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { afterAll, assert, describe, it } from "vitest";

import { Project } from "ts-morph";
import { buildGetClientOptionsParam } from "../../src/modular/helpers/client-helpers.js";
import type { ModularEmitterOptions } from "../../src/modular/interfaces.js";
import {
emitModularClientContextFromTypeSpec,
emitModularOperationsFromTypeSpec,
} from "../util/emit-util.js";
import { clearCompileCache } from "../util/test-util.js";

afterAll(clearCompileCache);

describe("client option forwarding", () => {
const credentialSpec = `
import "@typespec/http";
import "@typespec/rest";
import "@azure-tools/typespec-azure-core";

using TypeSpec.Http;
using TypeSpec.Rest;
using Azure.Core;

@useAuth(
OAuth2Auth<[
{
type: OAuth2FlowType.implicit,
authorizationUrl: "https://login.microsoftonline.com/common/oauth2/authorize",
scopes: ["https://example.com/.default"],
}
]>
)
@service(#{ title: "ScopeClient" })
@server("{endpoint}", "Service endpoint", { endpoint: url })
namespace ScopeService;

@route("/read")
@get
op read(): void;
`;

it("merges normalized operation headers with generated service headers", async () => {
const result = await emitModularOperationsFromTypeSpec(`
@route("/read")
@get
op read(@header("x-service-header") serviceHeader: string): void;
`);
const text = result![0]!.getFullText();

assert.include(text, "const requestParameters = operationOptionsToRequestParameters(options);");
assert.include(text, '"x-service-header": serviceHeader');
// The core helper folds deprecated customHeaders into this normalized headers bag.
assert.include(text, "...requestParameters.headers");
assert.notInclude(text, "...options.requestOptions?.headers");
});

it("normalizes legacy credential scopes after current credential options", () => {
const text = emitGetClientOptions();
const currentScopes = text.indexOf("options.credentials?.scopes");
const legacyStringScopes = text.indexOf('typeof options.credentialScopes === "string"');
const legacyArrayScopes = text.indexOf(": options.credentialScopes");
const generatedScopes = text.indexOf('"https://example.com/.default"');

assert.isAtLeast(currentScopes, 0);
assert.isAbove(legacyStringScopes, currentScopes);
assert.include(text, "? [options.credentialScopes]");
assert.isAbove(legacyArrayScopes, legacyStringScopes);
assert.isAbove(generatedScopes, legacyArrayScopes);
});

it("declares the deprecated credential scopes alias on scoped client options only", async () => {
const scopedResult = await emitModularClientContextFromTypeSpec(credentialSpec, {
"add-credentials": true,
"credential-scopes": ["https://example.com/.default"],
});
const scopedText = scopedResult!.getFullText();

assert.include(
scopedText,
"export interface ScopeServiceClientOptionalParams extends ClientOptions",
);
assert.include(scopedText, "@deprecated Use `credentials.scopes` instead.");
assert.include(scopedText, "credentialScopes?: string | string[];");

const unscopedResult = await emitModularClientContextFromTypeSpec(`
@route("/read")
@get
op read(): void;
`);
assert.notInclude(unscopedResult!.getFullText(), "credentialScopes");
});

it("preserves logging header and query allowlists while defaulting the logger", () => {
const text = emitGetClientOptions();

assert.include(
text,
"loggingOptions: { ...options.loggingOptions, logger: options.loggingOptions?.logger ?? logger.info }",
);
});
});

function emitGetClientOptions(): string {
const project = new Project({ useInMemoryFileSystem: true });
const factory = project.createSourceFile("client.ts").addFunction({ name: "createClient" });
const emitterOptions: ModularEmitterOptions = {
options: {
addCredentials: true,
credentialScopes: ["https://example.com/.default"],
},
modularOptions: {
sourceRoot: "",
compatibilityMode: false,
experimentalExtensibleEnums: false,
},
};

buildGetClientOptionsParam(factory, emitterOptions, "endpoint");
return factory.getText();
}
Original file line number Diff line number Diff line change
Expand Up @@ -892,9 +892,10 @@ export function _readSend(
context: Client,
options: ReadOptionalParams = { requestOptions: {} },
): StreamableMethod {
const requestParameters = operationOptionsToRequestParameters(options);
return context.path("/").get({
...operationOptionsToRequestParameters(options),
headers: { accept: "application/json", ...options.requestOptions?.headers },
...requestParameters,
headers: { accept: "application/json", ...requestParameters.headers },
});
}

Expand Down Expand Up @@ -962,9 +963,10 @@ export function _readSend(
context: Client,
options: ReadOptionalParams = { requestOptions: {} },
): StreamableMethod {
const requestParameters = operationOptionsToRequestParameters(options);
return context.path("/").get({
...operationOptionsToRequestParameters(options),
headers: { accept: "application/json", ...options.requestOptions?.headers },
...requestParameters,
headers: { accept: "application/json", ...requestParameters.headers },
});
}

Expand Down Expand Up @@ -1049,9 +1051,10 @@ export function _readSend(
context: Client,
options: ReadOptionalParams = { requestOptions: {} },
): StreamableMethod {
const requestParameters = operationOptionsToRequestParameters(options);
return context.path("/").get({
...operationOptionsToRequestParameters(options),
headers: { accept: "application/json", ...options.requestOptions?.headers },
...requestParameters,
headers: { accept: "application/json", ...requestParameters.headers },
});
}

Expand Down Expand Up @@ -1209,9 +1212,10 @@ export function _readSend(
context: Client,
options: ReadOptionalParams = { requestOptions: {} },
): StreamableMethod {
const requestParameters = operationOptionsToRequestParameters(options);
return context.path("/").get({
...operationOptionsToRequestParameters(options),
headers: { accept: "application/json", ...options.requestOptions?.headers },
...requestParameters,
headers: { accept: "application/json", ...requestParameters.headers },
});
}

Expand Down Expand Up @@ -1424,9 +1428,10 @@ export function _readSend(
context: Client,
options: ReadOptionalParams = { requestOptions: {} },
): StreamableMethod {
const requestParameters = operationOptionsToRequestParameters(options);
return context.path("/").get({
...operationOptionsToRequestParameters(options),
headers: { accept: "application/json", ...options.requestOptions?.headers },
...requestParameters,
headers: { accept: "application/json", ...requestParameters.headers },
});
}

Expand Down
Loading
Loading