init: 클린 아키텍처 백엔드
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
# adapter:outbound:objectstorage — module rules
|
||||
|
||||
## Registered identity
|
||||
|
||||
- Module ID: `adapter-outbound-objectstorage`
|
||||
- Gradle path: `:adapter:outbound:objectstorage`
|
||||
- Focused test: `./gradlew :adapter:outbound:objectstorage:test --console=plain`
|
||||
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
|
||||
- Registry SSOT: `.harness/project/modules.yaml`.
|
||||
|
||||
Package root: `dev.caskeleton.adapter.outbound.objectstorage`. Driven (outbound) adapter
|
||||
implementing `dev.caskeleton.application.storage.ObjectStoragePort` (application-core). Design
|
||||
rationale lives in [README.md](README.md).
|
||||
|
||||
## Responsibility
|
||||
|
||||
- Persist/retrieve binary blobs behind `ObjectStoragePort`. Two backends select the same port by
|
||||
`ca-skeleton.objectstorage.backend`: `filesystem` (default) and `s3` (S3/MinIO, AWS SDK v2).
|
||||
- Opt-in: `ObjectStorageConfig` gates each backend with `@ConditionalOnProperty`; filesystem is the
|
||||
`matchIfMissing` default. The adapters are plain classes; the config assembles them as beans.
|
||||
|
||||
## Allowed
|
||||
|
||||
- Project deps: `:application-core`, `:shared-contract` — SSOT is the
|
||||
`adapter-outbound-objectstorage` entry in `.harness/project/modules.yaml`; `src/build.gradle`
|
||||
enforces it. No
|
||||
`:domain-core`, no sibling adapters (shared outbound code would go through `:adapter:outbound:support`
|
||||
if ever needed).
|
||||
- External: `software.amazon.awssdk:s3` (version via the module-scoped `software.amazon.awssdk:bom`
|
||||
platform, pinned by root `ext.awsSdkVersion`), `spring-boot-starter`,
|
||||
`spring-boot-configuration-processor` (annotation processor).
|
||||
|
||||
## Forbidden
|
||||
|
||||
- Inbound adapters, sibling outbound adapters, persistence, `app-bootstrap`, `sample-portfolio`
|
||||
(ArchUnit `OUTBOUND_ADAPTERS_*` family rules).
|
||||
- Leaking a raw AWS SDK type across `ObjectStoragePort` (B7) — the port returns only `StoredObject`
|
||||
/ `byte[]` / primitives.
|
||||
- Fully-qualified inline type references; more than one public top-level type per file.
|
||||
|
||||
## Tests
|
||||
|
||||
`FilesystemObjectStorageAdapterTest` (temp-dir round-trip), `S3ObjectStorageAdapterTest` (mocked
|
||||
`S3Client` mapping), `S3ObjectStorageAdapterIT` (Testcontainers MinIO, `disabledWithoutDocker`).
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:objectstorage:check
|
||||
```
|
||||
@@ -0,0 +1,72 @@
|
||||
# adapter:outbound:objectstorage — design-decision reference
|
||||
|
||||
Object-storage outbound (driven) adapter. Package root:
|
||||
`dev.caskeleton.adapter.outbound.objectstorage`. Implements the `application-core` port
|
||||
`dev.caskeleton.application.storage.ObjectStoragePort` behind an opt-in `@ConditionalOnProperty`
|
||||
selector, mirroring the existing outbound adapters (notification / cache-redis / httpclient).
|
||||
|
||||
The allowed/forbidden dependency policy is owned by `src/build.gradle`'s
|
||||
`allowedProjectDependencies['adapter:outbound:objectstorage']` (SSOT). Module rules live in
|
||||
[CLAUDE.md](CLAUDE.md); this document records the **design rationale** lifted out of the code
|
||||
comments.
|
||||
|
||||
## Module overview
|
||||
|
||||
An **opt-in** blob-storage adapter placed behind an application-core port. Two backends select the
|
||||
same `ObjectStoragePort` by configuration:
|
||||
|
||||
- **filesystem** (default, `matchIfMissing`) — `FilesystemObjectStorageAdapter` writes blobs under
|
||||
`ca-skeleton.objectstorage.base-path`. No external service, so the local profile just works. The
|
||||
`location` in the `StoredObject` receipt is the `file://` URI.
|
||||
- **s3** — `S3ObjectStorageAdapter` uses the AWS SDK v2 `S3Client`. The client's endpoint override +
|
||||
path-style access (wired in `ObjectStorageConfig`) make the same code work against real AWS S3
|
||||
(leave `endpoint` unset) and MinIO (`endpoint=http://localhost:9000`). The `location` is an
|
||||
`s3://bucket/key` URI.
|
||||
|
||||
Selector: `ca-skeleton.objectstorage.backend=filesystem|s3` (filesystem is the default). Exactly one
|
||||
`ObjectStoragePort` bean is contributed, so a fork injects the port without knowing the active
|
||||
backend.
|
||||
|
||||
## The port contract (framework-neutral)
|
||||
|
||||
`ObjectStoragePort` is a minimal, framework-neutral surface:
|
||||
|
||||
- `StoredObject put(String key, byte[] content, String contentType)` — store/overwrite.
|
||||
- `Optional<byte[]> get(String key)` — read, `empty()` when absent.
|
||||
- `void delete(String key)` — idempotent delete.
|
||||
- `boolean exists(String key)`.
|
||||
|
||||
Keys are caller-supplied, backend-relative, opaque strings. Implementations reject a blank key or a
|
||||
key that escapes the backend namespace (path traversal) with `IllegalArgumentException` — the
|
||||
filesystem adapter normalises the resolved path and checks it still starts with the base directory.
|
||||
The port intentionally exposes **no** streaming or presigned-URL surface; a fork adds those when a
|
||||
concrete feature needs them. Raw external SDK types never cross the port (B7) — the adapter returns
|
||||
only `StoredObject` / `byte[]` / primitives.
|
||||
|
||||
## AWS SDK versioning (why the BOM is imported at module scope)
|
||||
|
||||
`software.amazon.awssdk:*` versions are **not** managed by the Spring Boot BOM and this repo has no
|
||||
version catalog. The AWS SDK v2 BOM is therefore imported as a `dependencyManagement` platform in
|
||||
**this module's** `build.gradle` using the root `ext.awsSdkVersion` SSOT (set in `src/build.gradle`),
|
||||
exactly like the grpc module imports `grpc-bom`. This keeps the strict-locking blast radius to this
|
||||
module — the shared root `dependencyManagement` block stays awssdk-free.
|
||||
|
||||
## IO-failure handling
|
||||
|
||||
Filesystem IO failures are wrapped in the shared-contract `DependencyFailureException`
|
||||
(`dependencyName="objectstorage"`) so a fork's web error handler classifies them uniformly with the
|
||||
other outbound dependencies. Illegal/blank keys are `IllegalArgumentException` (a caller bug, not a
|
||||
dependency failure). The S3 adapter maps `NoSuchKey` / HTTP 404 to `Optional.empty()` / `false`.
|
||||
|
||||
## Tests
|
||||
|
||||
- `FilesystemObjectStorageAdapterTest` — `@TempDir` put/get/delete/exists round-trip, overwrite,
|
||||
idempotent delete, path-traversal + blank-key rejection.
|
||||
- `S3ObjectStorageAdapterTest` — key/metadata/URI mapping against a mocked `S3Client` (no network).
|
||||
- `S3ObjectStorageAdapterIT` — real S3-protocol round-trip against Testcontainers MinIO; skipped
|
||||
automatically when Docker is unavailable (`@Testcontainers(disabledWithoutDocker = true)`).
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:objectstorage:check
|
||||
```
|
||||
@@ -0,0 +1,33 @@
|
||||
// Driven adapter: object storage behind application-core's ObjectStoragePort. Two backends — local
|
||||
// filesystem (default, no external service) and S3/MinIO via the AWS SDK v2 S3 client (endpoint
|
||||
// override makes the same code work against real AWS S3 and MinIO). Opt-in via
|
||||
// @ConditionalOnProperty (ca-skeleton.objectstorage.backend); filesystem is the matchIfMissing
|
||||
// default.
|
||||
//
|
||||
// software.amazon.awssdk:* versions are NOT managed by the Spring Boot BOM, and this repo has no
|
||||
// version catalog, so the AWS SDK v2 BOM platform is imported HERE (module scope) using the root
|
||||
// `ext.awsSdkVersion` SSOT — this keeps the strict-locking blast radius to this module (the shared
|
||||
// root dependencyManagement block stays awssdk-free), mirroring the grpc module's grpc-bom import.
|
||||
description = 'Outbound adapter: object storage (S3/MinIO + local filesystem)'
|
||||
|
||||
dependencyManagement {
|
||||
imports {
|
||||
mavenBom "software.amazon.awssdk:bom:${awsSdkVersion}"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':application-core')
|
||||
implementation project(':shared-contract')
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-starter'
|
||||
implementation 'software.amazon.awssdk:s3'
|
||||
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
|
||||
// test-only: Testcontainers MinIO integration test for the S3 backend. Uses the core
|
||||
// GenericContainer (no dedicated module) so the S3 round-trip runs against a real MinIO when
|
||||
// Docker is available and is skipped (disabledWithoutDocker) otherwise.
|
||||
testImplementation 'org.testcontainers:testcontainers'
|
||||
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.docker-java:docker-java-api:3.7.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.docker-java:docker-java-transport-zerodep:3.7.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.docker-java:docker-java-transport:3.7.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-codec:commons-codec:1.19.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.netty:netty-buffer:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-base:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-compression:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-http2:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-http:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-marshalling:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec-protobuf:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-codec:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-common:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-handler:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-resolver:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-transport-classes-epoll:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-transport-native-unix-common:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
|
||||
io.netty:netty-transport:4.2.7.Final=runtimeClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.java.dev.jna:jna:5.18.1=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-compress:1.28.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle,runtimeClasspath,testRuntimeClasspath
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle,runtimeClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jetbrains:annotations:17.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.rnorth.duct-tape:duct-tape:1.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.testcontainers:testcontainers-junit-jupiter:2.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.testcontainers:testcontainers:2.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:annotations:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:apache-client:2.30.0=runtimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:arns:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:auth:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:aws-core:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:aws-query-protocol:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:aws-xml-protocol:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:checksums-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:checksums:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:crt-core:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:endpoints-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:http-auth-aws-eventstream:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:http-auth-aws:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:http-auth-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:http-auth:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:http-client-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:identity-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:json-utils:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:metrics-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:netty-nio-client:2.30.0=runtimeClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:profiles:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:protocol-core:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:regions:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:retries-spi:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:retries:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:s3:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:sdk-core:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:third-party-jackson-core:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.awssdk:utils:2.30.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
software.amazon.eventstream:eventstream:1.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage;
|
||||
|
||||
import dev.caskeleton.application.storage.ObjectStoragePort;
|
||||
import dev.caskeleton.application.storage.StoredObject;
|
||||
import dev.caskeleton.shared.error.DependencyFailureException;
|
||||
import dev.caskeleton.shared.error.OperationalError;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Filesystem-backed {@link ObjectStoragePort} — the default backend so the local profile needs no
|
||||
* MinIO/S3. Blobs are written under a configured base directory and the {@code location} in the
|
||||
* returned {@link StoredObject} is the {@code file://} URI. Content type is not persisted (this
|
||||
* port exposes no content type on read); it is echoed back in the {@link StoredObject} receipt
|
||||
* only.
|
||||
*/
|
||||
public class FilesystemObjectStorageAdapter implements ObjectStoragePort {
|
||||
|
||||
private static final String DEPENDENCY_NAME = "objectstorage";
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FilesystemObjectStorageAdapter.class);
|
||||
|
||||
private final Path baseDir;
|
||||
|
||||
public FilesystemObjectStorageAdapter(String basePath) {
|
||||
this.baseDir = Path.of(basePath).toAbsolutePath().normalize();
|
||||
try {
|
||||
Files.createDirectories(baseDir);
|
||||
log.info("filesystem object storage base dir: {}", baseDir);
|
||||
} catch (IOException e) {
|
||||
throw new DependencyFailureException(
|
||||
OperationalError.INTERNAL_ERROR,
|
||||
DEPENDENCY_NAME,
|
||||
"cannot create object storage base dir",
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public StoredObject put(String key, byte[] content, String contentType) {
|
||||
Objects.requireNonNull(content, "content must be non-null");
|
||||
requireContentType(contentType);
|
||||
Path target = resolve(key);
|
||||
try {
|
||||
Path parent = target.getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
Files.write(target, content);
|
||||
} catch (IOException e) {
|
||||
throw new DependencyFailureException(
|
||||
OperationalError.INTERNAL_ERROR, DEPENDENCY_NAME, "failed to store object", e);
|
||||
}
|
||||
return new StoredObject(key, content.length, contentType, target.toUri());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<byte[]> get(String key) {
|
||||
Path target = resolve(key);
|
||||
if (!Files.isRegularFile(target)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(Files.readAllBytes(target));
|
||||
} catch (IOException e) {
|
||||
throw new DependencyFailureException(
|
||||
OperationalError.INTERNAL_ERROR, DEPENDENCY_NAME, "failed to read object", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String key) {
|
||||
Path target = resolve(key);
|
||||
try {
|
||||
Files.deleteIfExists(target);
|
||||
} catch (IOException e) {
|
||||
throw new DependencyFailureException(
|
||||
OperationalError.INTERNAL_ERROR, DEPENDENCY_NAME, "failed to delete object", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String key) {
|
||||
return Files.isRegularFile(resolve(key));
|
||||
}
|
||||
|
||||
/** Resolves a key under {@code baseDir}, rejecting blank keys and path traversal. */
|
||||
private Path resolve(String key) {
|
||||
if (key == null || key.isBlank()) {
|
||||
throw new IllegalArgumentException("object key must be non-null and non-blank");
|
||||
}
|
||||
Path resolved = baseDir.resolve(key).normalize();
|
||||
if (!resolved.startsWith(baseDir)) {
|
||||
throw new IllegalArgumentException("illegal object key (path traversal): " + key);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static void requireContentType(String contentType) {
|
||||
if (contentType == null || contentType.isBlank()) {
|
||||
throw new IllegalArgumentException("contentType must be non-null and non-blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage;
|
||||
|
||||
import dev.caskeleton.application.storage.ObjectStoragePort;
|
||||
import java.net.URI;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.util.StringUtils;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3ClientBuilder;
|
||||
|
||||
/**
|
||||
* Opt-in wiring for the object-storage adapter. The backend is selected by {@code
|
||||
* ca-skeleton.objectstorage.backend}: {@code filesystem} (the {@code matchIfMissing} default)
|
||||
* contributes a {@link FilesystemObjectStorageAdapter}; {@code s3} contributes an AWS SDK v2 {@link
|
||||
* S3Client} plus an {@link S3ObjectStorageAdapter}. Exactly one {@link ObjectStoragePort} bean is
|
||||
* contributed, so a fork can inject the port without knowing which backend is active.
|
||||
*
|
||||
* <p>The S3 client's endpoint override + path-style access make the same adapter work against real
|
||||
* AWS S3 (leave {@code endpoint} unset) and MinIO (set {@code endpoint=http://localhost:9000}).
|
||||
* When {@code auto-create-bucket} is enabled the bucket is created at startup if missing.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(ObjectStorageSettings.class)
|
||||
public class ObjectStorageConfig {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(
|
||||
prefix = "ca-skeleton.objectstorage",
|
||||
name = "backend",
|
||||
havingValue = "filesystem",
|
||||
matchIfMissing = true)
|
||||
public ObjectStoragePort filesystemObjectStoragePort(ObjectStorageSettings properties) {
|
||||
return new FilesystemObjectStorageAdapter(properties.getBasePath());
|
||||
}
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
@ConditionalOnProperty(prefix = "ca-skeleton.objectstorage", name = "backend", havingValue = "s3")
|
||||
public S3Client objectStorageS3Client(ObjectStorageSettings properties) {
|
||||
S3ClientBuilder builder =
|
||||
S3Client.builder()
|
||||
.region(Region.of(properties.getRegion()))
|
||||
.forcePathStyle(properties.isPathStyleAccess());
|
||||
if (StringUtils.hasText(properties.getEndpoint())) {
|
||||
builder.endpointOverride(URI.create(properties.getEndpoint()));
|
||||
}
|
||||
if (StringUtils.hasText(properties.getAccessKey())) {
|
||||
builder.credentialsProvider(
|
||||
StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey())));
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "ca-skeleton.objectstorage", name = "backend", havingValue = "s3")
|
||||
public ObjectStoragePort s3ObjectStoragePort(
|
||||
S3Client objectStorageS3Client, ObjectStorageSettings properties) {
|
||||
S3ObjectStorageAdapter adapter =
|
||||
new S3ObjectStorageAdapter(objectStorageS3Client, properties.getBucket());
|
||||
if (properties.isAutoCreateBucket()) {
|
||||
adapter.ensureBucketExists();
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Typed settings for the object-storage adapter, bound from {@code ca-skeleton.objectstorage.*}.
|
||||
* Everything both backends need is expressed here, so switching from the local filesystem to
|
||||
* S3/MinIO is pure configuration. Bound as a mutable JavaBean (not a record) so a fork can leave
|
||||
* any subset of fields unset and inherit the defaults below. Named {@code *Settings} per the
|
||||
* code-conventions N6 naming rule for {@code dev.caskeleton} {@code @ConfigurationProperties}
|
||||
* types.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ca-skeleton.objectstorage")
|
||||
public class ObjectStorageSettings {
|
||||
|
||||
/** Which backend to activate: {@code filesystem} (default) or {@code s3}. */
|
||||
private String backend = "filesystem";
|
||||
|
||||
/** Filesystem backend: root directory blobs are written under. */
|
||||
private String basePath = "./.data/objectstorage";
|
||||
|
||||
/** S3/MinIO backend: target bucket. */
|
||||
private String bucket = "ca-skeleton";
|
||||
|
||||
/**
|
||||
* S3/MinIO backend: endpoint override. Defaults to a local MinIO ({@code http://localhost:9000})
|
||||
* so {@code backend=s3} connects to a local S3-compatible store out of the box; set to {@code
|
||||
* null}/empty to target real AWS S3 (virtual-host style), or override per environment.
|
||||
*/
|
||||
private String endpoint = "http://localhost:9000";
|
||||
|
||||
/** S3/MinIO backend: AWS region (also required by MinIO's signature). */
|
||||
private String region = "us-east-1";
|
||||
|
||||
/** S3/MinIO backend: access key; null falls back to the default AWS credential chain. */
|
||||
private String accessKey;
|
||||
|
||||
/** S3/MinIO backend: secret key; null falls back to the default AWS credential chain. */
|
||||
private String secretKey;
|
||||
|
||||
/** S3/MinIO backend: MinIO requires path-style access; real S3 uses virtual-host style. */
|
||||
private boolean pathStyleAccess = true;
|
||||
|
||||
/** S3/MinIO backend: create the bucket on startup if it is missing. */
|
||||
private boolean autoCreateBucket = true;
|
||||
|
||||
public String getBackend() {
|
||||
return backend;
|
||||
}
|
||||
|
||||
public void setBackend(String backend) {
|
||||
this.backend = backend;
|
||||
}
|
||||
|
||||
public String getBasePath() {
|
||||
return basePath;
|
||||
}
|
||||
|
||||
public void setBasePath(String basePath) {
|
||||
this.basePath = basePath;
|
||||
}
|
||||
|
||||
public String getBucket() {
|
||||
return bucket;
|
||||
}
|
||||
|
||||
public void setBucket(String bucket) {
|
||||
this.bucket = bucket;
|
||||
}
|
||||
|
||||
public String getEndpoint() {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
public void setEndpoint(String endpoint) {
|
||||
this.endpoint = endpoint;
|
||||
}
|
||||
|
||||
public String getRegion() {
|
||||
return region;
|
||||
}
|
||||
|
||||
public void setRegion(String region) {
|
||||
this.region = region;
|
||||
}
|
||||
|
||||
public String getAccessKey() {
|
||||
return accessKey;
|
||||
}
|
||||
|
||||
public void setAccessKey(String accessKey) {
|
||||
this.accessKey = accessKey;
|
||||
}
|
||||
|
||||
public String getSecretKey() {
|
||||
return secretKey;
|
||||
}
|
||||
|
||||
public void setSecretKey(String secretKey) {
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
public boolean isPathStyleAccess() {
|
||||
return pathStyleAccess;
|
||||
}
|
||||
|
||||
public void setPathStyleAccess(boolean pathStyleAccess) {
|
||||
this.pathStyleAccess = pathStyleAccess;
|
||||
}
|
||||
|
||||
public boolean isAutoCreateBucket() {
|
||||
return autoCreateBucket;
|
||||
}
|
||||
|
||||
public void setAutoCreateBucket(boolean autoCreateBucket) {
|
||||
this.autoCreateBucket = autoCreateBucket;
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage;
|
||||
|
||||
import dev.caskeleton.application.storage.ObjectStoragePort;
|
||||
import dev.caskeleton.application.storage.StoredObject;
|
||||
import java.net.URI;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import software.amazon.awssdk.core.ResponseBytes;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchBucketException;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
|
||||
/**
|
||||
* S3/MinIO implementation of {@link ObjectStoragePort} (AWS SDK v2 {@link S3Client}). Selected with
|
||||
* {@code ca-skeleton.objectstorage.backend=s3}. The client's endpoint override + path-style access
|
||||
* (wired in {@link ObjectStorageConfig}) make this same code work against real AWS S3 and MinIO.
|
||||
* The {@code location} in the returned {@link StoredObject} is an {@code s3://bucket/key} URI.
|
||||
*/
|
||||
public class S3ObjectStorageAdapter implements ObjectStoragePort {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(S3ObjectStorageAdapter.class);
|
||||
|
||||
private final S3Client s3;
|
||||
private final String bucket;
|
||||
|
||||
public S3ObjectStorageAdapter(S3Client s3, String bucket) {
|
||||
this.s3 = Objects.requireNonNull(s3, "s3 client must be non-null");
|
||||
if (bucket == null || bucket.isBlank()) {
|
||||
throw new IllegalArgumentException("bucket must be non-null and non-blank");
|
||||
}
|
||||
this.bucket = bucket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the configured bucket if it does not already exist. Invoked at startup by {@link
|
||||
* ObjectStorageConfig} when {@code auto-create-bucket} is enabled; a no-op when the bucket is
|
||||
* present.
|
||||
*/
|
||||
public void ensureBucketExists() {
|
||||
try {
|
||||
s3.headBucket(HeadBucketRequest.builder().bucket(bucket).build());
|
||||
} catch (NoSuchBucketException e) {
|
||||
createBucket();
|
||||
} catch (S3Exception e) {
|
||||
if (e.statusCode() == 404) {
|
||||
createBucket();
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void createBucket() {
|
||||
log.info("creating object storage bucket '{}'", bucket);
|
||||
s3.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public StoredObject put(String key, byte[] content, String contentType) {
|
||||
requireKey(key);
|
||||
Objects.requireNonNull(content, "content must be non-null");
|
||||
if (contentType == null || contentType.isBlank()) {
|
||||
throw new IllegalArgumentException("contentType must be non-null and non-blank");
|
||||
}
|
||||
s3.putObject(
|
||||
PutObjectRequest.builder().bucket(bucket).key(key).contentType(contentType).build(),
|
||||
RequestBody.fromBytes(content));
|
||||
log.debug("uploaded s3://{}/{} ({} bytes)", bucket, key, content.length);
|
||||
return new StoredObject(key, content.length, contentType, location(key));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<byte[]> get(String key) {
|
||||
requireKey(key);
|
||||
try {
|
||||
ResponseBytes<GetObjectResponse> object =
|
||||
s3.getObjectAsBytes(GetObjectRequest.builder().bucket(bucket).key(key).build());
|
||||
return Optional.of(object.asByteArray());
|
||||
} catch (NoSuchKeyException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String key) {
|
||||
requireKey(key);
|
||||
s3.deleteObject(DeleteObjectRequest.builder().bucket(bucket).key(key).build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String key) {
|
||||
requireKey(key);
|
||||
try {
|
||||
s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(key).build());
|
||||
return true;
|
||||
} catch (NoSuchKeyException e) {
|
||||
return false;
|
||||
} catch (S3Exception e) {
|
||||
if (e.statusCode() == 404) {
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** The {@code s3://bucket/key} locator recorded in a {@link StoredObject} receipt. */
|
||||
URI location(String key) {
|
||||
return URI.create("s3://" + bucket + "/" + key);
|
||||
}
|
||||
|
||||
private static void requireKey(String key) {
|
||||
if (key == null || key.isBlank()) {
|
||||
throw new IllegalArgumentException("object key must be non-null and non-blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.storage.StoredObject;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* Temp-dir round-trip contract for {@link FilesystemObjectStorageAdapter} (the default backend).
|
||||
*/
|
||||
class FilesystemObjectStorageAdapterTest {
|
||||
|
||||
private FilesystemObjectStorageAdapter adapter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp(@TempDir Path baseDir) {
|
||||
adapter = new FilesystemObjectStorageAdapter(baseDir.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void putThenGetReturnsSameBytes() {
|
||||
byte[] content = "hello-object-storage".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
StoredObject stored = adapter.put("docs/greeting.txt", content, "text/plain");
|
||||
|
||||
assertThat(stored.key()).isEqualTo("docs/greeting.txt");
|
||||
assertThat(stored.size()).isEqualTo(content.length);
|
||||
assertThat(stored.contentType()).isEqualTo("text/plain");
|
||||
assertThat(stored.location().getScheme()).isEqualTo("file");
|
||||
|
||||
assertThat(adapter.get("docs/greeting.txt"))
|
||||
.map(bytes -> new String(bytes, StandardCharsets.UTF_8))
|
||||
.contains(new String(content, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsReflectsPutAndDelete() {
|
||||
assertThat(adapter.exists("k")).isFalse();
|
||||
|
||||
adapter.put("k", new byte[] {1, 2, 3}, "application/octet-stream");
|
||||
assertThat(adapter.exists("k")).isTrue();
|
||||
|
||||
adapter.delete("k");
|
||||
assertThat(adapter.exists("k")).isFalse();
|
||||
assertThat(adapter.get("k")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getMissingKeyReturnsEmpty() {
|
||||
assertThat(adapter.get("absent")).isEqualTo(Optional.empty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteIsIdempotentForMissingKey() {
|
||||
adapter.delete("never-written"); // must not throw
|
||||
assertThat(adapter.exists("never-written")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void overwriteReplacesContent() {
|
||||
adapter.put("k", "first".getBytes(StandardCharsets.UTF_8), "text/plain");
|
||||
adapter.put("k", "second".getBytes(StandardCharsets.UTF_8), "text/plain");
|
||||
|
||||
assertThat(adapter.get("k")).map(String::new).contains("second");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pathTraversalKeyIsRejected() {
|
||||
assertThatThrownBy(() -> adapter.put("../escape", new byte[0], "text/plain"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> adapter.exists("../escape"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankKeyIsRejected() {
|
||||
assertThatThrownBy(() -> adapter.get(" ")).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.application.storage.StoredObject;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.containers.wait.strategy.Wait;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
/**
|
||||
* Real S3-protocol round-trip for {@link S3ObjectStorageAdapter} against a Testcontainers MinIO
|
||||
* (endpoint override + path-style access — the exact wiring {@link ObjectStorageConfig} applies).
|
||||
* Skipped automatically when Docker is unavailable ({@code disabledWithoutDocker = true}); the pure
|
||||
* key/metadata/URI mapping is covered separately by {@link S3ObjectStorageAdapterTest}.
|
||||
*/
|
||||
@Testcontainers(disabledWithoutDocker = true)
|
||||
class S3ObjectStorageAdapterIT {
|
||||
|
||||
private static final int MINIO_PORT = 9000;
|
||||
private static final String ACCESS_KEY = "minioadmin";
|
||||
private static final String SECRET_KEY = "minioadmin";
|
||||
private static final String BUCKET = "it-objectstorage";
|
||||
|
||||
@Container
|
||||
@SuppressWarnings("resource")
|
||||
static final GenericContainer<?> MINIO =
|
||||
new GenericContainer<>(DockerImageName.parse("minio/minio:RELEASE.2024-01-16T16-07-38Z"))
|
||||
.withEnv("MINIO_ROOT_USER", ACCESS_KEY)
|
||||
.withEnv("MINIO_ROOT_PASSWORD", SECRET_KEY)
|
||||
.withCommand("server", "/data")
|
||||
.withExposedPorts(MINIO_PORT)
|
||||
.waitingFor(Wait.forHttp("/minio/health/ready").forPort(MINIO_PORT));
|
||||
|
||||
private S3Client s3;
|
||||
private S3ObjectStorageAdapter adapter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
String endpoint = "http://" + MINIO.getHost() + ":" + MINIO.getMappedPort(MINIO_PORT);
|
||||
s3 =
|
||||
S3Client.builder()
|
||||
.endpointOverride(URI.create(endpoint))
|
||||
.region(Region.US_EAST_1)
|
||||
.forcePathStyle(true)
|
||||
.credentialsProvider(
|
||||
StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY)))
|
||||
.build();
|
||||
adapter = new S3ObjectStorageAdapter(s3, BUCKET);
|
||||
adapter.ensureBucketExists();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (s3 != null) {
|
||||
s3.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void putGetExistsDeleteRoundTrip() {
|
||||
byte[] content = "minio-round-trip".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
StoredObject stored = adapter.put("reports/q3.csv", content, "text/csv");
|
||||
|
||||
assertThat(stored.location().toString()).isEqualTo("s3://" + BUCKET + "/reports/q3.csv");
|
||||
assertThat(stored.size()).isEqualTo(content.length);
|
||||
assertThat(adapter.exists("reports/q3.csv")).isTrue();
|
||||
assertThat(adapter.get("reports/q3.csv")).map(String::new).contains("minio-round-trip");
|
||||
|
||||
adapter.delete("reports/q3.csv");
|
||||
|
||||
assertThat(adapter.exists("reports/q3.csv")).isFalse();
|
||||
assertThat(adapter.get("reports/q3.csv")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAndExistsForAbsentKey() {
|
||||
assertThat(adapter.get("nope/missing.bin")).isEmpty();
|
||||
assertThat(adapter.exists("nope/missing.bin")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void ensureBucketExistsIsIdempotent() {
|
||||
adapter.ensureBucketExists(); // second call must not fail
|
||||
adapter.put("k", new byte[] {9}, "application/octet-stream");
|
||||
assertThat(adapter.exists("k")).isTrue();
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package dev.caskeleton.adapter.outbound.objectstorage;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import dev.caskeleton.application.storage.StoredObject;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import software.amazon.awssdk.core.ResponseBytes;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
|
||||
/**
|
||||
* Key/metadata/URI mapping contract for {@link S3ObjectStorageAdapter}, verified against a mocked
|
||||
* {@link S3Client} (no network). The real S3 protocol round-trip is exercised by {@link
|
||||
* S3ObjectStorageAdapterIT} against Testcontainers MinIO.
|
||||
*/
|
||||
class S3ObjectStorageAdapterTest {
|
||||
|
||||
private static final String BUCKET = "ca-skeleton-test";
|
||||
|
||||
@Test
|
||||
void putMapsBucketKeyContentTypeAndReturnsS3Location() {
|
||||
S3Client s3 = mock(S3Client.class);
|
||||
S3ObjectStorageAdapter adapter = new S3ObjectStorageAdapter(s3, BUCKET);
|
||||
byte[] content = "payload".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
StoredObject stored = adapter.put("images/cover.png", content, "image/png");
|
||||
|
||||
ArgumentCaptor<PutObjectRequest> request = ArgumentCaptor.forClass(PutObjectRequest.class);
|
||||
verify(s3).putObject(request.capture(), any(RequestBody.class));
|
||||
assertThat(request.getValue().bucket()).isEqualTo(BUCKET);
|
||||
assertThat(request.getValue().key()).isEqualTo("images/cover.png");
|
||||
assertThat(request.getValue().contentType()).isEqualTo("image/png");
|
||||
|
||||
assertThat(stored.key()).isEqualTo("images/cover.png");
|
||||
assertThat(stored.size()).isEqualTo(content.length);
|
||||
assertThat(stored.contentType()).isEqualTo("image/png");
|
||||
assertThat(stored.location().toString()).isEqualTo("s3://" + BUCKET + "/images/cover.png");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getReturnsBytesForPresentObject() {
|
||||
S3Client s3 = mock(S3Client.class);
|
||||
byte[] content = "downloaded".getBytes(StandardCharsets.UTF_8);
|
||||
when(s3.getObjectAsBytes(any(GetObjectRequest.class)))
|
||||
.thenReturn(ResponseBytes.fromByteArray(GetObjectResponse.builder().build(), content));
|
||||
|
||||
S3ObjectStorageAdapter adapter = new S3ObjectStorageAdapter(s3, BUCKET);
|
||||
|
||||
assertThat(adapter.get("k")).map(String::new).contains("downloaded");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getMapsNoSuchKeyToEmpty() {
|
||||
S3Client s3 = mock(S3Client.class);
|
||||
when(s3.getObjectAsBytes(any(GetObjectRequest.class)))
|
||||
.thenThrow(NoSuchKeyException.builder().message("missing").build());
|
||||
|
||||
S3ObjectStorageAdapter adapter = new S3ObjectStorageAdapter(s3, BUCKET);
|
||||
|
||||
assertThat(adapter.get("absent")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsMapsNoSuchKeyAndNotFoundToFalse() {
|
||||
S3Client noSuchKey = mock(S3Client.class);
|
||||
when(noSuchKey.headObject(any(HeadObjectRequest.class)))
|
||||
.thenThrow(NoSuchKeyException.builder().message("missing").build());
|
||||
assertThat(new S3ObjectStorageAdapter(noSuchKey, BUCKET).exists("absent")).isFalse();
|
||||
|
||||
S3Client notFound = mock(S3Client.class);
|
||||
when(notFound.headObject(any(HeadObjectRequest.class)))
|
||||
.thenThrow(S3Exception.builder().statusCode(404).build());
|
||||
assertThat(new S3ObjectStorageAdapter(notFound, BUCKET).exists("absent")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsReturnsTrueWhenHeadSucceeds() {
|
||||
S3Client s3 = mock(S3Client.class);
|
||||
when(s3.headObject(any(HeadObjectRequest.class)))
|
||||
.thenReturn(HeadObjectResponse.builder().build());
|
||||
|
||||
assertThat(new S3ObjectStorageAdapter(s3, BUCKET).exists("present")).isTrue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user