Skip to content

Commit 6ea2607

Browse files
committed
feat(middleware-user-agent): add injectUserAgent plugin
1 parent 4423ba9 commit 6ea2607

File tree

8 files changed

+170
-10
lines changed

8 files changed

+170
-10
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const base = require("../../jest.config.base.js");
2+
3+
module.exports = {
4+
...base,
5+
};

packages/middleware-user-agent/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"tslib": "^1.8.0"
2323
},
2424
"devDependencies": {
25+
"@aws-sdk/middleware-stack": "3.0.0",
2526
"@aws-sdk/types": "3.0.0",
2627
"@types/jest": "^26.0.4",
2728
"jest": "^26.1.0",
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const USER_AGENT = "user-agent";
2+
3+
export const X_AMZ_USER_AGENT = "x-amz-user-agent";
4+
5+
export const SPACE = " ";
6+
7+
export const UA_ESCAPE_REGEX = /[^\!\#\$\%\&\'\*\+\-\.\^\_\`\|\~\d\w]/g;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from "./configurations";
22
export * from "./user-agent-middleware";
3+
export * from "./inject-ua-plugin";
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { constructStack } from "@aws-sdk/middleware-stack";
2+
import { Handler } from "@aws-sdk/types";
3+
4+
import { injectUserAgent } from "./inject-ua-plugin";
5+
6+
describe("injectUserAgent", () => {
7+
it("should inject user agent to handler execution context", async () => {
8+
const stack = constructStack();
9+
const addSpy = jest.spyOn(stack, "add");
10+
stack.use(
11+
injectUserAgent({
12+
name: "foo-api",
13+
version: "1.0.0",
14+
userAgentIdentifier: "FooApi",
15+
})
16+
);
17+
stack.add(
18+
(next, context) => (args) => {
19+
// @ts-ignore
20+
args.input.context = context;
21+
return next(args);
22+
},
23+
{ step: "build" }
24+
);
25+
const mockCoreHandler: Handler<object, object> = jest.fn();
26+
const handler = stack.resolve(mockCoreHandler, {});
27+
await handler({ input: {} });
28+
expect(mockCoreHandler).toHaveBeenCalled();
29+
expect((mockCoreHandler as jest.Mock).mock.calls[0][0]?.input?.context).toMatchObject({
30+
userAgent: [["foo-api", "1.0.0"]],
31+
});
32+
expect(addSpy.mock.calls[0][1]).toMatchObject({
33+
name: "injectFooApiUAMiddleware",
34+
step: "initialize",
35+
tags: ["SET_USER_AGENT", "USER_AGENT"],
36+
});
37+
});
38+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { MiddlewareStack, Pluggable } from "@aws-sdk/types";
2+
3+
export interface InjectUserAgentOptions {
4+
/**
5+
* The aame of the user agent to be injected to the request, e.g. a dependency name.
6+
*/
7+
name: string;
8+
/**
9+
* The value of the user agent to be injected to the request, e.g. a dependency version.
10+
*/
11+
version?: string;
12+
/**
13+
* The identifier of the middleware that injecting this user agent pair to the handler execution context.
14+
* The middleware name will be `inject${userAgentIdentifier}UAMiddleware`. You can use this name to remove
15+
* this middleware.
16+
*/
17+
userAgentIdentifier?: string;
18+
}
19+
20+
/**
21+
* A helper plugin that inject user agent pair.
22+
*/
23+
export const injectUserAgent = (options: InjectUserAgentOptions): Pluggable<any, any> => ({
24+
applyToStack: (stack: MiddlewareStack<any, any>) => {
25+
stack.add(
26+
(next, context) => (args) => {
27+
if (!context.userAgent) context.userAgent = [];
28+
context.userAgent.push([options.name, options.version]);
29+
return next(args);
30+
},
31+
{
32+
step: "initialize",
33+
name: options.userAgentIdentifier ? `inject${options.userAgentIdentifier}UAMiddleware` : undefined,
34+
tags: ["SET_USER_AGENT", "USER_AGENT"],
35+
}
36+
);
37+
},
38+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { HttpRequest } from "@aws-sdk/protocol-http";
2+
import { UserAgentPair } from "@aws-sdk/types";
3+
4+
import { USER_AGENT, X_AMZ_USER_AGENT } from "./constants";
5+
import { userAgentMiddleware } from "./user-agent-middleware";
6+
7+
describe("userAgentMiddleware", () => {
8+
const mockNextHandler = jest.fn();
9+
10+
beforeEach(() => {
11+
jest.clearAllMocks();
12+
});
13+
14+
it("should collect user agent pair from default, custom-supplied, and handler context", async () => {
15+
const middleware = userAgentMiddleware({
16+
defaultUserAgent: async () => [
17+
["default_agent", "1.0.0"],
18+
["aws-sdk-js", "1.0.0"],
19+
],
20+
customUserAgent: [["custom_ua/abc"]],
21+
runtime: "node",
22+
});
23+
const handler = middleware(mockNextHandler, { userAgent: [["cfg/retry-mode", "standard"]] });
24+
await handler({ input: {}, request: new HttpRequest({ headers: {} }) });
25+
expect(mockNextHandler.mock.calls.length).toEqual(1);
26+
const sdkUserAgent = mockNextHandler.mock.calls[0][0].request.headers[X_AMZ_USER_AGENT];
27+
expect(sdkUserAgent).toEqual(expect.stringContaining("aws-sdk-js/1.0.0"));
28+
expect(sdkUserAgent).toEqual(expect.stringContaining("default_agent/1.0.0"));
29+
expect(sdkUserAgent).toEqual(expect.stringContaining("custom_ua/abc"));
30+
expect(sdkUserAgent).toEqual(expect.stringContaining("cfg/retry-mode/standard"));
31+
expect(mockNextHandler.mock.calls[0][0].request.headers[USER_AGENT]).toEqual(
32+
expect.stringContaining("aws-sdk-js/1.0.0")
33+
);
34+
});
35+
36+
it(`should not set ${USER_AGENT} header in browser`, async () => {
37+
const middleware = userAgentMiddleware({
38+
defaultUserAgent: async () => [["aws-sdk-js", "1.0.0"]],
39+
runtime: "browser",
40+
});
41+
const handler = middleware(mockNextHandler, {});
42+
await handler({ input: {}, request: new HttpRequest({ headers: {} }) });
43+
expect(mockNextHandler.mock.calls.length).toEqual(1);
44+
expect(mockNextHandler.mock.calls[0][0].request.headers[USER_AGENT]).toBeUndefined();
45+
});
46+
47+
describe("should sanitize the user agent string", () => {
48+
const cases: { ua: UserAgentPair; expected: string }[] = [
49+
{ ua: ["/name", "1.0.0"], expected: "name/1.0.0" },
50+
{ ua: ["Name", "1.0.0"], expected: "Name/1.0.0" },
51+
{ ua: ["md/name", "1.0.0"], expected: "md/name/1.0.0" },
52+
{ ua: ["$prefix/&name", "1.0.0"], expected: "$prefix/&name/1.0.0" },
53+
{ ua: ["name(or not)", "1.0.0"], expected: "name_or_not_/1.0.0" },
54+
{ ua: ["name", "1.0.0(test_version)"], expected: "name/1.0.0_test_version" },
55+
];
56+
for (const { ua, expected } of cases) {
57+
it(`should sanitize user agent ${ua} to ${expected}`, async () => {
58+
const middleware = userAgentMiddleware({
59+
defaultUserAgent: async () => [ua],
60+
runtime: "browser",
61+
});
62+
const handler = middleware(mockNextHandler, {});
63+
await handler({ input: {}, request: new HttpRequest({ headers: {} }) });
64+
expect(mockNextHandler.mock.calls[0][0].request.headers[X_AMZ_USER_AGENT]).toEqual(
65+
expect.stringContaining(expected)
66+
);
67+
});
68+
}
69+
});
70+
});

packages/middleware-user-agent/src/user-agent-middleware.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,7 @@ import {
1111
} from "@aws-sdk/types";
1212

1313
import { UserAgentResolvedConfig } from "./configurations";
14-
15-
const USER_AGENT = "user-agent";
16-
const X_AMZ_USER_AGENT = "x-amz-user-agent";
17-
const SPACE = " ";
18-
19-
const UA_ESCAPE_REGEX = /[^\!\#\$\%\&\'\*\+\-\.\^\_\`\|\~\d\w]/g;
14+
import { SPACE, UA_ESCAPE_REGEX, USER_AGENT, X_AMZ_USER_AGENT } from "./constants";
2015

2116
/**
2217
* Build user agent header sections from:
@@ -59,13 +54,18 @@ export const userAgentMiddleware = (options: UserAgentResolvedConfig) => <Output
5954

6055
/**
6156
* Escape the each pair according to https://tools.ietf.org/html/rfc5234 and join the pair with pattern `name/version`.
57+
* User agent name may include prefix like `md/`, `api/`, `os/` etc., we should not escape the `/` after the prefix.
6258
* @private
6359
*/
64-
export const escapeUserAgent = (pair: UserAgentPair): string =>
65-
pair
66-
.filter((item) => item !== undefined)
67-
.map((item) => item.replace(UA_ESCAPE_REGEX, "_"))
60+
export const escapeUserAgent = ([name, version]: UserAgentPair): string => {
61+
const prefixSeparatorIndex = name.indexOf("/");
62+
const prefix = name.substring(0, prefixSeparatorIndex); // If no prefix, prefix is just ""
63+
const uaName = name.substring(prefixSeparatorIndex + 1);
64+
return [prefix, uaName, version]
65+
.filter((item) => item && item.length > 0)
66+
.map((item) => item?.replace(UA_ESCAPE_REGEX, "_"))
6867
.join("/");
68+
};
6969

7070
export const getUserAgentMiddlewareOptions: BuildHandlerOptions = {
7171
name: "getUserAgentMiddleware",

0 commit comments

Comments
 (0)