Skip to content

Commit b5da159

Browse files
feat(middleware-bucket-endpoint): generate endpoint from Access Point ARN (#1441)
* feat(middleware-bucket-endpoint): validate access point arn * feat(middleware-bucket-endpoint): implement hostname population from ARN * feat(middleware-bucket-endpoint): refactor and add unit tests * feat(middleware-bucket-endpoint): refactor middleware implementation and unit tests * feat(middleware-bucket-endpoint): set region from arn to handler context * fix(middleware-bucket-endpoint): address feedbacks * test(middleware-bucket-endpoint): fix unit test Co-authored-by: Trivikram Kamat <[email protected]>
1 parent d12f9ba commit b5da159

File tree

7 files changed

+728
-291
lines changed

7 files changed

+728
-291
lines changed

packages/middleware-bucket-endpoint/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"dependencies": {
2121
"@aws-sdk/protocol-http": "1.0.0-gamma.5",
2222
"@aws-sdk/types": "1.0.0-gamma.4",
23+
"@aws-sdk/util-arn-parser": "1.0.0-gamma.1",
2324
"tslib": "^1.8.0"
2425
},
2526
"devDependencies": {
@@ -29,4 +30,4 @@
2930
"jest": "^26.1.0",
3031
"typescript": "~3.9.3"
3132
}
32-
}
33+
}
Lines changed: 155 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
1-
import { constructStack } from "@aws-sdk/middleware-stack";
21
import { HttpRequest } from "@aws-sdk/protocol-http";
32

4-
import { bucketEndpointMiddleware, bucketEndpointMiddlewareOptions } from "./bucketEndpointMiddleware";
53
import { resolveBucketEndpointConfig } from "./configurations";
64

