Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(middleware-sdk-s3): add middleware for following region redirects #5185

Merged
merged 8 commits into from
Oct 5, 2023
4 changes: 4 additions & 0 deletions packages/middleware-sdk-s3/jest.config.e2e.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
preset: "ts-jest",
testMatch: ["**/*.e2e.spec.ts"],
};
1 change: 1 addition & 0 deletions packages/middleware-sdk-s3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"clean": "rimraf ./dist-* && rimraf *.tsbuildinfo",
"test": "jest",
"test:integration": "jest -c jest.config.integ.js",
"test:e2e": "jest -c jest.config.e2e.js",
"extract:docs": "api-extractor run --local"
},
"main": "./dist-cjs/index.js",
Expand Down
1 change: 1 addition & 0 deletions packages/middleware-sdk-s3/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./check-content-length-header";
export * from "./region-redirect-middleware";
export * from "./s3Configuration";
export * from "./throw-200-exceptions";
export * from "./validate-bucket-name";
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {
CreateBucketCommand,
DeleteBucketCommand,
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { STS } from "@aws-sdk/client-sts";

const regionConfigs = [
{ region: "us-east-1", followRegionRedirects: true },
{ region: "us-west-2", followRegionRedirects: true },
{ region: "us-west-1", followRegionRedirects: true },
];

const s3Clients = regionConfigs.map((config) => new S3Client(config));

const testValue = "Hello S3 global client!";

async function testS3GlobalClient() {
const stsClient = new STS({});

const callerID = await stsClient.getCallerIdentity({});

const bucketNames = regionConfigs.map((config) => `${callerID.Account}-redirect-testing-${config.region}`);
await Promise.all(
bucketNames.map((bucketName, index) => s3Clients[index].send(new CreateBucketCommand({ Bucket: bucketName })))
);
// Upload objects to each bucket
for (const bucketName of bucketNames) {
for (const s3Client of s3Clients) {
const objKey = `object-from-${await s3Client.config.region()}-client`;
await s3Client.send(new PutObjectCommand({ Bucket: bucketName, Key: objKey, Body: testValue }));
}
}

// Fetch, assert, and delete objects
for (const bucketName of bucketNames) {
for (const s3Client of s3Clients) {
const objKey = `object-from-${await s3Client.config.region()}-client`;
const { Body } = await s3Client.send(new GetObjectCommand({ Bucket: bucketName, Key: objKey }));
const data = await Body?.transformToString();
expect(data).toEqual(testValue);
await s3Client.send(new DeleteObjectCommand({ Bucket: bucketName, Key: objKey }));
}
}

for (let i = 0; i < s3Clients.length; ++i) {
s3Clients[i].send(new DeleteBucketCommand({ Bucket: bucketNames[(i + 1) % bucketNames.length] }));
}
}

describe("S3 Global Client Test", () => {
it("Can perform all operations cross-regionally by following region redirect", async () => {
await testS3GlobalClient();
}, 50000);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { HandlerExecutionContext } from "@smithy/types";

import { regionRedirectMiddleware } from "./region-redirect-middleware";

describe(regionRedirectMiddleware.name, () => {
const region = async () => "us-east-1";
const redirectRegion = "us-west-2";
let call = 0;
const next = (arg: any) => {
if (call === 0) {
call++;
throw Object.assign(new Error(), {
Code: "PermanentRedirect",
$metadata: { httpStatusCode: 301 },
$response: { headers: { "x-amz-bucket-region": redirectRegion } },
});
}
return null as any;
};

beforeEach(() => {
call = 0;
});

it("set S3 region redirect on context if receiving a PermanentRedirect error code with status 301", async () => {
const middleware = regionRedirectMiddleware({ region, followRegionRedirects: true });
const context = {} as HandlerExecutionContext;
const handler = middleware(next, context);
await handler({ input: null });
expect(context.__s3RegionRedirect).toEqual(redirectRegion);
});

it("does not follow the redirect when followRegionRedirects is false", async () => {
const middleware = regionRedirectMiddleware({ region, followRegionRedirects: false });
const context = {} as HandlerExecutionContext;
const handler = middleware(next, context);
// Simulating a PermanentRedirect error with status 301
await expect(async () => {
await handler({ input: null });
}).rejects.toThrowError(
Object.assign(new Error(), {
Code: "PermanentRedirect",
$metadata: { httpStatusCode: 301 },
$response: { headers: { "x-amz-bucket-region": redirectRegion } },
})
);
// Ensure that context.__s3RegionRedirect is not set
expect(context.__s3RegionRedirect).toBeUndefined();
});
});
117 changes: 117 additions & 0 deletions packages/middleware-sdk-s3/src/region-redirect-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {
HandlerExecutionContext,
InitializeHandler,
InitializeHandlerArguments,
InitializeHandlerOptions,
InitializeHandlerOutput,
InitializeMiddleware,
MetadataBearer,
Pluggable,
Provider,
RelativeMiddlewareOptions,
SerializeHandler,
SerializeHandlerArguments,
SerializeHandlerOutput,
SerializeMiddleware,
} from "@smithy/types";

/**
* @internal
*/
interface PreviouslyResolved {
region: Provider<string>;
followRegionRedirects: boolean;
}

/**
* @internal
*/
export function regionRedirectMiddleware(clientConfig: PreviouslyResolved): InitializeMiddleware<any, any> {
return <Output extends MetadataBearer>(
next: InitializeHandler<any, Output>,
context: HandlerExecutionContext
): InitializeHandler<any, Output> =>
async (args: InitializeHandlerArguments<any>): Promise<InitializeHandlerOutput<Output>> => {
try {
return next(args);
} catch (err) {
if (
clientConfig.followRegionRedirects &&
err.Code === "PermanentRedirect" &&
siddsriv marked this conversation as resolved.
Show resolved Hide resolved
err.$metadata.httpStatusCode === 301
) {
try {
const actualRegion = err.$response.headers["x-amz-bucket-region"];
context.logger?.debug(`Redirecting from ${await clientConfig.region()} to ${actualRegion}`);
context.__s3RegionRedirect = actualRegion;
} catch (e) {
throw new Error("Region redirect failed: " + e);
}
return next(args);
} else {
throw err;
}
}
};
}

/**
* @internal
*/
export const regionRedirectEndpointMiddleware = (config: PreviouslyResolved): SerializeMiddleware<any, any> => {
siddsriv marked this conversation as resolved.
Show resolved Hide resolved
return <Output extends MetadataBearer>(
next: SerializeHandler<any, Output>,
context: HandlerExecutionContext
): SerializeHandler<any, Output> =>
async (args: SerializeHandlerArguments<any>): Promise<SerializeHandlerOutput<Output>> => {
const originalRegion = await config.region();
if (context.__s3RegionRedirect) {
const regionProviderRef = config.region;
config.region = async () => {
config.region = regionProviderRef;
return context.__s3RegionRedirect;
};
}
const result = await next({
...args,
});
siddsriv marked this conversation as resolved.
Show resolved Hide resolved
if (context.__s3RegionRedirect) {
const region = await config.region();
if (originalRegion !== region) {
throw new Error("Region was not restored following S3 region redirect.");
}
}
return result;
};
};

/**
* @internal
*/
export const regionRedirectMiddlewareOptions: InitializeHandlerOptions = {
step: "initialize",
tags: ["REGION_REDIRECT", "S3"],
name: "regionRedirectMiddleware",
override: true,
};

/**
* @internal
*/
export const regionRedirectEndpointMiddlewareOptions: RelativeMiddlewareOptions = {
tags: ["REGION_REDIRECT", "S3"],
name: "regionRedirectEndpointMiddleware",
override: true,
relation: "before",
toMiddleware: "endpointV2Middleware",
};

/**
* @internal
*/
export const getRegionRedirectMiddlewarePlugin = (clientConfig: PreviouslyResolved): Pluggable<any, any> => ({
applyToStack: (clientStack) => {
clientStack.add(regionRedirectMiddleware(clientConfig), regionRedirectMiddlewareOptions);
clientStack.addRelativeTo(regionRedirectEndpointMiddleware(clientConfig), regionRedirectEndpointMiddlewareOptions);
},
});
9 changes: 8 additions & 1 deletion packages/middleware-sdk-s3/src/s3Configuration.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* @public
*
*
* All endpoint parameters with built-in bindings of AWS::S3::*
*/
export interface S3InputConfig {
Expand All @@ -17,17 +17,24 @@ export interface S3InputConfig {
* Whether multi-region access points (MRAP) should be disabled.
*/
disableMultiregionAccessPoints?: boolean;
/**
* If you receive a permanent redirect with status 301,
* the client will retry your request with the corrected region.
*/
followRegionRedirects?: boolean;
siddsriv marked this conversation as resolved.
Show resolved Hide resolved
}

export interface S3ResolvedConfig {
forcePathStyle: boolean;
useAccelerateEndpoint: boolean;
disableMultiregionAccessPoints: boolean;
followRegionRedirects: boolean;
}

export const resolveS3Config = <T>(input: T & S3InputConfig): T & S3ResolvedConfig => ({
...input,
forcePathStyle: input.forcePathStyle ?? false,
useAccelerateEndpoint: input.useAccelerateEndpoint ?? false,
disableMultiregionAccessPoints: input.disableMultiregionAccessPoints ?? false,
followRegionRedirects: input.followRegionRedirects ?? false,
});
Loading