S3

Installation

dependencies {
    implementation(platform("org.http4k:http4k-connect-bom:5.22.1.0"))
    implementation("org.http4k:http4k-connect-amazon-s3")
    implementation("org.http4k:http4k-connect-amazon-s3-fake")
}

The S3 connector consists of 2 interfaces:

  • S3 for global operations, providing the following Actions:

    • CreateBucket
    • HeadBucket
    • ListBuckets
  • S3Bucket for bucket level operations, providing the following Actions:

    • CopyObject
    • CreateObject
    • DeleteBucket
    • DeleteObject
    • DeleteObjectTagging
    • GetObject
    • GetObjectTagging
    • HeadObject
    • ListObjectsV2
    • PutObject
    • PutObjectTagging
    • RestoreObject

Example usage

const val USE_REAL_CLIENT = false

fun main() {
    // we can connect to the real service or the fake (drop in replacement)
    val http: HttpHandler = if (USE_REAL_CLIENT) JavaHttpClient() else FakeS3()

    val bucketName = BucketName.of("foobar")
    val bucketKey = BucketKey.of("keyName")
    val region = Region.of("us-east-1")

    // create global and bucket level clients
    val s3 = S3.Http({ AwsCredentials("accessKeyId", "secretKey") }, http.debug())
    val s3Bucket = S3Bucket.Http(bucketName, region, { AwsCredentials("accessKeyId", "secretKey") }, http.debug())

    // all operations return a Result monad of the API type
    val createResult: Result<Unit, RemoteFailure> = s3.createBucket(bucketName, region)
    createResult.valueOrNull()!!

    // we can store some content in the bucket...
    val putResult: Result<Unit, RemoteFailure> = s3Bucket.putObject(bucketKey, "hellothere".byteInputStream())
    putResult.valueOrNull()!!

    // and get back the content which we stored
    val getResult: Result<InputStream?, RemoteFailure> = s3Bucket.get(bucketKey)
    val content: InputStream = getResult.valueOrNull()!!
    println(content.reader().readText())
}

The client APIs utilise the http4k-aws module for request signing, which means no dependencies on the incredibly fat Amazon-SDK JARs. This means this integration is perfect for running Serverless Lambdas where binary size is a performance factor.

How the Fake works with bucket-level operations

S3 is a bit of a strange beast in that it each bucket gets its own virtual hostname. This makes running a Fake an interesting challenge without messing around with DNS and hostname files.

This implementation supports both global and bucket level operations by inspecting the subdomain of the X-Forwarded-For header, which is populated by the S3 client built into this module.

In the case of a missing header (if for instance a non-http4k client attempts to push some data into it without the x-forwarded-for header), it creates a global bucket which is then used to store all of the data for these unknown requests.

Default Fake ports:

  • Global: default port: 26467
  • Bucket: default port: 42628
FakeS3().start()

Connecting to a local S3 emulator

Services like LocalStack or MinIO can emulate AWS services locally. However, for S3 bucket operations you either need to use a specific pre-configured bucket hostname like http://<bucket-name>.s3.localhost.localstack.cloud:4566, or you configure the S3Bucket to always perform path-style requests like this:

val s3Bucket = S3Bucket.Http(
    bucketName = bucketName, 
    bucketRegion = region,
    credentialsProvider = { credentials },
    overrideEndpoint = Uri.of("http://localhost:4566"),
    forcePathStyle = true // always use path-style requests
)

Pre-Signed Requests

Http4k supports pre-signed requests with the generic AwsRequestPreSigner class. However, http4k-connect provides a simplified interface for common S3 Bucket operations with the S3BucketPresigner.

fun main() {    
    // create pre-signer
    val preSigner = S3BucketPreSigner(
        bucketName = BucketName.of("foobar"),
        region = Region.of("us-east-1"),
        credentials = AwsCredentials("accessKeyId", "secretKey")
    )

    val key = BucketKey.of("keyName")

    // create a pre-signed PUT
    val put = preSigner.put(
        key = key,
        duration = Duration.ofMinutes(5), // how long the URL is valid for
        headers = listOf("content-type" to "application.json")  // add optional signed headers
    )
    println(put.uri)

    // create a pre-signed GET
    val get = preSigner.get(
        key = key,
        duration = Duration.ofMinutes(5)
    )
    println(get)

    // share these URIs to your clients so they can perform the operations without credentials
}