Skip to content

Commit

Permalink
Add S3 example.
Browse files Browse the repository at this point in the history
  • Loading branch information
Alexander-Miller committed Oct 20, 2023
1 parent 77484df commit fe0f97e
Show file tree
Hide file tree
Showing 21 changed files with 751 additions and 0 deletions.
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: CI-Build

on:
push:
branches: [ master ]
pull_request:
branches: [ master ]

concurrency:
group: ci_${{ github.ref }}
cancel-in-progress: true

env:
javaVersion: "17"
javaDistribution: "liberica"

jobs:
ci-platform-aws:
runs-on: ubuntu-latest
needs:
- s3
steps:
- run: echo "CI-Build completed!"

s3:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
java-version: ${{ env.javaVersion }}
distribution: ${{ env.javaDistribution }}
- run: |
chmod +x gradlew
./gradlew :examples:s3:build
- uses: actions/upload-artifact@v3
if: always()
with:
name: test-results_s3
path: "**/build/reports/tests"
65 changes: 65 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import io.spring.gradle.dependencymanagement.dsl.DependencyManagementExtension
import org.gradle.api.file.DuplicatesStrategy.INCLUDE
import org.gradle.api.tasks.testing.logging.TestLogEvent.FAILED
import org.gradle.api.tasks.testing.logging.TestLogEvent.SKIPPED
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
id("org.springframework.boot") version "3.1.0" apply false
id("io.spring.dependency-management") version "1.1.0" apply false
id("org.asciidoctor.jvm.convert") version "3.3.2" apply false

kotlin("jvm") version "1.8.21" apply false
kotlin("plugin.spring") version "1.8.21" apply false
kotlin("plugin.jpa") version "1.8.21" apply false
kotlin("plugin.noarg") version "1.8.21" apply false
}

allprojects {
repositories { mavenCentral(); mavenLocal() }

if (project.childProjects.isNotEmpty()) return@allprojects

apply {
plugin("io.spring.dependency-management")
}

the<DependencyManagementExtension>().apply {
imports {
mavenBom("org.jetbrains.kotlin:kotlin-bom:1.8.21")
mavenBom("org.testcontainers:testcontainers-bom:1.18.3")
mavenBom(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES)
}

dependencies {
dependency("com.ninja-squad:springmockk:4.0.2")
dependency("io.mockk:mockk-jvm:1.13.5")
dependency("org.testcontainers:junit-jupiter:1.18.1")
dependency("org.testcontainers:localstack:1.18.1")
dependency("com.amazonaws:aws-java-sdk-s3:1.12.272")
dependency("com.amazonaws:aws-java-sdk-sts:1.12.272")
dependency("io.kotest:kotest-assertions-core:5.6.2")
}
}

tasks {
withType<Copy> { duplicatesStrategy = INCLUDE }
withType<Jar> { duplicatesStrategy = INCLUDE }
withType<JavaCompile> {
sourceCompatibility = "17"
targetCompatibility = "17"
}
withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "17"
incremental = false
}
}
withType<Test> {
group = "verification"
useJUnitPlatform()
testLogging { events(FAILED, SKIPPED) }
}
}
}
23 changes: 23 additions & 0 deletions examples/s3/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
plugins {
id("org.springframework.boot")
id("io.spring.dependency-management")

kotlin("jvm")
kotlin("plugin.spring")
}

dependencies {
implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.testcontainers:localstack")
implementation("com.amazonaws:aws-java-sdk-s3")
implementation("com.amazonaws:aws-java-sdk-sts")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.mockk:mockk-jvm")
testImplementation("com.ninja-squad:springmockk")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("io.kotest:kotest-assertions-core")
}
19 changes: 19 additions & 0 deletions examples/s3/http/S3.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
### Get Clean Code Cover
GET localhost:8080/books/{{clean-code}}/cover

### Get Lord of the Rings Cover
GET localhost:8080/books/{{lord-of-the-rings}}/cover

### Get Mythical Man Month Cover
### Will fail at first because the cover has to be added by the PUT request below.
GET localhost:8080/books/{{mythical-man-moth}}/cover

### Put Mythical Man Month Cover
PUT localhost:8080/books/{{mythical-man-moth}}/cover
Content-Type: multipart/form-data; boundary=boundary

--boundary
Content-Disposition: form-data; name="cover"; filename="mythical-man-month.jpg"
Content-Type: image/jpeg

< ../src/main/resources/bookcovers/mythical-man-month.jpg
7 changes: 7 additions & 0 deletions examples/s3/http/http-client.env.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"ldev": {
"clean-code": "73a3a94e-9ec7-4c35-82ff-9235fa4a2a86",
"lord-of-the-rings": "81abeacb-9850-4691-94b8-2fff9bb7b1a7",
"mythical-man-moth": "021e0ea7-6fda-4682-9747-eb7161154885"
}
}
17 changes: 17 additions & 0 deletions examples/s3/src/main/kotlin/example/aws/s3/Application.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package example.aws.s3

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.builder.SpringApplicationBuilder
import org.springframework.boot.runApplication

@SpringBootApplication
class Application

