From 16d35ea3916d04fbbbf0811cc8c7ddf438afab89 Mon Sep 17 00:00:00 2001 From: Hendrik Ebbers Date: Fri, 25 Sep 2026 09:04:19 +0200 Subject: [PATCH] feat(storage): auto-configure the object store from openelements.storage.* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 for the storage module: the ObjectStore implementations are now wired by Spring instead of waiting for a consumer to instantiate them, and they read the reactor's own property namespace. Activation is a choice, not a switch. The module ships three implementations and all three are on the classpath at once, so @ConditionalOnClass — how every other feature module decides — cannot tell them apart and classpath order must not. openelements.storage.type names one; unset, nothing is registered. @ConditionalOnMissingBean lets an application's own ObjectStore win. - StorageAutoConfiguration carries the @Bean methods itself rather than importing a @Configuration, following ApplicationInfoAutoConfiguration: @ConditionalOnMissingBean is only reliable while auto-configurations are processed. - StorageProperties replaces the origin application's storage.s3.* @Value placeholders with openelements.storage.*, and keeps the fail-fast the @Value form had: a missing setting for the selected type fails start-up naming the property, rather than starting a service that cannot store. - S3Config becomes S3Clients — the client wiring moved to the auto-configuration, leaving a Spring-free helper and, with it, a services.storage.* tree that is plain Java. - The aggregate test now also asserts the module stays inert under a full classpath without the property, which is the claim that would otherwise regress silently. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 32 +++- docs/TODO.md | 48 +++--- .../AggregateStarterIntegrationTest.java | 13 +- .../base/services/storage/s3/S3Clients.java | 39 +++++ .../base/services/storage/s3/S3Config.java | 100 ------------ .../storage/StorageAutoConfiguration.java | 108 +++++++++++++ .../base/storage/StorageProperties.java | 117 ++++++++++++++ .../spring/base/storage/package-info.java | 7 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../storage/StorageAutoConfigurationTest.java | 152 ++++++++++++++++++ 10 files changed, 487 insertions(+), 130 deletions(-) create mode 100644 spring-services-storage/src/main/java/com/openelements/spring/base/services/storage/s3/S3Clients.java delete mode 100644 spring-services-storage/src/main/java/com/openelements/spring/base/services/storage/s3/S3Config.java create mode 100644 spring-services-storage/src/main/java/com/openelements/spring/base/storage/StorageAutoConfiguration.java create mode 100644 spring-services-storage/src/main/java/com/openelements/spring/base/storage/StorageProperties.java create mode 100644 spring-services-storage/src/main/java/com/openelements/spring/base/storage/package-info.java create mode 100644 spring-services-storage/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 spring-services-storage/src/test/java/com/openelements/spring/base/storage/StorageAutoConfigurationTest.java diff --git a/README.md b/README.md index 14cace6..1d96034 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,29 @@ openelements.db-backup.base-url=https://db-backup.internal:8081 # required whe openelements.db-backup.api-token=${DB_BACKUP_API_TOKEN} # required for authenticated calls ``` +The **object store** (`spring-services-storage`) opts in the same way, but with a choice rather than a +switch: the module ships three implementations and `openelements.storage.type` says which one to +register. With the property unset no `ObjectStore` bean exists at all. + +```properties +# S3 or any S3-compatible endpoint +openelements.storage.type=s3 +openelements.storage.s3.endpoint=https://s3.eu-central-1.amazonaws.com # all five required for type=s3 +openelements.storage.s3.region=eu-central-1 +openelements.storage.s3.bucket=my-objects +openelements.storage.s3.access-key=${S3_ACCESS_KEY} +openelements.storage.s3.secret-key=${S3_SECRET_KEY} + +# …or a local directory +openelements.storage.type=file +openelements.storage.file.root=/var/lib/my-app/objects # required for type=file + +# …or on the heap, for tests and local development only — objects do not survive a restart +openelements.storage.type=memory +``` + +Declaring your own `ObjectStore` bean makes the library back off, whatever `type` says. + If a feature stays disabled, none of its beans are created and no connection settings are needed. Secrets (`master-key`, `api-token`) must come from environment variables or secret management, never from committed configuration. @@ -353,7 +376,7 @@ spring-services/ — reactor parent (packaging=pom) ├── spring-services-dbbackup — db-backup sidecar client (RestClient, no extra dep) ├── spring-services-scim — SCIM 2.0 Users provider (opt-in via openelements.scim.token) ├── spring-services-tenant — row-level multi-tenancy (self-activates on the classpath) -├── spring-services-storage — object store: S3, file system, in-memory (→ AWS SDK v2) +├── spring-services-storage — object store: S3, file, in-memory (opt-in via openelements.storage.type) ├── spring-services-all — everything bundle (depends on all modules; no config of its own) └── spring-services-bom — bill of materials for lockstep versioning ``` @@ -361,9 +384,10 @@ spring-services/ — reactor parent (packaging=pom) Each optional feature module ships its own `@AutoConfiguration` guarded by `@ConditionalOnClass`, so it self-activates when present and never pulls its heavy dependency into a consumer that skips it. -`spring-services-storage` is the one exception so far: it ships the `ObjectStore` implementations but -no auto-configuration, because an application has to choose one of them — declaring the implementation -it wants as a bean is that choice. Picking by classpath order would make it by accident. +`spring-services-storage` is guarded differently, because `@ConditionalOnClass` cannot help there: its +three `ObjectStore` implementations are all on the classpath at once, so nothing about the classpath +distinguishes them. `openelements.storage.type` makes the choice explicit instead, and an application +that declares its own `ObjectStore` overrides it. ## Release Process diff --git a/docs/TODO.md b/docs/TODO.md index caea1a9..8e93a1c 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,34 +2,32 @@ ## Finish the storage module (spring-services-storage) -The module currently holds the `ObjectStore` API and its three implementations, lifted from an -application that used them, and nothing more: it compiles and is part of the reactor, but it is not -yet a spring-services feature module in the sense the other nine are. Open work, roughly in order: - -- **Auto-configuration.** `S3Config` is a plain `@Configuration` that no - `AutoConfiguration.imports` file names, so it is inert unless a consumer component-scans it. It - needs a `StorageAutoConfiguration` like every other module, and that raises the question the - README note already records: which implementation activates, and on what condition. `s3` vs - `file` cannot be decided by `@ConditionalOnClass` — both are always on the classpath. -- **Property namespace.** The `@Value` placeholders are `storage.s3.endpoint`, `.region`, - `.access-key`, `.secret-key` and `.bucket` — the origin application's namespace. The repo's is - `openelements.*`, and the values belong in a `@ConfigurationProperties` record rather than five - `@Value` parameters. -- **Tests.** The sources arrived without any. `FileObjectStore` alone justifies several: the - traversal guard on keys, the scratch-then-move visibility guarantee, ranged reads past the end, - and the incomplete-upload sweep. +The module has its auto-configuration and its `openelements.storage.*` namespace. What is still open +is everything below the wiring: + +- **Tests for the implementations.** Only the auto-configuration is covered. `FileObjectStore` alone + justifies several: the traversal guard on keys, the scratch-then-move visibility guarantee, ranged + reads past the end, and the incomplete-upload sweep. `S3ObjectStore`'s multipart boundary (the + switch from a single `PutObject` to a multipart upload at exactly `PART_SIZE_BYTES`) is the other + untested edge that matters. - **`InMemoryObjectStore` is public API here, not a test fixture.** Its `failDeletes`, `failPuts`, - `lastGetOffset` and `lastGetLength` are public mutable fields — fine inside one application, not - as a published surface. Either give it a proper test-control API or move it to a test artifact. + `lastGetOffset` and `lastGetLength` are public mutable fields — fine inside one application, not as + a published surface. `openelements.storage.type=memory` now makes it selectable in configuration, + which sharpens the question: either give it a proper test-control API or move it to a test artifact + and drop the `memory` type. - **The AWS SDK is a hard dependency.** A consumer that only wants `FileObjectStore` still pulls `software.amazon.awssdk:s3`. `optional` would stop that, at the cost of making the S3 path require - an explicit declaration. -- **Javadoc still describes the origin application.** It refers to `OrphanSweep`, to "audio" as the - payload, and to Record Store as the target — none of which mean anything to a reader of this - library. The reasoning behind the prose is worth keeping; the nouns are not. - -**Context:** The module was created by moving the sources in as a deliberate first step — get -everything into the reactor compiling, decide the Spring-facing design afterwards. + an explicit declaration — and the S3 beans would then need moving into a nested + `@ConditionalOnClass(S3Client.class)` configuration, since `StorageAutoConfiguration` names + `S3Client` in a method signature today. +- **Javadoc still describes the origin application.** `ObjectStore`, `FileObjectStore` and + `InMemoryObjectStore` refer to `OrphanSweep`, to "audio" as the payload, and to Record Store as the + target — none of which mean anything to a reader of this library. The reasoning behind the prose is + worth keeping; the nouns are not. (`S3Clients` was cleaned up when it was split out of the former + `S3Config`.) + +**Context:** The module arrived by moving sources in from an application (step 1), then got its +Spring-facing design (step 2). The remainder is the cleanup that neither step needed. ## Property toggles and consumer overridability for core security beans diff --git a/spring-services-all/src/test/java/com/example/aggregate/AggregateStarterIntegrationTest.java b/spring-services-all/src/test/java/com/example/aggregate/AggregateStarterIntegrationTest.java index f615d17..8ebddd0 100644 --- a/spring-services-all/src/test/java/com/example/aggregate/AggregateStarterIntegrationTest.java +++ b/spring-services-all/src/test/java/com/example/aggregate/AggregateStarterIntegrationTest.java @@ -5,6 +5,7 @@ import com.openelements.spring.base.mcp.McpProperties; import com.openelements.spring.base.services.email.EmailService; import com.openelements.spring.base.services.slack.SlackService; +import com.openelements.spring.base.services.storage.ObjectStore; import com.openelements.spring.base.services.user.SystemUser; import com.openelements.spring.base.services.user.UserRepository; import org.junit.jupiter.api.DisplayName; @@ -28,7 +29,9 @@ *
  • the core library persistence resolves ({@link UserRepository}, System User bootstrapped); *
  • representative beans from multiple optional feature modules are present — {@link SlackService} * (slack), {@link EmailService} (email), and {@link McpProperties} (mcp) — proving every module - * self-activated by classpath presence without any {@code @Import}. + * self-activated by classpath presence without any {@code @Import}; + *
  • the storage module, which deliberately does not self-activate by classpath presence, + * registers no {@link ObjectStore}. * */ @SpringBootTest(classes = AggregateApp.class) @@ -69,4 +72,12 @@ void optionalFeatureModulesSelfActivate() { .as("mcp module must self-activate (properties bound unconditionally)") .isNotEmpty(); } + + @Test + @DisplayName("The storage module registers no ObjectStore until a type is configured") + void storageStaysInertWithoutAType() { + assertThat(context.getBeanNamesForType(ObjectStore.class)) + .as("three implementations are on the classpath; none may be picked by classpath presence") + .isEmpty(); + } } diff --git a/spring-services-storage/src/main/java/com/openelements/spring/base/services/storage/s3/S3Clients.java b/spring-services-storage/src/main/java/com/openelements/spring/base/services/storage/s3/S3Clients.java new file mode 100644 index 0000000..a84f80f --- /dev/null +++ b/spring-services-storage/src/main/java/com/openelements/spring/base/services/storage/s3/S3Clients.java @@ -0,0 +1,39 @@ +package com.openelements.spring.base.services.storage.s3; + +import software.amazon.awssdk.services.s3.S3ClientBuilder; +import software.amazon.awssdk.services.s3.S3Configuration; + +/** Client settings an {@link S3ObjectStore} needs its {@code S3Client} to have been built with. */ +public final class S3Clients { + + private S3Clients() { + } + + /** + * Makes the Java SDK send each request body in one piece instead of {@code aws-chunked}. + * + *

    By default the SDK frames a body as {@code aws-chunked} with a trailing CRC32 + * ({@code x-amz-trailer: x-amz-checksum-crc32}). Not every S3-compatible provider implements + * trailing checksums, and one that does not answers {@code 501 NotImplemented} — so chunked + * encoding is switched off and the body goes out whole, with its checksum in an ordinary header + * the store verifies. + * + *

    This costs one extra pass over each part and no memory: {@link S3ObjectStore} already hands + * the SDK a {@link java.io.ByteArrayInputStream} over a bounded buffer, which is both resettable + * and already resident, so nothing is buffered that was not already there. + * + *

    Nothing else breaks from it: {@code aws-chunked} is an optimisation, so AWS and other + * S3-compatible providers accept a body sent in one piece just as well. + * + *

    Public because a test that builds its own client must be able to build it exactly the way + * the configured bean is built. A test passing against a more lenient client would prove nothing + * about the one the application runs with. + * + * @param builder the builder to configure + * @return the same builder, for chaining + */ + public static S3ClientBuilder withoutChunkedEncoding(final S3ClientBuilder builder) { + return builder.serviceConfiguration( + S3Configuration.builder().chunkedEncodingEnabled(false).build()); + } +} diff --git a/spring-services-storage/src/main/java/com/openelements/spring/base/services/storage/s3/S3Config.java b/spring-services-storage/src/main/java/com/openelements/spring/base/services/storage/s3/S3Config.java deleted file mode 100644 index 4c8e978..0000000 --- a/spring-services-storage/src/main/java/com/openelements/spring/base/services/storage/s3/S3Config.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.openelements.spring.base.services.storage.s3; - -import java.net.URI; - -import com.openelements.spring.base.services.storage.ObjectStore; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -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; -import software.amazon.awssdk.services.s3.S3Configuration; - -/** - * Wires the AWS SDK v2 {@link S3Client} and the {@link ObjectStore}. - * - *

    The bucket and credentials are read through {@code @Value}, which fails start-up when the - * underlying {@code S3_BUCKET} / {@code S3_ACCESS_KEY} / {@code S3_SECRET_KEY} placeholder cannot be - * resolved (unlike {@code @ConfigurationProperties}, which would silently keep the unresolved - * string). A backend that starts without a store would accept uploads it cannot keep. - */ -@Configuration -public class S3Config { - - /** Creates the configuration; Spring instantiates it. */ - public S3Config() { - } - - /** - * The SDK client every request goes through, closed when the context shuts down. - * - * @param endpoint the store's endpoint - * @param region the region to sign requests for - * @param accessKey the access key - * @param secretKey the secret key - * @return the client - */ - @Bean(destroyMethod = "close") - public S3Client s3Client( - @Value("${storage.s3.endpoint}") String endpoint, - @Value("${storage.s3.region}") String region, - @Value("${storage.s3.access-key}") String accessKey, - @Value("${storage.s3.secret-key}") String secretKey) { - return withoutChunkedEncoding(S3Client.builder() - .endpointOverride(URI.create(endpoint)) - .region(Region.of(region)) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create(accessKey, secretKey)))) - // Several S3-compatible providers do not support virtual-host addressing. - .forcePathStyle(true) - .build(); - } - - /** - * Makes the Java SDK send each request body in one piece instead of {@code aws-chunked}. - * - *

    By default the SDK frames a body as {@code aws-chunked} with a trailing CRC32 - * ({@code x-amz-trailer: x-amz-checksum-crc32}). Record Store does not implement trailing - * checksums and answers {@code 501 NotImplemented} (since 0.1.2; earlier versions returned a - * generic {@code 400 InvalidRequest}), so chunked encoding is switched off and the body goes - * out whole, with its checksum in an ordinary header the store verifies. - * - *

    This costs one extra pass over each part and no memory: {@code S3ObjectStore} already - * hands the SDK a {@link java.io.ByteArrayInputStream} over a bounded {@code PART_SIZE_BYTES} - * buffer, which is both resettable and already resident, so nothing is buffered that was not - * already there. - * - *

    The setting is not specific to Record Store in the sense of breaking anything else: - * {@code aws-chunked} is an optimisation, so AWS and other S3-compatible providers accept a - * body sent in one piece just as well. - * - *

    A second departure from the SDK's defaults used to live here: payload signing, because the - * SDK's {@code x-amz-content-sha256: UNSIGNED-PAYLOAD} was refused. Record Store 0.1.2 accepts - * it (record-store#74), so that one is gone and the SDK's default signing applies. - * - * @param builder the builder to configure - * @return the same builder, for chaining - */ - // Public, not package-private: the sweep's integration test lives in the orphan package and - // builds its client through this very helper on purpose, so a test can never pass against a - // client configured more leniently than the one the application bean gets. - public static S3ClientBuilder withoutChunkedEncoding(S3ClientBuilder builder) { - return builder.serviceConfiguration( - S3Configuration.builder().chunkedEncodingEnabled(false).build()); - } - - /** - * The store the application talks to. - * - * @param s3Client the configured client - * @param bucket the bucket every key lives in - * @return the store - */ - @Bean - public ObjectStore objectStore(S3Client s3Client, @Value("${storage.s3.bucket}") String bucket) { - return new S3ObjectStore(s3Client, bucket); - } -} diff --git a/spring-services-storage/src/main/java/com/openelements/spring/base/storage/StorageAutoConfiguration.java b/spring-services-storage/src/main/java/com/openelements/spring/base/storage/StorageAutoConfiguration.java new file mode 100644 index 0000000..264d30e --- /dev/null +++ b/spring-services-storage/src/main/java/com/openelements/spring/base/storage/StorageAutoConfiguration.java @@ -0,0 +1,108 @@ +package com.openelements.spring.base.storage; + +import com.openelements.spring.base.services.storage.ObjectStore; +import com.openelements.spring.base.services.storage.file.FileObjectStore; +import com.openelements.spring.base.services.storage.memory.InMemoryObjectStore; +import com.openelements.spring.base.services.storage.s3.S3Clients; +import com.openelements.spring.base.services.storage.s3.S3ObjectStore; +import java.net.URI; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +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; + +/** + * Auto-configuration for the optional object-store feature. + * + *

    Registered through this module's + * {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}, but — + * unlike the other feature modules — being on the classpath is not enough to activate it. The + * module ships three {@link ObjectStore} implementations and they are all present at once, so + * {@code @ConditionalOnClass} cannot choose between them and classpath order must not. The choice is + * {@code openelements.storage.type}, and with it unset nothing here is registered. + * + *

    Each store is guarded by {@link ConditionalOnMissingBean}, so an application that declares its + * own {@link ObjectStore} keeps it and the library backs off. + * + *

    The beans live on the auto-configuration itself rather than on an imported + * {@code @Configuration} — the shape the other modules use — because {@code @ConditionalOnMissingBean} + * is only reliable while auto-configurations are being processed, which is after the application's + * own beans are known. + */ +@AutoConfiguration +@ConditionalOnProperty(prefix = "openelements.storage", name = "type") +@EnableConfigurationProperties(StorageProperties.class) +public class StorageAutoConfiguration { + + /** Creates the auto-configuration; the store is contributed by one of the {@code @Bean} methods. */ + public StorageAutoConfiguration() { + } + + /** + * The SDK client the S3 store issues requests through, closed when the context shuts down. + * + *

    Path-style addressing is forced because several S3-compatible providers do not support + * virtual-host addressing. + * + * @param properties the storage configuration + * @return the client + */ + @Bean(destroyMethod = "close") + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "openelements.storage", name = "type", havingValue = "s3") + public S3Client storageS3Client(final StorageProperties properties) { + final StorageProperties.S3 s3 = properties.requiredS3(); + return S3Clients.withoutChunkedEncoding(S3Client.builder() + .endpointOverride(URI.create(s3.endpoint())) + .region(Region.of(s3.region())) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(s3.accessKey(), s3.secretKey())))) + .forcePathStyle(true) + .build(); + } + + /** + * The store for {@code openelements.storage.type=s3}. + * + * @param s3Client the client to issue requests through + * @param properties the storage configuration + * @return the store + */ + @Bean + @ConditionalOnMissingBean(ObjectStore.class) + @ConditionalOnProperty(prefix = "openelements.storage", name = "type", havingValue = "s3") + public ObjectStore s3ObjectStore(final S3Client s3Client, final StorageProperties properties) { + return new S3ObjectStore(s3Client, properties.requiredS3().bucket()); + } + + /** + * The store for {@code openelements.storage.type=file}. + * + * @param properties the storage configuration + * @return the store + */ + @Bean + @ConditionalOnMissingBean(ObjectStore.class) + @ConditionalOnProperty(prefix = "openelements.storage", name = "type", havingValue = "file") + public ObjectStore fileObjectStore(final StorageProperties properties) { + return new FileObjectStore(properties.requiredFile().root()); + } + + /** + * The store for {@code openelements.storage.type=memory}, which keeps every object on the heap + * and loses all of them when the process ends. + * + * @return the store + */ + @Bean + @ConditionalOnMissingBean(ObjectStore.class) + @ConditionalOnProperty(prefix = "openelements.storage", name = "type", havingValue = "memory") + public ObjectStore inMemoryObjectStore() { + return new InMemoryObjectStore(); + } +} diff --git a/spring-services-storage/src/main/java/com/openelements/spring/base/storage/StorageProperties.java b/spring-services-storage/src/main/java/com/openelements/spring/base/storage/StorageProperties.java new file mode 100644 index 0000000..1129dc0 --- /dev/null +++ b/spring-services-storage/src/main/java/com/openelements/spring/base/storage/StorageProperties.java @@ -0,0 +1,117 @@ +package com.openelements.spring.base.storage; + +import java.nio.file.Path; +import java.util.Objects; +import org.jspecify.annotations.Nullable; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration for the object store, bound from the {@code openelements.storage.*} namespace. + * + *

    {@link #type()} has no default. The module ships three implementations and no rule could pick + * between them: they are all on the classpath at once, so {@code @ConditionalOnClass} — the way the + * other feature modules decide — cannot tell them apart. Leaving the property unset therefore + * registers nothing at all, and an application that wants a store says which one. + * + * @param type which implementation to register, or {@code null} to register none + * @param s3 settings for {@link Type#S3}; required for that type, ignored otherwise + * @param file settings for {@link Type#FILE}; required for that type, ignored otherwise + */ +@ConfigurationProperties("openelements.storage") +public record StorageProperties( + @Nullable Type type, + @Nullable S3 s3, + @Nullable File file +) { + + /** The implementation an application selects through {@code openelements.storage.type}. */ + public enum Type { + + /** {@link com.openelements.spring.base.services.storage.s3.S3ObjectStore} over an S3-compatible endpoint. */ + S3, + + /** {@link com.openelements.spring.base.services.storage.file.FileObjectStore} over a local directory. */ + FILE, + + /** + * {@link com.openelements.spring.base.services.storage.memory.InMemoryObjectStore}, which keeps + * every object on the heap and loses all of them when the process ends. For tests and local + * development; never for an environment whose objects have to outlive a restart. + */ + MEMORY + } + + /** + * The S3 settings, which {@code openelements.storage.type=s3} requires. + * + * @return the settings + * @throws NullPointerException if no {@code openelements.storage.s3.*} property is configured + */ + public S3 requiredS3() { + return Objects.requireNonNull(s3, + "openelements.storage.s3.* must be configured when openelements.storage.type=s3"); + } + + /** + * The file-system settings, which {@code openelements.storage.type=file} requires. + * + * @return the settings + * @throws NullPointerException if no {@code openelements.storage.file.*} property is configured + */ + public File requiredFile() { + return Objects.requireNonNull(file, + "openelements.storage.file.root must be configured when openelements.storage.type=file"); + } + + /** + * Connection settings for an S3-compatible endpoint. Credentials belong in environment variables + * or a secret manager, never in a checked-in properties file. + * + *

    Every component is required, and a missing one fails start-up rather than start-up + * succeeding with a store the application cannot actually write to. + * + * @param endpoint the endpoint URL, which is explicit because the module targets AWS S3 and + * other S3-compatible providers alike + * @param region the region to sign requests for + * @param bucket the bucket every key lives in + * @param accessKey the access key + * @param secretKey the secret key + */ + public record S3(String endpoint, String region, String bucket, String accessKey, + String secretKey) { + + /** + * Validates that nothing is missing. + * + * @throws NullPointerException if any component was not configured + */ + public S3 { + required(endpoint, "endpoint"); + required(region, "region"); + required(bucket, "bucket"); + required(accessKey, "access-key"); + required(secretKey, "secret-key"); + } + + private static void required(final @Nullable String value, final String name) { + Objects.requireNonNull(value, () -> "openelements.storage.s3." + name + " is required"); + } + } + + /** + * Settings for the file-system store. + * + * @param root the directory the store owns; created if missing + */ + public record File(Path root) { + + /** + * Validates that the root is configured. + * + * @throws NullPointerException if {@code root} was not configured + */ + public File { + Objects.requireNonNull(root, "openelements.storage.file.root is required"); + } + } +} diff --git a/spring-services-storage/src/main/java/com/openelements/spring/base/storage/package-info.java b/spring-services-storage/src/main/java/com/openelements/spring/base/storage/package-info.java new file mode 100644 index 0000000..51504fd --- /dev/null +++ b/spring-services-storage/src/main/java/com/openelements/spring/base/storage/package-info.java @@ -0,0 +1,7 @@ +/** + * Null-marked package (JSpecify): every type is non-null unless annotated {@link org.jspecify.annotations.Nullable}. + */ +@NullMarked +package com.openelements.spring.base.storage; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-services-storage/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-services-storage/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..0cd0284 --- /dev/null +++ b/spring-services-storage/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.openelements.spring.base.storage.StorageAutoConfiguration diff --git a/spring-services-storage/src/test/java/com/openelements/spring/base/storage/StorageAutoConfigurationTest.java b/spring-services-storage/src/test/java/com/openelements/spring/base/storage/StorageAutoConfigurationTest.java new file mode 100644 index 0000000..30fab38 --- /dev/null +++ b/spring-services-storage/src/test/java/com/openelements/spring/base/storage/StorageAutoConfigurationTest.java @@ -0,0 +1,152 @@ +package com.openelements.spring.base.storage; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.openelements.spring.base.services.storage.ObjectStore; +import com.openelements.spring.base.services.storage.file.FileObjectStore; +import com.openelements.spring.base.services.storage.memory.InMemoryObjectStore; +import com.openelements.spring.base.services.storage.s3.S3ObjectStore; +import java.nio.file.Path; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import software.amazon.awssdk.services.s3.S3Client; + +/** + * Activation and selection tests for {@link StorageAutoConfiguration}. + * + *

    The module ships three {@link ObjectStore} implementations, so the interesting behaviour is not + * "does it activate" but "does exactly the configured one activate" — and that nothing activates + * when an application has the module on the classpath without asking for a store. + */ +@DisplayName("StorageAutoConfiguration") +class StorageAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(StorageAutoConfiguration.class)); + + @Test + @DisplayName("Registers no store when openelements.storage.type is unset") + void inertWithoutType() { + contextRunner.run(context -> + assertThat(context).hasNotFailed().doesNotHaveBean(ObjectStore.class)); + } + + @Nested + @DisplayName("selection") + class Selection { + + @Test + @DisplayName("type=memory registers the in-memory store") + void memory() { + contextRunner.withPropertyValues("openelements.storage.type=memory") + .run(context -> assertThat(context).hasNotFailed() + .hasSingleBean(ObjectStore.class) + .getBean(ObjectStore.class) + .isInstanceOf(InMemoryObjectStore.class)); + } + + @Test + @DisplayName("type=file registers the file store under the configured root") + void file(@TempDir final Path root) { + contextRunner.withPropertyValues( + "openelements.storage.type=file", + "openelements.storage.file.root=" + root) + .run(context -> { + assertThat(context).hasNotFailed() + .hasSingleBean(ObjectStore.class) + .getBean(ObjectStore.class) + .isInstanceOf(FileObjectStore.class); + assertThat(root.resolve("objects")).exists(); + }); + } + + @Test + @DisplayName("type=s3 registers the S3 store and its client") + void s3() { + contextRunner.withPropertyValues(s3Properties()) + .run(context -> assertThat(context).hasNotFailed() + .hasSingleBean(S3Client.class) + .hasSingleBean(ObjectStore.class) + .getBean(ObjectStore.class) + .isInstanceOf(S3ObjectStore.class)); + } + + @Test + @DisplayName("Only the selected store is registered, not the other two") + void onlyTheSelectedOne() { + contextRunner.withPropertyValues("openelements.storage.type=memory") + .run(context -> assertThat(context).hasNotFailed() + .doesNotHaveBean(FileObjectStore.class) + .doesNotHaveBean(S3ObjectStore.class) + .doesNotHaveBean(S3Client.class)); + } + } + + @Nested + @DisplayName("misconfiguration fails start-up") + class Misconfiguration { + + @Test + @DisplayName("type=s3 without any openelements.storage.s3.* property") + void s3WithoutSettings() { + contextRunner.withPropertyValues("openelements.storage.type=s3") + .run(context -> assertThat(context).hasFailed() + .getFailure() + .rootCause() + .hasMessageContaining("openelements.storage.s3.*")); + } + + @Test + @DisplayName("type=s3 with an incomplete openelements.storage.s3.* block") + void s3WithIncompleteSettings() { + contextRunner.withPropertyValues( + "openelements.storage.type=s3", + "openelements.storage.s3.endpoint=https://s3.example.com") + .run(context -> assertThat(context).hasFailed() + .getFailure() + .rootCause() + .hasMessageContaining("openelements.storage.s3.region is required")); + } + + @Test + @DisplayName("type=file without openelements.storage.file.root") + void fileWithoutRoot() { + contextRunner.withPropertyValues("openelements.storage.type=file") + .run(context -> assertThat(context).hasFailed() + .getFailure() + .rootCause() + .hasMessageContaining("openelements.storage.file.root")); + } + } + + @Test + @DisplayName("An application's own ObjectStore wins and the library backs off") + void consumerBeanWins() { + contextRunner.withPropertyValues("openelements.storage.type=memory") + .withBean("applicationStore", ObjectStore.class, ConsumerStore::new) + .run(context -> assertThat(context).hasNotFailed() + .hasSingleBean(ObjectStore.class) + .getBean(ObjectStore.class) + .isInstanceOf(ConsumerStore.class)); + } + + private static String[] s3Properties() { + return new String[]{ + "openelements.storage.type=s3", + "openelements.storage.s3.endpoint=https://s3.example.com", + "openelements.storage.s3.region=eu-central-1", + "openelements.storage.s3.bucket=objects", + "openelements.storage.s3.access-key=key", + "openelements.storage.s3.secret-key=secret" + }; + } + + /** Stands in for an application that brings its own store; never called. */ + private static final class ConsumerStore extends InMemoryObjectStore { + } +}