Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export declare class OAuth2Client {
}

export declare interface OAuth2ClientOptionalParams extends ClientOptions {
credentialScopes?: string | string[];
}

export { RestError }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export declare class UnionClient {
}

export declare interface UnionClientOptionalParams extends ClientOptions {
credentialScopes?: string | string[];
}

export declare interface ValidKeyOptionalParams extends OperationOptions {
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