/**
* Start the application from a builder (instead of the usual [runApplication]) so we can supply our [S3Initializer].
*/
fun main(args: Array<String>) {
SpringApplicationBuilder(Application::class.java)
.initializers(S3Initializer())
.run(*args)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package example.aws.s3

import com.amazonaws.auth.AWSStaticCredentialsProvider
import com.amazonaws.auth.BasicAWSCredentials
import com.amazonaws.client.builder.AwsClientBuilder
import com.amazonaws.services.s3.AmazonS3
import com.amazonaws.services.s3.AmazonS3ClientBuilder
import example.aws.s3.domain.S3Properties
import org.springframework.boot.context.event.ApplicationStartedEvent
import org.springframework.context.ApplicationContextInitializer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.event.EventListener
import org.springframework.context.support.GenericApplicationContext
import org.testcontainers.containers.localstack.LocalStackContainer
import org.testcontainers.utility.DockerImageName
import java.io.File
import java.util.function.Supplier

@Configuration
class ApplicationConfiguration(

/**
* There is no real [AmazonS3] bean available to us, we artificially create and inject our own using the
* [S3Initializer] below. IntelliJ does not know that, so we have to suppress its inspections about missing beans.
*/
@Suppress("SpringJavaInjectionPointsAutowiringInspection")
private val s3: AmazonS3,
private val s3properties: S3Properties,
) {

/**
* Our S3 is not permanent, but is instead re-created every time the app is running. Therefore, we also have to
* create a bucket every time the container starts.
*
* We also use this opportunity to add 2 book covers into the bucket to serve as pre-made examples.
*/
@EventListener
fun createBucketAddExamples(event: ApplicationStartedEvent) {
s3.createBucket(s3properties.bucketName)

val cleanCodeCover = File(this::class.java.getResource("/bookcovers/clean_code.jpg")!!.path)
val lotrCover = File(this::class.java.getResource("/bookcovers/fellowship_of_the_ring.jpg")!!.path)

s3.putObject(s3properties.bucketName, s3properties.books.cleanCode, cleanCodeCover)
s3.putObject(s3properties.bucketName, s3properties.books.lordOfTheRings, lotrCover)
}
}

/**
* This [ApplicationContextInitializer] is the means by which we create an S3 instance, both for our tests, and also as
* a substitute for not having a real AWS environment in this simple example.
*/
class S3Initializer : ApplicationContextInitializer<GenericApplicationContext> {

override fun initialize(applicationContext: GenericApplicationContext) {
val container = S3Container().withServices(LocalStackContainer.Service.S3).apply { start() }
createS3Bean(container, applicationContext)
}

/**
* Normally you would declare a [Bean] method where you create and configure your [AmazonS3] client. Since we use
* a Test Container we instead inject our client directly in Spring's application context.
*/
private fun createS3Bean(
container: LocalStackContainer,
applicationContext: GenericApplicationContext
) {
applicationContext.registerBean(
AmazonS3::class.java.simpleName,
AmazonS3::class.java,
Supplier {
AmazonS3ClientBuilder
.standard()
.withEndpointConfiguration(
AwsClientBuilder.EndpointConfiguration(
container.getEndpointOverride(LocalStackContainer.Service.S3).toString(),
container.region
)
)
.withCredentials(
AWSStaticCredentialsProvider(BasicAWSCredentials(container.accessKey, container.secretKey))
)
.build()
}
)
}

private class S3Container : LocalStackContainer(DockerImageName.parse("localstack/localstack:0.11.3"))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package example.aws.s3.api

import example.aws.s3.domain.BookCoverData
import example.aws.s3.domain.BookCoverService
import org.springframework.core.io.InputStreamResource
import org.springframework.core.io.Resource
import org.springframework.http.HttpStatus.CREATED
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.http.ResponseEntity.notFound
import org.springframework.http.ResponseEntity.ok
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import java.util.*

/**
* Simple REST controller that allows to either GET or PUT a cover for a specific book.
*/
@RestController
class BookCoverController(
private val bookCoverService: BookCoverService
) {

@GetMapping("/books/{id}/cover")
fun getCover(@PathVariable("id") id: UUID): ResponseEntity<Resource> {
val coverData: BookCoverData = bookCoverService.findCover(id)
?: return notFound().build()

return ok()
.contentLength(coverData.contentLength)
.contentType(MediaType.parseMediaType(coverData.contentType))
.body(InputStreamResource(coverData.byteStream))
}

@ResponseStatus(CREATED)
@PutMapping("/books/{id}/cover")
fun putCover(
@PathVariable("id") id: UUID,
@RequestParam("cover") cover: MultipartFile,
) {
bookCoverService.saveCover(id, cover)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package example.aws.s3.domain

import com.amazonaws.services.s3.AmazonS3
import com.amazonaws.services.s3.model.AmazonS3Exception
import com.amazonaws.services.s3.model.ObjectMetadata
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.http.HttpStatus.NOT_FOUND
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.util.*

/**
* Business class that implements the details of loading and saving book covers. Offers a clean api that hides the
* lower level details of the S3 sdk.
*/
@Service
@Suppress("SpringJavaInjectionPointsAutowiringInspection")
@EnableConfigurationProperties(S3Properties::class)
class BookCoverService(
private val s3: AmazonS3,
private val s3Properties: S3Properties,
) {

fun findCover(id: UUID): BookCoverData? {
val s3Object = try {
s3.getObject(s3Properties.bucketName, id.toString())
} catch (e: AmazonS3Exception) {
when (e.statusCode) {
NOT_FOUND.value() -> return null
else -> throw e
}
}
return BookCoverData(
byteStream = s3Object.objectContent,
contentLength = s3Object.objectMetadata.contentLength,
contentType = s3Object.objectMetadata.contentType,
)
}

fun saveCover(
id: UUID,
cover: MultipartFile,
) {
s3.putObject(
s3Properties.bucketName,
id.toString(),
cover.inputStream,
ObjectMetadata().also {
it.contentLength = cover.size
it.contentType = cover.contentType
}
)
}

}

data class BookCoverData(
val byteStream: InputStream,
val contentLength: Long,
val contentType: String,
)
Loading

0 comments on commit fe0f97e

Please sign in to comment.