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
33 changes: 33 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,27 @@ services:
timeout: 10s
retries: 15

localstack:
image: localstack/localstack:3.8
container_name: shopsphere-localstack
ports:
# S3 (and other AWS APIs) on the edge port. Presigned URLs are signed against
# HOSTNAME_EXTERNAL=localhost so a link opened in your browser actually resolves.
- "4566:4566"
environment:
SERVICES: s3
# Enforce presigned-URL signatures/expiry (LocalStack skips this by default), matching
# real S3 and the test setup in SharedContainers.
S3_SKIP_SIGNATURE_VALIDATION: "0"
volumes:
# Creates the product-image bucket on startup (see scripts/localstack-init.sh).
- ./scripts/localstack-init.sh:/etc/localstack/init/ready.d/init.sh
healthcheck:
test: ["CMD-SHELL", "awslocal s3 ls s3://shopsphere-product-images >/dev/null 2>&1 || exit 1"]
interval: 5s
timeout: 5s
retries: 15

app:
# Only started with `docker compose --profile full up -d --build`.
profiles: ["full"]
Expand All @@ -81,6 +102,8 @@ services:
condition: service_healthy
kafka:
condition: service_healthy
localstack:
condition: service_healthy
environment:
DB_HOST: postgres
DB_PORT: "5432"
Expand All @@ -89,6 +112,16 @@ services:
DB_PASSWORD: shopsphere
KAFKA_BOOTSTRAP_SERVERS: kafka:29092
JWT_SECRET: dev-only-secret-change-me-32-bytes-minimum-aaaa
# Storage seam: the containerised app reaches LocalStack by service name. NOTE: presigned URLs
# are signed against this endpoint, so under the `full` profile they read `localstack:4566` —
# resolvable inside the compose network but not from a host browser. The primary dev loop
# (app on the host via `mvn spring-boot:run`, S3_ENDPOINT defaulting to localhost:4566) mints
# host-openable URLs. Real S3 has no such split. See ADR-0016.
S3_ENDPOINT: http://localstack:4566
S3_BUCKET: shopsphere-product-images
AWS_REGION: us-east-1
S3_ACCESS_KEY: test
S3_SECRET_KEY: test
ports:
- "8080:8080"

Expand Down
33 changes: 33 additions & 0 deletions docs/adr/0016-product-images-on-s3-with-localstack-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
status: accepted
date: 2026-06-05
cites: APoSD, PoEAA, DDD, XP, PragProg
---

# 0016 — Product images on S3, behind a deep module, with LocalStack for dev/cloud parity

Phase 16 lets an admin attach an image to a product and lets shoppers see it. The bytes live in S3; the database stores only a key; reads are served by short-lived presigned URLs from a private bucket. The design goal that shaped everything was operability: **spin the project up locally and point the same binary at real S3 by changing configuration, not code** — the S3 mirror of the Phase-11 RDS seam.

## A deep module over storage

`ProductImageStorage` exposes two operations — `upload(productId, bytes, contentType) → key` and `presignedRead(key, ttl) → URL` — and hides everything else: the S3 SDK, the client, the bucket, the `<productId>.<ext>` key scheme, the content-type allow-list, and how a URL is signed. **APoSD** — this is a deep module in the same family as Phase-14's `PaymentMethods` and Phase-15's `PaymentProvider`: a tiny interface over a substantial concern. Catalog code (the controllers, the `ProductMapper`) never imports an S3 type; swapping the backend touches no caller. **DDD** — images belong to Products, so the module and the `image_key` column live in the Catalog context; no other module learns that S3 exists.

## The dev↔cloud seam, and LocalStack as a high-fidelity Service Stub

The one knob that flips environments is `shopsphere.storage.s3.endpoint`: a LocalStack URL in dev/test, blank in cloud so the SDK resolves real S3. Credentials follow the same shape — explicit keys for LocalStack, otherwise the default AWS provider chain (instance profile). Path-style addressing is forced so the same configuration works against both. **PragProg — configure, don't hardcode**: the bucket, region, endpoint, and credentials are all configuration; the same JAR serves dev, the fully-containerised `full` compose profile, and cloud.

