Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/stale-phones-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@smithy/middleware-endpoint": minor
---

handle clientContextParam collisions with builtin config keys
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, test as it } from "vitest";
import { XYZService } from "xyz";

describe("client context parameters precedence integration test", () => {
it("should handle conflicting vs non-conflicting parameter precedence correctly", async () => {
// For non-conflicting params
const clientWithNonConflicting = new XYZService({
endpoint: "https://localhost",
apiKey: async () => ({ apiKey: "test-key" }),
customParam: "user-custom-value",
clientContextParams: {
apiKey: "test-key",
customParam: "nested-custom-value",
},
});

// Verify that endpoint resolution uses the nested value over root value
const resolvedConfig = clientWithNonConflicting.config;
const effectiveCustomParam = resolvedConfig.clientContextParams?.customParam ?? resolvedConfig.customParam;
expect(effectiveCustomParam).toBe("nested-custom-value");

// For conflicting parameters
const clientWithConflicting = new XYZService({
endpoint: "https://localhost",
apiKey: async () => ({ apiKey: "auth-key" }),
clientContextParams: {
apiKey: "endpoint-key",
},
});

// Verify that both auth and endpoint contexts can coexist
const resolvedConfigConflicting = clientWithConflicting.config;

// Verify endpoint context has the nested value
expect(resolvedConfigConflicting.clientContextParams?.apiKey).toBe("endpoint-key");

// Verify auth context has the auth provider
const authIdentity = await resolvedConfigConflicting.apiKey?.();
expect(authIdentity?.apiKey).toBe("auth-key");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ describe("local model integration test for cbor eventstreams", () => {
it("should read and write cbor event streams", async () => {
const client = new XYZService({
endpoint: "https://localhost",
apiKey: async () => ({ apiKey: "test-api-key" }),
clientContextParams: {
apiKey: "test-api-key",
},
});

const body = cbor.serialize({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,31 @@ describe(createConfigValueProvider.name, () => {
expect(await createConfigValueProvider("v1", "endpoint", config)()).toEqual(sampleUrl);
expect(await createConfigValueProvider("v2", "endpoint", config)()).toEqual(sampleUrl);
});

it("should prioritize clientContextParams over direct properties", async () => {
const config = {
stage: "prod",
clientContextParams: {
stage: "beta",
},
};
expect(await createConfigValueProvider("stage", "stage", config, true)()).toEqual("beta");
});

it("should fall back to direct property when clientContextParams is not provided", async () => {
const config = {
customParam: "direct-value",
};
expect(await createConfigValueProvider("customParam", "customParam", config)()).toEqual("direct-value");
});

it("should fall back to direct property when clientContextParams exists but param is not in it", async () => {
const config = {
customParam: "direct-value",
clientContextParams: {
otherParam: "other-value",
},
};
expect(await createConfigValueProvider("customParam", "customParam", config)()).toEqual("direct-value");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,29 @@ import type { Endpoint, EndpointV2 } from "@smithy/types";
* it will most likely not contain the config
* value, but we use it as a fallback.
* @param config - container of the config values.
* @param isClientContextParam - whether this is a client context parameter.
*
* @returns async function that will resolve with the value.
*/
export const createConfigValueProvider = <Config extends Record<string, unknown>>(
configKey: string,
canonicalEndpointParamKey: string,
config: Config
config: Config,
isClientContextParam = false
) => {
const configProvider = async () => {
const configValue: unknown = config[configKey] ?? config[canonicalEndpointParamKey];
let configValue: unknown;

if (isClientContextParam) {
// For client context parameters, check clientContextParams first
const clientContextParams = config.clientContextParams as Record<string, unknown> | undefined;
const nestedValue: unknown = clientContextParams?.[canonicalEndpointParamKey];
configValue = nestedValue ?? config[configKey] ?? config[canonicalEndpointParamKey];
Copy link
Contributor

@siddsriv siddsriv Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could canonicalEndpointParamKey and configKey differ? (ApiKey -- from the ruleset vs apiKey -- from the client config) would that create any problems for the precedence lookup?

Copy link
Contributor

@siddsriv siddsriv Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for example, would this case correctly prefer the nested value?

const client = new XYZService({
  apiKey: async () => ({ apiKey: "auth-secret" }),   
  clientContextParams: {
    apiKey: "endpoint-header-key"
  }
});

if the nested value looks for the canonical name, would it find ApiKey (name in commonParams) and then fall back to the root value when it doesn't find clientContextParams.[ApiKey]?

Copy link
Contributor Author

@smilkuri smilkuri Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the current logic using canonicalEndpointParamKey. It would look for clientContextParams["ApiKey"] → not found. So it would fall back to config["apiKey"] → finds "auth-secret"
But it should have found "endpoint-header-key" from clientContextParams["apiKey"].

I think this should be const nestedValue: unknown = clientContextParams?.[configKey];

} else {
// For built-in parameters and other config properties
configValue = config[configKey] ?? config[canonicalEndpointParamKey];
}

if (typeof configValue === "function") {
return configValue();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,12 @@ export const resolveParams = async <
break;
case "clientContextParams":
case "builtInParams":
endpointParams[name] = await createConfigValueProvider<Config>(instruction.name, name, clientConfig)();
endpointParams[name] = await createConfigValueProvider<Config>(
instruction.name,
name,
clientConfig,
instruction.type !== "builtInParams"
)();
break;
case "operationContextParams":
endpointParams[name] = instruction.get(commandInput);
Expand Down
4 changes: 4 additions & 0 deletions packages/util-retry/src/retries.integ.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe("retries", () => {
it("should retry throttling and transient-error status codes", async () => {
const client = new XYZService({
endpoint: "https://localhost/nowhere",
apiKey: { apiKey: "test-api-key" },
});

requireRequestsFrom(client)
Expand Down Expand Up @@ -50,6 +51,7 @@ describe("retries", () => {
it("should retry when a retryable trait is modeled", async () => {
const client = new XYZService({
endpoint: "https://localhost/nowhere",
apiKey: { apiKey: "test-api-key" },
});

requireRequestsFrom(client)
Expand Down Expand Up @@ -80,6 +82,7 @@ describe("retries", () => {
it("should retry retryable trait with throttling", async () => {
const client = new XYZService({
endpoint: "https://localhost/nowhere",
apiKey: { apiKey: "test-api-key" },
});

requireRequestsFrom(client)
Expand Down Expand Up @@ -110,6 +113,7 @@ describe("retries", () => {
it("should not retry if the error is not modeled with retryable trait and is not otherwise retryable", async () => {
const client = new XYZService({
endpoint: "https://localhost/nowhere",
apiKey: { apiKey: "test-api-key" },
});

requireRequestsFrom(client)
Expand Down
17 changes: 6 additions & 11 deletions private/my-local-model-schema/src/XYZServiceClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,8 @@ import {
import { getContentLengthPlugin } from "@smithy/middleware-content-length";
import {
type EndpointInputConfig,
type EndpointRequiredInputConfig,
type EndpointRequiredResolvedConfig,
type EndpointResolvedConfig,
resolveEndpointConfig,
resolveEndpointRequiredConfig,
} from "@smithy/middleware-endpoint";
import {
type RetryInputConfig,
Expand Down Expand Up @@ -203,7 +200,6 @@ export type XYZServiceClientConfigType = Partial<__SmithyConfiguration<__HttpHan
ClientDefaults &
RetryInputConfig &
EndpointInputConfig<EndpointParameters> &
EndpointRequiredInputConfig &
EventStreamSerdeInputConfig &
HttpAuthSchemeInputConfig &
ClientInputEndpointParameters;
Expand All @@ -222,7 +218,6 @@ export type XYZServiceClientResolvedConfigType = __SmithyResolvedConfiguration<_
RuntimeExtensionsConfig &
RetryResolvedConfig &
EndpointResolvedConfig<EndpointParameters> &
EndpointRequiredResolvedConfig &
EventStreamSerdeResolvedConfig &
HttpAuthSchemeResolvedConfig &
ClientResolvedEndpointParameters;
Expand Down Expand Up @@ -255,19 +250,19 @@ export class XYZServiceClient extends __Client<
const _config_1 = resolveClientEndpointParameters(_config_0);
const _config_2 = resolveRetryConfig(_config_1);
const _config_3 = resolveEndpointConfig(_config_2);
const _config_4 = resolveEndpointRequiredConfig(_config_3);
const _config_5 = resolveEventStreamSerdeConfig(_config_4);
const _config_6 = resolveHttpAuthSchemeConfig(_config_5);
const _config_7 = resolveRuntimeExtensions(_config_6, configuration?.extensions || []);
this.config = _config_7;
const _config_4 = resolveEventStreamSerdeConfig(_config_3);
const _config_5 = resolveHttpAuthSchemeConfig(_config_4);
const _config_6 = resolveRuntimeExtensions(_config_5, configuration?.extensions || []);
this.config = _config_6;
this.middlewareStack.use(getSchemaSerdePlugin(this.config));
this.middlewareStack.use(getRetryPlugin(this.config));
this.middlewareStack.use(getContentLengthPlugin(this.config));
this.middlewareStack.use(
getHttpAuthSchemeEndpointRuleSetPlugin(this.config, {
httpAuthSchemeParametersProvider: defaultXYZServiceHttpAuthSchemeParametersProvider,
identityProviderConfigProvider: async (config: XYZServiceClientResolvedConfig) =>
new DefaultIdentityProviderConfig({}),
new DefaultIdentityProviderConfig({
"smithy.api#httpApiKeyAuth": config.apiKey,}),
})
);
this.middlewareStack.use(getHttpSigningPlugin(this.config));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// smithy-typescript generated code
import type { HttpAuthScheme } from "@smithy/types";
import type { ApiKeyIdentity, ApiKeyIdentityProvider, HttpAuthScheme } from "@smithy/types";

import type { XYZServiceHttpAuthSchemeProvider } from "./httpAuthSchemeProvider";

Expand All @@ -11,6 +11,8 @@ export interface HttpAuthExtensionConfiguration {
httpAuthSchemes(): HttpAuthScheme[];
setHttpAuthSchemeProvider(httpAuthSchemeProvider: XYZServiceHttpAuthSchemeProvider): void;
httpAuthSchemeProvider(): XYZServiceHttpAuthSchemeProvider;
setApiKey(apiKey: ApiKeyIdentity | ApiKeyIdentityProvider): void;
apiKey(): ApiKeyIdentity | ApiKeyIdentityProvider | undefined;
}

/**
Expand All @@ -19,6 +21,7 @@ export interface HttpAuthExtensionConfiguration {
export type HttpAuthRuntimeConfig = Partial<{
httpAuthSchemes: HttpAuthScheme[];
httpAuthSchemeProvider: XYZServiceHttpAuthSchemeProvider;
apiKey: ApiKeyIdentity | ApiKeyIdentityProvider;
}>;

/**
Expand All @@ -29,6 +32,7 @@ export const getHttpAuthExtensionConfiguration = (
): HttpAuthExtensionConfiguration => {
const _httpAuthSchemes = runtimeConfig.httpAuthSchemes!;
let _httpAuthSchemeProvider = runtimeConfig.httpAuthSchemeProvider!;
let _apiKey = runtimeConfig.apiKey;
return {
setHttpAuthScheme(httpAuthScheme: HttpAuthScheme): void {
const index = _httpAuthSchemes.findIndex((scheme) => scheme.schemeId === httpAuthScheme.schemeId);
Expand All @@ -47,6 +51,12 @@ export const getHttpAuthExtensionConfiguration = (
httpAuthSchemeProvider(): XYZServiceHttpAuthSchemeProvider {
return _httpAuthSchemeProvider;
},
setApiKey(apiKey: ApiKeyIdentity | ApiKeyIdentityProvider): void {
_apiKey = apiKey;
},
apiKey(): ApiKeyIdentity | ApiKeyIdentityProvider | undefined {
return _apiKey;
},
};
};

Expand All @@ -57,5 +67,6 @@ export const resolveHttpAuthRuntimeConfig = (config: HttpAuthExtensionConfigurat
return {
httpAuthSchemes: config.httpAuthSchemes(),
httpAuthSchemeProvider: config.httpAuthSchemeProvider(),
apiKey: config.apiKey(),
};
};
41 changes: 30 additions & 11 deletions private/my-local-model-schema/src/auth/httpAuthSchemeProvider.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
// smithy-typescript generated code
import type {
HandlerExecutionContext,
HttpAuthOption,
HttpAuthScheme,
HttpAuthSchemeParameters,
HttpAuthSchemeParametersProvider,
HttpAuthSchemeProvider,
Provider,
import { doesIdentityRequireRefresh, isIdentityExpired, memoizeIdentityProvider } from "@smithy/core";
import {
type ApiKeyIdentity,
type ApiKeyIdentityProvider,
type HandlerExecutionContext,
type HttpAuthOption,
type HttpAuthScheme,
type HttpAuthSchemeParameters,
type HttpAuthSchemeParametersProvider,
type HttpAuthSchemeProvider,
type Provider,
HttpApiKeyAuthLocation,
} from "@smithy/types";
import { getSmithyContext, normalizeProvider } from "@smithy/util-middleware";

Expand Down Expand Up @@ -41,9 +45,14 @@ export const defaultXYZServiceHttpAuthSchemeParametersProvider = async (
};
};

function createSmithyApiNoAuthHttpAuthOption(authParameters: XYZServiceHttpAuthSchemeParameters): HttpAuthOption {
function createSmithyApiHttpApiKeyAuthHttpAuthOption(authParameters: XYZServiceHttpAuthSchemeParameters): HttpAuthOption {
return {
schemeId: "smithy.api#noAuth",
schemeId: "smithy.api#httpApiKeyAuth",
signingProperties: {
name: "X-Api-Key",
in: HttpApiKeyAuthLocation.HEADER,
scheme: undefined,
},
};
}

Expand All @@ -59,7 +68,7 @@ export const defaultXYZServiceHttpAuthSchemeProvider: XYZServiceHttpAuthSchemePr
const options: HttpAuthOption[] = [];
switch (authParameters.operation) {
default: {
options.push(createSmithyApiNoAuthHttpAuthOption(authParameters));
options.push(createSmithyApiHttpApiKeyAuthHttpAuthOption(authParameters));
}
}
return options;
Expand Down Expand Up @@ -88,6 +97,10 @@ export interface HttpAuthSchemeInputConfig {
* @internal
*/
httpAuthSchemeProvider?: XYZServiceHttpAuthSchemeProvider;
/**
* The API key to use when making requests.
*/
apiKey?: ApiKeyIdentity | ApiKeyIdentityProvider;
}

/**
Expand All @@ -113,6 +126,10 @@ export interface HttpAuthSchemeResolvedConfig {
* @internal
*/
readonly httpAuthSchemeProvider: XYZServiceHttpAuthSchemeProvider;
/**
* The API key to use when making requests.
*/
readonly apiKey?: ApiKeyIdentityProvider;
}

/**
Expand All @@ -121,7 +138,9 @@ export interface HttpAuthSchemeResolvedConfig {
export const resolveHttpAuthSchemeConfig = <T>(
config: T & HttpAuthSchemeInputConfig
): T & HttpAuthSchemeResolvedConfig => {
const apiKey = memoizeIdentityProvider(config.apiKey, isIdentityExpired, doesIdentityRequireRefresh);
return Object.assign(config, {
authSchemePreference: normalizeProvider(config.authSchemePreference ?? []),
apiKey,
}) as T & HttpAuthSchemeResolvedConfig;
};
Loading