5+
const mockBucketHostname = jest.fn();
6+
jest.mock("./bucketHostname", () => ({
7+
bucketHostname: mockBucketHostname,
8+
}));
9+
const mockBucketArn = "an ARN structure";
10+
const mockArnParse = jest.fn().mockReturnValue(mockBucketArn);
11+
const mockArnValidation = jest.fn();
12+
jest.mock("@aws-sdk/util-arn-parser", () => ({
13+
parse: mockArnParse,
14+
validate: mockArnValidation,
15+
}));
16+
17+
import { bucketEndpointMiddleware } from "./bucketEndpointMiddleware";
18+
719
describe("bucketEndpointMiddleware", () => {
820
const input = { Bucket: "bucket" };
921
const requestInput = {
@@ -14,140 +26,160 @@ describe("bucketEndpointMiddleware", () => {
1426
path: "/bucket",
1527
};
1628
const next = jest.fn();
29+
const previouslyResolvedConfig = {
30+
region: jest.fn().mockResolvedValue("us-foo-1"),
31+
regionInfoProvider: jest
32+
.fn()
33+
.mockResolvedValue({ hostname: "foo.us-foo-2.amazonaws.com", partition: "aws-foo", signingRegion: "us-foo-1" }),
34+
useArnRegion: jest.fn().mockResolvedValue(false),
35+
};
1736

18-
beforeEach(() => {
19-
next.mockClear();
20-
});
21-
22-
it("should convert the request provided into one directed to a virtual hosted-style endpoint", async () => {
23-
const request = new HttpRequest(requestInput);
24-
const handler = bucketEndpointMiddleware(resolveBucketEndpointConfig({}))(next, {} as any);
25-
await handler({ input, request });
26-
27-
const {
28-
input: forwardedInput,
29-
request: { hostname, path },
30-
} = next.mock.calls[0][0];
31-
32-
expect(forwardedInput).toBe(input);
33-
expect(hostname).toBe("bucket.s3.us-west-2.amazonaws.com");
34-
expect(path).toBe("/");
35-
});
36-
37-
it("should not convert the request provided into one directed to a virtual hosted-style endpoint if so configured", async () => {
38-
const request = new HttpRequest(requestInput);
39-
const handler = bucketEndpointMiddleware(
40-
resolveBucketEndpointConfig({
41-
forcePathStyle: true,
42-
})
43-
)(next, {} as any);
44-
await handler({ input, request });
45-
46-
const {
47-
input: forwardedInput,
48-
request: { hostname, path },
49-
} = next.mock.calls[0][0];
50-
51-
expect(forwardedInput).toBe(input);
52-
expect(hostname).toBe("s3.us-west-2.amazonaws.com");
53-
expect(path).toBe("/bucket");
37+
afterEach(() => {
38+
mockArnValidation.mockClear();
39+
mockBucketHostname.mockClear();
5440
});
5541

56-
it("should use the bucket name as a virtual hosted-style endpoint if so configured", async () => {
57-
const request = new HttpRequest(requestInput);
58-
const handler = bucketEndpointMiddleware(
59-
resolveBucketEndpointConfig({
42+
describe("with regular bucket name", () => {
43+
beforeEach(() => {
44+
mockBucketHostname.mockReturnValue({
6045
bucketEndpoint: true,
61-
})
62-
)(next, {} as any);
63-
await handler({
64-
input: { Bucket: "files.domain.com" },
65-
request: { ...request, path: "/files.domain.com/path/to/key.ext" },
46+
hostname: "bucket.s3.us-west-2.amazonaws.com",
47+
});
6648
});
6749

68-
const {
69-
request: { hostname, path },
70-
} = next.mock.calls[0][0];
71-
72-
expect(hostname).toBe("files.domain.com");
73-
expect(path).toBe("/path/to/key.ext");
74-
});
75-
76-
it("should use a transfer acceleration endpoint if so configured", async () => {
77-
const request = new HttpRequest(requestInput);
78-
const handler = bucketEndpointMiddleware(
79-
resolveBucketEndpointConfig({
80-
useAccelerateEndpoint: true,
81-
})
82-
)(next, {} as any);
83-
await handler({ input, request });
84-
85-
const {
86-
input: forwardedInput,
87-
request: { hostname, path },
88-
} = next.mock.calls[0][0];
50+
it("should supply default parameters to bucket hostname constructor", async () => {
51+
const request = new HttpRequest(requestInput);
52+
mockArnValidation.mockReturnValue(false);
53+
const handler = bucketEndpointMiddleware(
54+
resolveBucketEndpointConfig({
55+
...previouslyResolvedConfig,
56+
})
57+
)(next, {} as any);
58+
await handler({ input, request });
59+
expect(mockBucketHostname).toBeCalled();
60+
const param = mockBucketHostname.mock.calls[0][0];
61+
expect(param).toEqual({
62+
bucketName: input.Bucket,
63+
baseHostname: requestInput.hostname,
64+
accelerateEndpoint: false,
65+
dualstackEndpoint: false,
66+
pathStyleEndpoint: false,
67+
tlsCompatible: true,
68+
});
69+
});
8970

90-
expect(forwardedInput).toBe(input);
91-
expect(hostname).toBe("bucket.s3-accelerate.amazonaws.com");
92-
expect(path).toBe("/");
71+
it("should relay parameters to bucket hostname constructor", async () => {
72+
const request = new HttpRequest({ ...requestInput, protocol: "http:" });
73+
mockArnValidation.mockReturnValue(false);
74+
const handler = bucketEndpointMiddleware(
75+
resolveBucketEndpointConfig({
76+
...previouslyResolvedConfig,
77+
useAccelerateEndpoint: true,
78+
useDualstackEndpoint: true,
79+
forcePathStyle: true,
80+
})
81+
)(next, {} as any);
82+
await handler({ input, request });
83+
expect(mockBucketHostname).toBeCalled();
84+
const param = mockBucketHostname.mock.calls[0][0];
85+
expect(param).toEqual({
86+
bucketName: input.Bucket,
87+
baseHostname: requestInput.hostname,
88+
accelerateEndpoint: true,
89+
dualstackEndpoint: true,
90+
pathStyleEndpoint: true,
91+
tlsCompatible: false,
92+
});
93+
});
9394
});
9495

95-
it("should use a dualstack endpoint if so configured", async () => {
96-
const request = new HttpRequest(requestInput);
97-
const handler = bucketEndpointMiddleware(
98-
resolveBucketEndpointConfig({
99-
useDualstackEndpoint: true,
100-
})
101-
)(next, {} as any);
102-
await handler({ input, request });
103-
104-
const {
105-
input: forwardedInput,
106-
request: { hostname, path },
107-
} = next.mock.calls[0][0];
108-
109-
expect(forwardedInput).toBe(input);
110-
expect(hostname).toBe("bucket.s3.dualstack.us-west-2.amazonaws.com");
111-
expect(path).toBe("/");
112-
});
96+
describe("allows bucket name to be an ARN", () => {
97+
beforeEach(() => {
98+
mockArnValidation.mockReturnValue(true);
99+
mockBucketHostname.mockReturnValue({
100+
bucketEndpoint: true,
101+
hostname: "myendpoint-123456789012.s3-accesspoint.us-west-2.amazonaws.com",
102+
});
103+
});
113104

114-
it("should use an accelerate dualstack endpoint if configured", async () => {
115-
const request = new HttpRequest(requestInput);
116-
const handler = bucketEndpointMiddleware(
117-
resolveBucketEndpointConfig({
118-
useAccelerateEndpoint: true,
119-
useDualstackEndpoint: true,
120-
})
121-
)(next, {} as any);
122-
await handler({ input, request });
105+
it("should relay parameters to bucket hostname constructor", async () => {
106+
const request = new HttpRequest(requestInput);
107+
const handler = bucketEndpointMiddleware(
108+
resolveBucketEndpointConfig({
109+
...previouslyResolvedConfig,
110+
})
111+
)(next, {} as any);
112+
await handler({
113+
input: { Bucket: "myendpoint-123456789012.s3-accesspoint.us-west-2.amazonaws.com" },
114+
request,
115+
});
116+
expect(mockBucketHostname).toBeCalled();
117+
const param = mockBucketHostname.mock.calls[0][0];
118+
expect(param).toEqual({
119+
bucketName: mockBucketArn,
120+
baseHostname: requestInput.hostname,
121+
accelerateEndpoint: false,
122+
dualstackEndpoint: false,
123+
pathStyleEndpoint: false,
124+
tlsCompatible: true,
125+
clientPartition: "aws-foo",
126+
clientSigningRegion: "us-foo-1",
127+
useArnRegion: false,
128+
});
129+
expect(previouslyResolvedConfig.region).toBeCalled();
130+
expect(previouslyResolvedConfig.regionInfoProvider).toBeCalled();
131+
expect(previouslyResolvedConfig.useArnRegion).toBeCalled();
132+
});
123133

124-
const {
125-
request: { hostname, path },
126-
} = next.mock.calls[0][0];
134+
it("should get client partition and signing region with pseudo region", async () => {
135+
const request = new HttpRequest(requestInput);
136+
const handler = bucketEndpointMiddleware(
137+
resolveBucketEndpointConfig({
138+
...previouslyResolvedConfig,
139+
region: () => Promise.resolve("fips-us-foo-1"),
140+
})
141+
)(next, {} as any);
142+
await handler({
143+
input: { Bucket: "myendpoint-123456789012.s3-accesspoint.us-west-2.amazonaws.com" },
144+
request,
145+
});
146+
expect(previouslyResolvedConfig.regionInfoProvider).toBeCalled();
147+
expect(previouslyResolvedConfig.regionInfoProvider.mock.calls[0][0]).toBe("us-foo-1");
148+
});
127149

128-
expect(hostname).toBe("bucket.s3-accelerate.dualstack.amazonaws.com");
129-
expect(path).toBe("/");
130-
});
150+
it("should supply bucketHostname in ARN object if bucket name string is a valid ARN", async () => {
151+
const request = new HttpRequest(requestInput);
152+
const handler = bucketEndpointMiddleware(
153+
resolveBucketEndpointConfig({
154+
...previouslyResolvedConfig,
155+
})
156+
)(next, {} as any);
157+
await handler({
158+
input: { Bucket: "myendpoint-123456789012.s3-accesspoint.us-west-2.amazonaws.com" },
159+
request,
160+
});
161+
expect(mockBucketHostname).toBeCalled();
162+
expect(mockBucketHostname.mock.calls[0][0].bucketName).toBe(mockBucketArn);
163+
expect(mockArnParse).toBeCalled();
164+
expect(mockArnValidation).toBeCalled();
165+
});
131166

132-
it("should be inserted before 'hostheaderMiddleware' if exists", async () => {
133-
const stack = constructStack();
134-
const mockHostheaderMiddleware = (next: any) => (args: any) => {
135-
args.request.arr.push("two");
136-
return next(args);
137-
};
138-
const mockbucketEndpointMiddleware = (next: any) => (args: any) => {
139-
args.request.arr.push("one");
140-
return next(args);
141-
};
142-
stack.add(mockHostheaderMiddleware, {
143-
...bucketEndpointMiddlewareOptions,
144-
name: bucketEndpointMiddlewareOptions.toMiddleware,
167+
it("should set signing_region to middleware context if the request will use region from ARN", async () => {
168+
const request = new HttpRequest(requestInput);
169+
previouslyResolvedConfig.useArnRegion.mockReturnValue(true);
170+
const arnRegion = "us-west-2";
171+
mockArnParse.mockReturnValue({ region: arnRegion });
172+
const handlerContext = {} as any;
173+
const handler = bucketEndpointMiddleware(
174+
resolveBucketEndpointConfig({
175+
...previouslyResolvedConfig,
176+
})
177+
)(next, handlerContext);
178+
await handler({
179+
input: { Bucket: `myendpoint-123456789012.s3-accesspoint.${arnRegion}.amazonaws.com` },
180+
request,
181+
});
182+
expect(handlerContext).toMatchObject({ signing_region: arnRegion });
145183
});
146-
stack.addRelativeTo(mockbucketEndpointMiddleware, bucketEndpointMiddlewareOptions);
147-
const handler = stack.resolve(next, {} as any);
148-
expect.assertions(2);
149-
await handler({ request: { arr: [] }, input: {} } as any);
150-
expect(next.mock.calls.length).toBe(1);
151-
expect(next.mock.calls[0][0].request.arr).toEqual(["one", "two"]);
152184
});
153185
});

packages/middleware-bucket-endpoint/src/bucketEndpointMiddleware.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,53 @@ import {
44
BuildHandlerArguments,
55
BuildHandlerOutput,
66
BuildMiddleware,
7+
HandlerExecutionContext,
78
MetadataBearer,
89
Pluggable,
910
RelativeMiddlewareOptions,
1011
} from "@aws-sdk/types";
12+
import { parse as parseArn, validate as validateArn } from "@aws-sdk/util-arn-parser";
1113

1214
import { bucketHostname } from "./bucketHostname";
15+
import { getPseudoRegion } from "./bucketHostnameUtils";
1316
import { BucketEndpointResolvedConfig } from "./configurations";
1417

1518
export function bucketEndpointMiddleware(options: BucketEndpointResolvedConfig): BuildMiddleware<any, any> {
16-
return <Output extends MetadataBearer>(next: BuildHandler<any, Output>): BuildHandler<any, Output> => async (
17-
args: BuildHandlerArguments<any>
18-
): Promise<BuildHandlerOutput<Output>> => {
19-
const { Bucket: bucketName } = args.input;
19+
return <Output extends MetadataBearer>(
20+
next: BuildHandler<any, Output>,
21+
context: HandlerExecutionContext
22+
): BuildHandler<any, Output> => async (args: BuildHandlerArguments<any>): Promise<BuildHandlerOutput<Output>> => {
23+
const { Bucket: bucketName } = args.input as { Bucket: string };
2024
let replaceBucketInPath = options.bucketEndpoint;
2125
const request = args.request;
2226
if (HttpRequest.isInstance(request)) {
2327
if (options.bucketEndpoint) {
2428
request.hostname = bucketName;
29+
} else if (validateArn(bucketName)) {
30+
const bucketArn = parseArn(bucketName);
31+
const clientRegion = getPseudoRegion(await options.region());
32+
const { partition, signingRegion } = (await options.regionInfoProvider(clientRegion)) || {};
33+
const useArnRegion = await options.useArnRegion();
34+
const { hostname, bucketEndpoint } = bucketHostname({
35+
bucketName: bucketArn,
36+
baseHostname: request.hostname,
37+
accelerateEndpoint: options.useAccelerateEndpoint,
38+
dualstackEndpoint: options.useDualstackEndpoint,
39+
pathStyleEndpoint: options.forcePathStyle,
40+
tlsCompatible: request.protocol === "https:",
41+
useArnRegion,
42+
clientPartition: partition,
43+
clientSigningRegion: signingRegion,
44+
});
45+
46+
// If the request needs to use a region inferred from ARN that different from client region, we need to set
47+
// them in the handler context so the signer will use them
48+
if (useArnRegion && clientRegion !== bucketArn.region) {
49+
context["signing_region"] = bucketArn.region;
50+
}
51+
52+
request.hostname = hostname;
53+
replaceBucketInPath = bucketEndpoint;
2554
} else {
2655
const { hostname, bucketEndpoint } = bucketHostname({
2756
bucketName,

0 commit comments

Comments
 (0)