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
8 changes: 7 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,10 @@ jobs:
key: maven-${{ hashFiles('pom.xml') }}
restore-keys: maven-
- name: Build and test
run: ./mvnw clean verify
# -Pfull-build is the release-equivalent build: besides tests it generates
# the Javadoc jar, sources jar and CycloneDX SBOM, so CI catches Javadoc/
# doclint problems (and a broken source/SBOM attachment) instead of only
# test failures — the tag-triggered release runs the same -Pfull-build goal.
# failOnWarnings turns any Javadoc warning into a build failure so they
# cannot regress silently.
run: ./mvnw -Pfull-build clean verify -Dmaven.javadoc.failOnWarnings=true
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,7 @@
TranslationConfig.class
})
public class FullSpringServiceConfig {

/** Creates the aggregate configuration; all feature configurations are wired via {@code @Import}. */
public FullSpringServiceConfig() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@
@Import({FullSpringServiceConfig.class, SpringServicesCoreAutoConfiguration.PackageRegistrar.class})
public class SpringServicesCoreAutoConfiguration {

/**
* Creates the auto-configuration; all beans are contributed via the imported configurations.
*/
public SpringServicesCoreAutoConfiguration() {}

/** Root package of the library — scanned for both entities and Spring Data repositories. */
static final String LIBRARY_ROOT_PACKAGE = "com.openelements.spring.base";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ public abstract class AbstractDbBackedDataService<E extends DbEntity, D extends
private final boolean publishEvents;

/**
* Constructs a new service that publishes lifecycle events for every create, update and delete.
*
* @param eventPublisher Spring's event publisher, used to broadcast lifecycle events
*/
public AbstractDbBackedDataService(@NonNull final ApplicationEventPublisher eventPublisher) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,25 @@
@MappedSuperclass
public abstract class AbstractEntity implements DbEntity {

/** The generated UUID primary key, assigned by JPA on persist. */
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", updatable = false, nullable = false)
private UUID id;

/** The creation timestamp, set once when the entity is first persisted. */
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;

/** The last-update timestamp, refreshed on every update. */
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;

/** Creates a new entity; the id and audit timestamps are assigned by JPA on persist. */
protected AbstractEntity() {}

/**
* {@inheritDoc}
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ public interface DataService<D extends WithId> {
*/
@NonNull Optional<D> findById(@NonNull final UUID id);

/**
* Looks up a DTO by its id, failing if none exists.
*
* @param id the id to look up
* @return the matching DTO
* @throws IllegalArgumentException if no entity with that id exists
*/
default @NonNull D findByIdOrThrow(@NonNull final UUID id) {
Objects.requireNonNull(id, "id must not be null");
return findById(id).orElseThrow(() -> new IllegalArgumentException("No such id " + id));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,25 @@
@NoRepositoryBean
public interface EntityRepository<E extends DbEntity> extends JpaRepository<E, UUID> {

/**
* Returns the entities matching the given ids as a stream.
*
* @param ids the ids to look up
* @return a stream of the matching entities, in no particular order
*/
@NonNull
default Stream<E> findAllByIds(@NonNull final Iterable<UUID> ids) {
Objects.requireNonNull(ids, "ids must not be null");
return findAllById(ids).stream();
}

/**
* Returns the entity with the given id, failing if none exists.
*
* @param id the id to look up
* @return the matching entity
* @throws IllegalArgumentException if no entity with that id exists
*/
@NonNull
default E findByIdOrThrow(@NonNull final UUID id) {
Objects.requireNonNull(id, "ids must not be null");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,37 @@
*/
public interface WithId {

/**
* Collects the ids of the given objects into an unmodifiable set.
*
* @param <T> the type of the objects
* @param data the objects whose ids to collect
* @return an unmodifiable set of the ids
*/
@NonNull
static <T extends WithId> Set<UUID> toIdSet(@NonNull final Iterable<T> data) {
return toIdStream(data).collect(Collectors.toUnmodifiableSet());
}

/**
* Collects the ids of the given objects into a list, preserving iteration order.
*
* @param <T> the type of the objects
* @param data the objects whose ids to collect
* @return a list of the ids
*/
@NonNull
static <T extends WithId> List<UUID> toIdList(@NonNull final Iterable<T> data) {
return toIdStream(data).toList();
}

/**
* Streams the ids of the given objects, preserving iteration order.
*
* @param <T> the type of the objects
* @param data the objects whose ids to stream
* @return a stream of the ids
*/
@NonNull
static <T extends WithId> Stream<UUID> toIdStream(@NonNull final Iterable<T> data) {
Objects.requireNonNull(data, "data must not be null");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,46 @@
import com.openelements.spring.base.data.DbEntity;
import java.util.Optional;

/**
* Mixin for entities that carry a single embedded image, stored as raw bytes plus a MIME content
* type. Provides default methods that bridge between the raw storage columns and the richer {@link
* ImageData} and {@link ImageType} value types.
*/
public interface EntityWithImage extends DbEntity {

/**
* Returns the raw bytes of the stored image.
*
* @return the image bytes, or {@code null} if no image is stored
*/
byte[] getRawImageData();

/**
* Sets the raw bytes of the stored image.
*
* @param rawImageData the image bytes, or {@code null} to clear the image
*/
void setRawImageData(byte[] rawImageData);

/**
* Returns the MIME content type of the stored image.
*
* @return the content type, or {@code null} if no image is stored
*/
String getContentType();

/**
* Sets the MIME content type of the stored image.
*
* @param contentType the content type, or {@code null} to clear it
*/
void setContentType(String contentType);

/**
* Returns the stored image as an {@link ImageData} value, if both bytes and type are present.
*
* @return the image data, or an empty {@link Optional} if no complete image is stored
*/
default Optional<ImageData> imageData() {
final byte[] rawImageData = getRawImageData();
if (rawImageData == null) {
Expand All @@ -26,18 +56,33 @@ default Optional<ImageData> imageData() {
return Optional.of(imageData);
}

/**
* Returns the {@link ImageType} derived from the stored content type.
*
* @return the image type, or {@code null} if no content type is stored
*/
default ImageType getImageType() {
return Optional.ofNullable(getContentType())
.map(c -> ImageType.fromContentType(c))
.orElse(null);
}

/**
* Sets the stored content type from the given {@link ImageType}.
*
* @param imageType the image type, or {@code null} to clear the content type
*/
default void setImageType(ImageType imageType) {
final String contentType =
Optional.ofNullable(imageType).map(t -> t.getContentType()).orElse(null);
setContentType(contentType);
}

/**
* Stores the given image, updating both the raw bytes and the content type.
*
* @param imageData the image to store, or {@code null} to clear the image
*/
default void setImageData(ImageData imageData) {
if (imageData == null) {
setRawImageData(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public record ImageData(@NonNull byte[] data, @NonNull ImageType imageType) {
*/
public static final int MAX_IMAGE_SIZE = 20 * 1024 * 1024;

/**
* Validates the components, rejecting {@code null}, empty, and oversized image data.
*
* @throws IllegalArgumentException if {@code data} is empty or larger than {@link #MAX_IMAGE_SIZE}
*/
public ImageData {
Objects.requireNonNull(data, "data must not be null");
Objects.requireNonNull(imageType, "imageType must not be null");
Expand All @@ -33,32 +38,73 @@ public record ImageData(@NonNull byte[] data, @NonNull ImageType imageType) {
}
}

/**
* Creates an instance from raw bytes and a MIME content type string.
*
* @param data the image bytes
* @param contentType the MIME content type (e.g. "image/png")
* @return the image data
*/
public static ImageData of(@NonNull byte[] data, @NonNull String contentType) {
Objects.requireNonNull(contentType, "contentType must not be null");
return new ImageData(data, ImageType.fromContentType(contentType));
}

/**
* Creates an instance from an uploaded multipart file.
*
* @param file the uploaded file
* @return the image data
* @throws IOException if the file bytes cannot be read
*/
public static ImageData of(@NonNull MultipartFile file) throws IOException {
Objects.requireNonNull(file, "file must not be null");
return of(file.getBytes(), file.getContentType());
}

/**
* Returns the MIME content type of this image.
*
* @return the content type (e.g. "image/png")
*/
public String contentType() {
return imageType.getContentType();
}

/**
* Returns the content type of this image as a Spring {@link MediaType}.
*
* @return the media type of this image
*/
public MediaType mediaType() {
return imageType.toMediaType();
}

/**
* Wraps this image in a {@code 200 OK} HTTP response with the appropriate content type.
*
* @return an HTTP response carrying the image bytes
*/
public ResponseEntity<byte[]> toHttpResponse() {
return ResponseEntity.ok().contentType(MediaType.parseMediaType(contentType())).body(data());
}

/**
* Converts this image to JPEG.
*
* @return a new instance holding the JPEG-encoded image
*/
public ImageData asJpeg() {
return ImageUtilities.toJpeg(this);
}

/**
* Converts this image to JPEG, scaling it to fit within the given bounds.
*
* @param maxWidth the maximum width in pixels
* @param maxHeight the maximum height in pixels
* @return a new instance holding the scaled JPEG-encoded image
*/
public ImageData asJpeg(int maxWidth, int maxHeight) {
return ImageUtilities.toJpeg(this, maxWidth, maxHeight);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@

import org.springframework.http.MediaType;

/** Supported image formats, each paired with its MIME content type. */
public enum ImageType {
/** Portable Network Graphics ({@code image/png}). */
PNG("image/png"),
/** JPEG ({@code image/jpeg}). */
JPEG("image/jpeg"),
/** WebP ({@code image/webp}). */
WEBP("image/webp"),
/** High Efficiency Image Coding ({@code image/heic}). */
HEIC("image/heic"),
/** High Efficiency Image File format ({@code image/heif}). */
HEIF("image/heif"),
/** Scalable Vector Graphics ({@code image/svg+xml}). */
SVG("image/svg+xml");

private final String contentType;
Expand All @@ -16,6 +23,13 @@ public enum ImageType {
this.contentType = contentType;
}

/**
* Resolves the image type matching the given MIME content type.
*
* @param contentType the MIME content type to resolve
* @return the matching image type
* @throws IllegalArgumentException if no image type matches the content type
*/
public static ImageType fromContentType(String contentType) {
for (ImageType type : ImageType.values()) {
if (type.contentType.equals(contentType)) {
Expand All @@ -25,10 +39,20 @@ public static ImageType fromContentType(String contentType) {
throw new IllegalArgumentException(contentType + " is not a valid image content type");
}

/**
* Returns the MIME content type associated with this image type.
*
* @return the MIME content type (e.g. "image/png")
*/
public String getContentType() {
return contentType;
}

/**
* Returns this image type's content type as a Spring {@link MediaType}.
*
* @return the media type for this image type
*/
public MediaType toMediaType() {
return MediaType.parseMediaType(contentType);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,27 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Utility that verifies whether the running JVM can decode HEIC/HEIF images.
*
* <p>Checks that an {@link ImageIO} reader for the format is registered and that a bundled probe
* image can actually be decoded, so that missing native libraries are detected at startup rather
* than on the first upload.
*/
public class HeicSupportCheck {

private static final Logger log = LoggerFactory.getLogger(HeicSupportCheck.class);
private static final String PROBE_RESOURCE = "/heic-probe/sample.heic";

/** Creates a new instance; all functionality is exposed through static methods. */
public HeicSupportCheck() {}

/**
* Verifies that HEIC/HEIF images can be decoded by the current runtime.
*
* @return {@code true} if a reader is registered and the bundled probe image decodes,
* {@code false} otherwise
*/
public static boolean verifyHeicSupport() {
final boolean readerRegistered =
Arrays.stream(ImageIO.getReaderFormatNames())
Expand Down
Loading