For testing, the choice was a hand-rolled in-memory fake versus a real S3 API. **PoEAA's Service Stub** says test the whole system against a stand-in — and LocalStack is a *high-fidelity* stand-in: it speaks the real S3 protocol, so the same `putObject`/presign code runs in tests and in production, including signature and expiry behaviour. That fidelity caught a real subtlety: LocalStack **skips presigned-URL signature validation by default**, which would have let an expired URL keep working and made the expiry test a no-op. Turning validation on (`S3_SKIP_SIGNATURE_VALIDATION=0`) makes the stub behave like S3 — the expired-URL-returns-403 test is meaningful precisely because the stub now enforces what S3 enforces. A fake would never have surfaced that.

## Private bucket, presigned reads

The bucket is private — public access blocked at every lever, ACLs disabled, SSE-S3 at rest (the authored, deferred Terraform). Clients never address the bucket directly; `ProductMapper` mints a **5-minute presigned read URL** per product on `GET /products` (null when the product has no image). Presigning is a *local* signature computation — no S3 round-trip — so minting one per row while paging a product list stays cheap. **PoEAA** — the private bucket plus a short-lived signed URL is the standard "don't make storage public to serve it" pattern; the URL is a capability, time-boxed.

## What's deliberately deferred or limited

- **Cloud Terraform is authored, not applied** (`terraform/s3/`), exactly like the secrets work (ADR-0013). The dev/test path is fully LocalStack, so the real bucket is only needed on real AWS; `terraform apply` is a future lab step and the app does not depend on it. **XP YAGNI** — no bucket provisioned for a cloud run that isn't a current goal.
- **Presigned-URL host under the fully-containerised `full` profile.** A presigned URL is signed against the client's endpoint, so when the *app itself* runs inside compose (`S3_ENDPOINT=http://localstack:4566`) the minted URLs read `localstack:4566` — resolvable inside the compose network but not from a host browser. The primary dev loop (app on the host, endpoint `localhost:4566`) mints host-openable URLs, and real S3 has no such split. Splitting the client and presigner endpoints would fix the `full`-profile browser case; it is not worth the extra config knob today (**APoSD** — don't add complexity a real need hasn't asked for). Recorded so it is a known limitation, not a surprise.
- **No image processing.** No resizing, thumbnails, or CDN — upload and presigned read only (**XP YAGNI**). The seam makes adding them later a change behind the interface.

## Consequences

Product images work end-to-end with **zero external dependency**: `docker compose up` brings up LocalStack with the bucket created, and `mvn verify` runs the image tests against LocalStack via Testcontainers — no AWS account, no credential. The admin upload endpoint also lands the `hasRole('ADMIN')` guard that ADR-0017 deferred for exactly this endpoint. The reversibility seam (PragProg) means the move to real S3 is a configuration change plus a `terraform apply`, with the IAM policy scoped to `GetObject`/`PutObject` on the one bucket.
2 changes: 1 addition & 1 deletion docs/modulith/components.puml
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ Container_Boundary("ShopSphere.ShopSphere_boundary", "ShopSphere", $tags="") {
Component(ShopSphere.ShopSphere.Ordering, "Ordering", $techn="Module", $descr="", $tags="", $link="")
}

Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="")
Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Catalog, "uses", $techn="", $tags="", $link="")
Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Payment, "uses", $techn="", $tags="", $link="")
Rel(ShopSphere.ShopSphere.Payment, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="")
Rel(ShopSphere.ShopSphere.Catalog, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="")
Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Identity, "depends on", $techn="", $tags="", $link="")
Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="")

