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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ coordinate is the reactor parent (a `pom`, no classes) — depend on one of the
**À la carte:** import the BOM once, then declare `spring-services-core` plus only the feature
modules you need (`spring-services-slack`, `spring-services-mcp`, `spring-services-email`,
`spring-services-search`, `spring-services-dbbackup`, `spring-services-scim`,
`spring-services-tenant`) without versions:
`spring-services-tenant`, `spring-services-storage`) without versions:

```xml
<dependencyManagement>
Expand Down Expand Up @@ -353,13 +353,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-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.

## Release Process

### SNAPSHOT Publishing (automatic)
Expand Down
31 changes: 31 additions & 0 deletions docs/TODO.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
# TODO

## 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.
- **`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.
- **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.

## Property toggles and consumer overridability for core security beans

Per-feature `@ConditionalOnMissingBean` / `@ConditionalOnProperty` for all library beans, so
Expand Down
2 changes: 2 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
<module>spring-services-mcp</module>
<module>spring-services-scim</module>
<module>spring-services-tenant</module>
<module>spring-services-storage</module>
<module>spring-services-all</module>
<module>spring-services-bom</module>
</modules>
Expand All @@ -60,6 +61,7 @@
<slack-api-client.version>1.45.3</slack-api-client.version>
<wiremock.version>3.10.0</wiremock.version>
<mcp-sdk.version>0.18.3</mcp-sdk.version>
<awssdk.version>2.55.5</awssdk.version>
</properties>

</project>
5 changes: 5 additions & 0 deletions spring-services-all/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@
<artifactId>spring-services-tenant</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.open-elements</groupId>
<artifactId>spring-services-storage</artifactId>
<version>${project.version}</version>
</dependency>

<!-- Test dependencies: the aggregate test boots the full classpath against Postgres. -->
<dependency>
Expand Down
5 changes: 5 additions & 0 deletions spring-services-bom/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@
<artifactId>spring-services-tenant</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.open-elements</groupId>
<artifactId>spring-services-storage</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.open-elements</groupId>
<artifactId>spring-services-all</artifactId>
Expand Down
37 changes: 37 additions & 0 deletions spring-services-storage/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>com.open-elements</groupId>
<artifactId>spring-services</artifactId>
<version>1.5.0-SNAPSHOT</version>
</parent>

<artifactId>spring-services-storage</artifactId>

<name>Spring Services Storage</name>
<description>Optional object-store feature module for spring-services</description>
<url>https://github.com/OpenElementsLabs/spring-services</url>

<dependencies>
<dependency>
<groupId>com.open-elements</groupId>
<artifactId>spring-services-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<version>${awssdk.version}</version>
</dependency>

<!-- Test dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.openelements.spring.base.services.storage;

/** Thrown when an object is read but no object exists under the key. */
public class ObjectNotFoundException extends RuntimeException {

/**
* Creates an exception naming the key that has no object.
*
* @param key the key that was read
*/
public ObjectNotFoundException(String key) {
super("No object found for key: " + key);
}

/**
* Creates an exception naming the key that has no object, keeping the store's own report of the
* miss as the cause.
*
* @param key the key that was read
* @param cause the store's exception reporting the missing object
*/
public ObjectNotFoundException(String key, Throwable cause) {
super("No object found for key: " + key, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.openelements.spring.base.services.storage;


import java.io.InputStream;
import java.time.Instant;
import java.util.OptionalLong;
import java.util.stream.Stream;

/**
* Abstraction over an S3-compatible object store. The single implementation targets AWS S3, Hetzner
* Object Storage and Record Store through an explicit endpoint and path-style addressing.
*
* <p>Writes stream without buffering the whole payload; reads return the store's response stream
* directly. Audio of any length must move through here without being materialised in the backend's
* heap or on its disk.
*/
public interface ObjectStore {

/**
* Writes a stream under a key. The length need not be known in advance; the data is uploaded in
* bounded parts so an hour of audio never lands in memory.
*
* @param key the object key
* @param data the payload; fully consumed and closed by the store
* @param contentType the object's content type
*/
void put(String key, InputStream data, String contentType);

/**
* Opens the object for reading.
*
* @param key the object key
* @return the object's content stream (caller closes it)
* @throws ObjectNotFoundException if no object exists under the key
*/
InputStream get(String key);

/**
* Opens a byte range of the object. A range extending past the end returns the available bytes
* rather than failing.
*
* @param key the object key
* @param offset the first byte to return
* @param length the maximum number of bytes to return
* @return the requested slice (caller closes it)
* @throws ObjectNotFoundException if no object exists under the key
*/
InputStream get(String key, long offset, long length);

/**
* The size of the object, or empty if it does not exist — distinguishable from an object of
* length zero.
*
* @param key the object key
* @return the size in bytes, or empty if there is no such object
*/
OptionalLong size(String key);

/**
* Deletes the object. Idempotent: deleting a missing key succeeds.
*
* @param key the object key
* @return {@code true} if an object was removed, {@code false} if there was nothing to remove
*/
boolean delete(String key);

/**
* Lists objects under a prefix.
*
* @param prefix the key prefix
* @return the matching objects
*/
Stream<StoredObject> list(String prefix);

/**
* Aborts incomplete multipart uploads initiated before the given moment. Such uploads are
* invisible to {@link #list(String)} — only reachable through the multipart-uploads listing — so
* the orphan sweep must reclaim them separately or they accumulate unreported.
*
* @param threshold abort uploads initiated before this instant
* @return the number of uploads aborted
*/
int abortIncompleteUploadsOlderThan(Instant threshold);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.openelements.spring.base.services.storage;

import org.jspecify.annotations.Nullable;

/** Thrown when an object-store operation fails for a reason other than a missing object. */
public class ObjectStoreException extends RuntimeException {

/**
* Creates an exception describing a failed operation.
*
* @param message what the store could not do
* @param cause the underlying failure, or {@code null} where the store itself is the origin
*/
public ObjectStoreException(String message, @Nullable Throwable cause) {
super(message, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.openelements.spring.base.services.storage;

import java.time.Instant;
import java.util.Objects;

/**
* A stored object as listed by {@link ObjectStore#list(String)}.
*
* @param key the object key
* @param size the object size in bytes
* @param lastModified when the object was last written
*/
public record StoredObject(String key, long size, Instant lastModified) {

/**
* Validates the components.
*
* @throws NullPointerException if {@code key} or {@code lastModified} is {@code null}
* @throws IllegalArgumentException if {@code size} is negative
*/
public StoredObject {
Objects.requireNonNull(key, "key must not be null");
if (size < 0) {
throw new IllegalArgumentException("size must not be negative, but was " + size);
}
Objects.requireNonNull(lastModified, "lastModified must not be null");
}
}
Loading
Loading