diff --git a/README.md b/README.md index 1d96034..52a2fc1 100644 --- a/README.md +++ b/README.md @@ -138,11 +138,11 @@ 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} +openelements.storage.s3.endpoint=https://s3.eu-central-1.amazonaws.com # required for type=s3 +openelements.storage.s3.bucket=my-objects # required +openelements.storage.s3.access-key=${S3_ACCESS_KEY} # required +openelements.storage.s3.secret-key=${S3_SECRET_KEY} # required +openelements.storage.s3.region=eu-central-1 # optional, default us-east-1 # …or a local directory openelements.storage.type=file diff --git a/docs/releases/upgrade-to-1.5.md b/docs/releases/upgrade-to-1.5.md index a76a318..49cffd5 100644 --- a/docs/releases/upgrade-to-1.5.md +++ b/docs/releases/upgrade-to-1.5.md @@ -55,16 +55,21 @@ distinguishes them — and classpath order must not be what decides where your o ```properties openelements.storage.type=s3 openelements.storage.s3.endpoint=https://s3.eu-central-1.amazonaws.com -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} + +# Optional. Defaults to us-east-1, which providers that ignore the region accept. +openelements.storage.s3.region=eu-central-1 ``` -All five are required for `type=s3`. Credentials are high-value secrets — provide them from -environment variables or a secret manager, never in plaintext config. Path-style addressing is forced -and chunked encoding is disabled, because several S3-compatible providers support neither -virtual-host addressing nor trailing checksums. +Endpoint, bucket and credentials are required for `type=s3`. The **region is optional**: it belongs +to the request *signature*, not to the address, so SigV4 needs some string there but only AWS derives +meaning from it — on AWS it must match the bucket's region, while Hetzner Object Storage and similar +providers accept whatever is sent. Credentials are high-value secrets — provide them from environment +variables or a secret manager, never in plaintext config. Path-style addressing is forced and chunked +encoding is disabled, because several S3-compatible providers support neither virtual-host addressing +nor trailing checksums. **A local directory:** 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 index 1129dc0..173b9be 100644 --- 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 @@ -4,6 +4,7 @@ import java.util.Objects; import org.jspecify.annotations.Nullable; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; /** * Configuration for the object store, bound from the {@code openelements.storage.*} namespace. @@ -67,30 +68,43 @@ public File requiredFile() { * 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. + *

Endpoint, bucket and credentials are required, and a missing one fails start-up rather than + * start-up succeeding with a store the application cannot actually write to. The region is the + * exception — see {@link #region()}. * * @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 region the region to sign requests for. It belongs to the signature, not to + * the address: SigV4 needs some region string, which is why there is a default, + * but only AWS derives meaning from it. Providers like Hetzner Object Storage + * accept whatever is sent, so configuring it there would be ceremony. Set it + * when your provider cares — on AWS it must match the bucket's region. * @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) { + public record S3(String endpoint, @DefaultValue(DEFAULT_REGION) String region, String bucket, + String accessKey, String secretKey) { /** - * Validates that nothing is missing. + * The region used when none is configured — the value S3-compatible providers conventionally + * accept and ignore. + */ + public static final String DEFAULT_REGION = "us-east-1"; + + /** + * Validates that nothing required is missing. * - * @throws NullPointerException if any component was not configured + * @throws NullPointerException if a required component was not configured */ public S3 { required(endpoint, "endpoint"); - required(region, "region"); required(bucket, "bucket"); required(accessKey, "access-key"); required(secretKey, "secret-key"); + // Not "required": the binder fills it from DEFAULT_REGION, so a null here means the + // record was constructed directly rather than bound, and Region.of would fail later. + Objects.requireNonNull(region, "region must not be null"); } private static void required(final @Nullable String value, final String name) { 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 index 30fab38..848afee 100644 --- 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 @@ -76,6 +76,32 @@ void s3() { .isInstanceOf(S3ObjectStore.class)); } + @Test + @DisplayName("type=s3 works without a region, which only AWS derives meaning from") + void s3WithoutARegion() { + contextRunner.withPropertyValues( + "openelements.storage.type=s3", + "openelements.storage.s3.endpoint=https://fsn1.your-objectstorage.com", + "openelements.storage.s3.bucket=objects", + "openelements.storage.s3.access-key=key", + "openelements.storage.s3.secret-key=secret") + .run(context -> { + assertThat(context).hasNotFailed().hasSingleBean(ObjectStore.class); + assertThat(context.getBean(StorageProperties.class).requiredS3().region()) + .isEqualTo(StorageProperties.S3.DEFAULT_REGION); + }); + } + + @Test + @DisplayName("A configured region is kept, for the providers that do care") + void s3WithAnExplicitRegion() { + contextRunner.withPropertyValues(s3Properties()) + .run(context -> assertThat(context).hasNotFailed() + .getBean(StorageProperties.class) + .extracting(properties -> properties.requiredS3().region()) + .isEqualTo("eu-central-1")); + } + @Test @DisplayName("Only the selected store is registered, not the other two") void onlyTheSelectedOne() { @@ -110,7 +136,7 @@ void s3WithIncompleteSettings() { .run(context -> assertThat(context).hasFailed() .getFailure() .rootCause() - .hasMessageContaining("openelements.storage.s3.region is required")); + .hasMessageContaining("openelements.storage.s3.bucket is required")); } @Test