SHOW_LEGEND(true)
@enduml
2 changes: 1 addition & 1 deletion docs/modulith/module-ordering.puml
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ Container_Boundary("ShopSphere.ShopSphere_boundary", "ShopSphere", $tags="") {
Component(ShopSphere.ShopSphere.Ordering, "Ordering", $techn="Module", $descr="", $tags="", $link="")
}

Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="")
Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Catalog, "uses", $techn="", $tags="", $link="")
Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Payment, "uses", $techn="", $tags="", $link="")
Rel(ShopSphere.ShopSphere.Payment, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="")
Rel(ShopSphere.ShopSphere.Catalog, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="")
Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Identity, "depends on", $techn="", $tags="", $link="")
Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="")

SHOW_LEGEND(true)
@enduml
18 changes: 18 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<testcontainers.version>1.20.3</testcontainers.version>
<spring-modulith.version>1.2.5</spring-modulith.version>
<jjwt.version>0.12.6</jjwt.version>
<awssdk.version>2.28.16</awssdk.version>
</properties>

<dependencies>
Expand Down Expand Up @@ -95,6 +96,11 @@
<artifactId>spring-kafka</artifactId>
</dependency>

<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
Expand Down Expand Up @@ -157,6 +163,11 @@
<artifactId>kafka</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>localstack</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
Expand All @@ -180,6 +191,13 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>${awssdk.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

Expand Down
7 changes: 7 additions & 0 deletions scripts/localstack-init.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/bin/bash
# Runs inside the LocalStack container once S3 is ready (mounted into init/ready.d).
# Creates the product-image bucket so the app finds it on first boot — the docker-compose
# equivalent of the bucket creation SharedContainers does for tests.
set -e
awslocal s3 mb s3://shopsphere-product-images
echo "localstack-init: created bucket shopsphere-product-images"
38 changes: 35 additions & 3 deletions src/main/java/com/shopsphere/catalog/AdminProductController.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,18 @@
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.UUID;

/**
Expand All @@ -32,9 +36,13 @@
class AdminProductController {

private final ProductRepository products;
private final ProductImageStorage images;
private final ProductMapper mapper;

AdminProductController(ProductRepository products) {
AdminProductController(ProductRepository products, ProductImageStorage images, ProductMapper mapper) {
this.products = products;
this.images = images;
this.mapper = mapper;
}

@PostMapping
Expand All @@ -47,7 +55,7 @@ ProductDto create(@Valid @RequestBody ProductRequest request) {
request.unitPrice().amount(),
request.unitPrice().currency(),
request.availableQty());
return ProductMapper.toDto(products.save(product));
return mapper.toDto(products.save(product));
}

@PutMapping("/{id}")
Expand All @@ -61,7 +69,9 @@ ResponseEntity<ProductDto> update(@PathVariable UUID id, @Valid @RequestBody Pro
request.unitPrice().amount(),
request.unitPrice().currency(),
request.availableQty());
return ResponseEntity.ok(ProductMapper.toDto(products.save(replacement)));
// An edit replaces the product's fields but not its image — carry the key across.
replacement.setImageKey(existing.getImageKey());
return ResponseEntity.ok(mapper.toDto(products.save(replacement)));
})
.orElseGet(() -> ResponseEntity.notFound().build());
}
Expand All @@ -75,6 +85,28 @@ ResponseEntity<Void> delete(@PathVariable UUID id) {
return ResponseEntity.noContent().build();
}

/**
* Uploads (or replaces) the image for a product. The bytes go to S3 via {@link ProductImageStorage};
* only the returned key is recorded on the product, so the read side can later mint a presigned URL.
* An unsupported content type is rejected 400 (see the exception handler); an unknown product is 404.
*/
@PostMapping("/{id}/image")
ResponseEntity<ProductDto> uploadImage(@PathVariable UUID id, @RequestParam("file") MultipartFile file)
throws IOException {
Product product = products.findById(id).orElse(null);
if (product == null) {
return ResponseEntity.notFound().build();
}
String key = images.upload(id, file.getBytes(), file.getContentType());
product.setImageKey(key);
return ResponseEntity.ok(mapper.toDto(products.save(product)));
}

@ExceptionHandler(S3ProductImageStorage.UnsupportedImageTypeException.class)
ResponseEntity<Void> unsupportedImageType() {
return ResponseEntity.badRequest().build();
}

record ProductRequest(
@NotBlank String name,
@NotBlank String description,
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/com/shopsphere/catalog/Product.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ class Product {
@Column(name = "available_qty", nullable = false)
private int availableQty;

// S3 key of this product's uploaded image, or null if none (Phase 16). The bytes live in S3.
@Column(name = "image_key")
private String imageKey;

protected Product() {
}

Expand Down Expand Up @@ -68,6 +72,14 @@ int getAvailableQty() {
return availableQty;
}

String getImageKey() {
return imageKey;
}

void setImageKey(String imageKey) {
this.imageKey = imageKey;
}

void decreaseAvailable(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("decrement must be positive, got " + amount);
Expand Down
8 changes: 5 additions & 3 deletions src/main/java/com/shopsphere/catalog/ProductController.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
class ProductController {

private final ProductRepository products;
private final ProductMapper mapper;

ProductController(ProductRepository products) {
ProductController(ProductRepository products, ProductMapper mapper) {
this.products = products;
this.mapper = mapper;
}

/**
Expand All @@ -30,13 +32,13 @@ class ProductController {
*/
@GetMapping
PagedResponse<ProductDto> list(@PageableDefault(size = 20, sort = "name") Pageable pageable) {
return PagedResponse.of(products.findAll(pageable), ProductMapper::toDto);
return PagedResponse.of(products.findAll(pageable), mapper::toDto);
}

@GetMapping("/{id}")
ResponseEntity<ProductDto> getOne(@PathVariable UUID id) {
return products.findById(id)
.map(ProductMapper::toDto)
.map(mapper::toDto)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/com/shopsphere/catalog/ProductDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ record ProductDto(
String name,
String description,
Money unitPrice,
int availableQty
int availableQty,
String imageUrl
) {
}
18 changes: 18 additions & 0 deletions src/main/java/com/shopsphere/catalog/ProductImageStorage.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.shopsphere.catalog;

import java.time.Duration;
import java.util.UUID;

/**
* Deep module over product-image object storage. Catalog code depends only on these two operations
* and never sees the S3 SDK, the bucket, the key scheme, or how a URL is signed. The implementation
* is swappable by configuration (LocalStack in dev, real S3 in cloud) — see {@link StorageConfig}.
*/
interface ProductImageStorage {

/** Stores {@code bytes} for {@code productId} and returns the storage key. */
String upload(UUID productId, byte[] bytes, String contentType);

/** Mints a time-limited read URL for a stored key. The URL stops working once {@code ttl} elapses. */
String presignedRead(String key, Duration ttl);
}
29 changes: 24 additions & 5 deletions src/main/java/com/shopsphere/catalog/ProductMapper.java
Original file line number Diff line number Diff line change
@@ -1,17 +1,36 @@
package com.shopsphere.catalog;

final class ProductMapper {
import org.springframework.stereotype.Component;

private ProductMapper() {
import java.time.Duration;

/**
* Maps a {@link Product} to its API DTO, minting a short-lived presigned read URL for the image when
* the product has one (null otherwise). Presigning is a local signature computation — no S3 round-trip
* — so doing it per row while paging a product list is cheap. The bucket itself stays private; the
* 5-minute URL is the only way a client reaches the bytes.
*/
@Component
class ProductMapper {

private static final Duration IMAGE_URL_TTL = Duration.ofMinutes(5);

private final ProductImageStorage images;

ProductMapper(ProductImageStorage images) {
this.images = images;
}

static ProductDto toDto(Product product) {
ProductDto toDto(Product product) {
String imageUrl = product.getImageKey() == null
? null
: images.presignedRead(product.getImageKey(), IMAGE_URL_TTL);
return new ProductDto(
product.getId(),
product.getName(),
product.getDescription(),
product.getUnitPrice(),
product.getAvailableQty()
);
product.getAvailableQty(),
imageUrl);
}
}
Loading
Loading