diff --git a/packages/middleware-sdk-s3/src/bucket-endpoint-middleware.ts b/packages/middleware-sdk-s3/src/bucket-endpoint-middleware.ts new file mode 100644 index 0000000000000..58d19d0eec305 --- /dev/null +++ b/packages/middleware-sdk-s3/src/bucket-endpoint-middleware.ts @@ -0,0 +1,56 @@ +import { + HandlerExecutionContext, + MetadataBearer, + RelativeMiddlewareOptions, + SerializeHandler, + SerializeHandlerArguments, + SerializeHandlerOutput, + SerializeMiddleware, +} from "@smithy/types"; + +interface PreviouslyResolved { + bucketEndpoint?: boolean; +} + +/** + * @internal + */ +export function bucketEndpointMiddleware(options: PreviouslyResolved): SerializeMiddleware { + return ( + next: SerializeHandler, + context: HandlerExecutionContext + ): SerializeHandler => + async (args: SerializeHandlerArguments): Promise> => { + if (options.bucketEndpoint) { + const endpoint = context.endpointV2; + if (endpoint) { + const bucket: string | undefined = args.input.Bucket; + if (typeof bucket === "string") { + try { + const bucketEndpointUrl = new URL(bucket); + endpoint.url = bucketEndpointUrl; + } catch (e) { + const warning = `@aws-sdk/middleware-sdk-s3: bucketEndpoint=true was set but Bucket=${bucket} could not be parsed as URL.`; + if (context.logger?.constructor?.name === "NoOpLogger") { + console.warn(warning); + } else { + context.logger?.warn?.(warning); + } + throw e; + } + } + } + } + return next(args); + }; +} + +/** + * @internal + */ +export const bucketEndpointMiddlewareOptions: RelativeMiddlewareOptions = { + name: "bucketEndpointMiddleware", + override: true, + relation: "after", + toMiddleware: "endpointV2Middleware", +}; diff --git a/packages/middleware-sdk-s3/src/middleware-sdk-s3.integ.spec.ts b/packages/middleware-sdk-s3/src/middleware-sdk-s3.integ.spec.ts index 742c6348a4bbf..b94232466f6f0 100644 --- a/packages/middleware-sdk-s3/src/middleware-sdk-s3.integ.spec.ts +++ b/packages/middleware-sdk-s3/src/middleware-sdk-s3.integ.spec.ts @@ -57,5 +57,31 @@ describe("middleware-sdk-s3", () => { expect.hasAssertions(); }); + + it("allows using a bucket input value as the endpoint", async () => { + const client = new S3({ + region: "us-west-2", + bucketEndpoint: true, + }); + + requireRequestsFrom(client).toMatch({ + query: { "x-id": "PutObject" }, + protocol: "https:", + hostname: "mybucket.com", + port: 8888, + path: "/my-bucket-path/my-key", + headers: { + host: "mybucket.com:8888", + }, + }); + + await client.putObject({ + Bucket: "https://mybucket.com:8888/my-bucket-path", + Key: "my-key", + Body: "abcd", + }); + + expect.hasAssertions(); + }); }); }); diff --git a/packages/middleware-sdk-s3/src/s3Configuration.ts b/packages/middleware-sdk-s3/src/s3Configuration.ts index 5d79857a19f4d..4072a01dcaa4c 100644 --- a/packages/middleware-sdk-s3/src/s3Configuration.ts +++ b/packages/middleware-sdk-s3/src/s3Configuration.ts @@ -32,6 +32,10 @@ export interface S3InputConfig { * Identity provider for an S3 feature. */ s3ExpressIdentityProvider?: S3ExpressIdentityProvider; + /** + * Whether to use the bucket name as the endpoint for this client. + */ + bucketEndpoint?: boolean; } /** @@ -54,6 +58,7 @@ export interface S3ResolvedConfig { disableMultiregionAccessPoints: boolean; followRegionRedirects: boolean; s3ExpressIdentityProvider: S3ExpressIdentityProvider; + bucketEndpoint: boolean; } export const resolveS3Config = ( @@ -81,5 +86,6 @@ export const resolveS3Config = ( }) ) ), + bucketEndpoint: input.bucketEndpoint ?? false, }; }; diff --git a/packages/middleware-sdk-s3/src/validate-bucket-name.spec.ts b/packages/middleware-sdk-s3/src/validate-bucket-name.spec.ts index 0f70e83b80dce..b0864379f8814 100644 --- a/packages/middleware-sdk-s3/src/validate-bucket-name.spec.ts +++ b/packages/middleware-sdk-s3/src/validate-bucket-name.spec.ts @@ -11,7 +11,7 @@ describe("validateBucketNameMiddleware", () => { }); it("throws error if Bucket parameter contains '/'", async () => { - const handler = validateBucketNameMiddleware()(mockNextHandler, {} as any); + const handler = validateBucketNameMiddleware({} as any)(mockNextHandler, {} as any); const bucket = "bucket/part/of/key"; let error; try { @@ -29,7 +29,7 @@ describe("validateBucketNameMiddleware", () => { }); it("doesn't throw error if Bucket parameter has no '/'", async () => { - const handler = validateBucketNameMiddleware()(mockNextHandler, {} as any); + const handler = validateBucketNameMiddleware({} as any)(mockNextHandler, {} as any); const args = { input: { Bucket: "bucket", @@ -42,7 +42,7 @@ describe("validateBucketNameMiddleware", () => { it("should not validate bucket name if the bucket name is an ARN", async () => { mockValidateArn.mockReturnValue(true); - const handler = validateBucketNameMiddleware()(mockNextHandler, {} as any); + const handler = validateBucketNameMiddleware({} as any)(mockNextHandler, {} as any); const args = { input: { Bucket: "arn:aws:s3:us-east-1:123456789012:accesspoint/myendpoint", diff --git a/packages/middleware-sdk-s3/src/validate-bucket-name.ts b/packages/middleware-sdk-s3/src/validate-bucket-name.ts index 85b8dfa144b6c..23fd863887c7b 100644 --- a/packages/middleware-sdk-s3/src/validate-bucket-name.ts +++ b/packages/middleware-sdk-s3/src/validate-bucket-name.ts @@ -9,16 +9,19 @@ import { Pluggable, } from "@smithy/types"; +import { bucketEndpointMiddleware, bucketEndpointMiddlewareOptions } from "./bucket-endpoint-middleware"; +import { S3ResolvedConfig } from "./s3Configuration"; + /** * @internal */ -export function validateBucketNameMiddleware(): InitializeMiddleware { +export function validateBucketNameMiddleware({ bucketEndpoint }: S3ResolvedConfig): InitializeMiddleware { return (next: InitializeHandler): InitializeHandler => async (args: InitializeHandlerArguments): Promise> => { const { input: { Bucket }, } = args; - if (typeof Bucket === "string" && !validateArn(Bucket) && Bucket.indexOf("/") >= 0) { + if (!bucketEndpoint && typeof Bucket === "string" && !validateArn(Bucket) && Bucket.indexOf("/") >= 0) { const err = new Error(`Bucket name shouldn't contain '/', received '${Bucket}'`); err.name = "InvalidBucketName"; throw err; @@ -40,9 +43,9 @@ export const validateBucketNameMiddlewareOptions: InitializeHandlerOptions = { /** * @internal */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export const getValidateBucketNamePlugin = (unused: any): Pluggable => ({ +export const getValidateBucketNamePlugin = (options: S3ResolvedConfig): Pluggable => ({ applyToStack: (clientStack) => { - clientStack.add(validateBucketNameMiddleware(), validateBucketNameMiddlewareOptions); + clientStack.add(validateBucketNameMiddleware(options), validateBucketNameMiddlewareOptions); + clientStack.addRelativeTo(bucketEndpointMiddleware(options), bucketEndpointMiddlewareOptions); }, }); diff --git a/private/aws-client-api-test/src/client-interface-tests/client-s3/impl/initializeWithMaximalConfiguration.ts b/private/aws-client-api-test/src/client-interface-tests/client-s3/impl/initializeWithMaximalConfiguration.ts index 7e9a37d9d0c4f..0944609ae18d8 100644 --- a/private/aws-client-api-test/src/client-interface-tests/client-s3/impl/initializeWithMaximalConfiguration.ts +++ b/private/aws-client-api-test/src/client-interface-tests/client-s3/impl/initializeWithMaximalConfiguration.ts @@ -116,6 +116,7 @@ export const initializeWithMaximalConfiguration = () => { disableS3ExpressSessionAuth: false, useGlobalEndpoint: false, signingEscapePath: false, + bucketEndpoint: false, }; const s3 = new S3Client(config);