Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -353,17 +376,18 @@ 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
```

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

Expand Down
48 changes: 23 additions & 25 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,7 +29,9 @@
* <li>the core library persistence resolves ({@link UserRepository}, System User bootstrapped);
* <li>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};
* <li>the storage module, which deliberately does <em>not</em> self-activate by classpath presence,
* registers no {@link ObjectStore}.
* </ul>
*/
@SpringBootTest(classes = AggregateApp.class)
Expand Down Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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());
}
}

This file was deleted.

Loading
Loading