DesignDocumentController.java
package org.fresnel.backend.api;
import org.fresnel.backend.persistence.DesignEntity;
import org.fresnel.backend.persistence.DesignRepository;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Save / load endpoints for {@link DesignDocument} envelopes.
*
* <p>This controller offers two complementary persistence modes:
*
* <ul>
* <li><b>Stateless round-trip</b>: {@code POST /save} returns the canonicalised
* JSON as a downloadable attachment; {@code POST /load} validates and echoes
* a posted document. Used by the SPA's "Download / upload JSON" buttons.
* <li><b>Server-side persistence</b>: {@code POST /persist} stores the design
* in the database (Postgres in production, H2 in local/dev/tests) and returns
* the assigned UUID; {@code GET /persist} lists designs scoped to the caller
* (admin sees all); {@code GET /persist/{id}} loads a specific design.
* </ul>
*/
@RestController
@RequestMapping("/api/designs")
public class DesignDocumentController {
private final ObjectMapper mapper;
private final DesignRepository repository;
public DesignDocumentController(ObjectMapper mapper, DesignRepository repository) {
this.mapper = mapper;
this.repository = repository;
}
// -------- Stateless round-trip (existing API) --------
@PostMapping(value = "/save",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<byte[]> save(@RequestBody DesignDocument doc) throws Exception {
validate(doc);
DesignDocument normalised = new DesignDocument(
doc.kind(),
doc.version() <= 0 ? DesignDocument.SCHEMA_VERSION : doc.version(),
doc.payload());
byte[] body = mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(normalised);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"fresnel-design-" + normalised.kind() + ".json\"");
return ResponseEntity.ok().headers(headers).body(body);
}
@PostMapping(value = "/load",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public DesignDocument load(@RequestBody DesignDocument doc) {
validate(doc);
if (doc.version() > DesignDocument.SCHEMA_VERSION) {
throw new IllegalArgumentException(
"Design schema version " + doc.version() + " is newer than supported ("
+ DesignDocument.SCHEMA_VERSION + "). Please upgrade.");
}
return doc;
}
// -------- Server-side persistence --------
@PostMapping(value = "/persist",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, String> persist(@RequestBody DesignDocument doc) throws Exception {
validate(doc);
String name = (doc.payload().has("name") && doc.payload().get("name").isTextual())
? doc.payload().get("name").asText() : null;
DesignEntity entity = new DesignEntity(
null,
doc.kind(),
doc.version() <= 0 ? DesignDocument.SCHEMA_VERSION : doc.version(),
name,
currentOwnerOrNull(),
mapper.writeValueAsString(doc.payload()));
DesignEntity saved = repository.save(entity);
return Map.of("id", saved.getId().toString());
}
@GetMapping(value = "/persist", produces = MediaType.APPLICATION_JSON_VALUE)
public List<Map<String, Object>> list() {
List<DesignEntity> rows = isAdmin()
? repository.findAllByOrderByCreatedAtDesc()
: repository.findAllByOwnerIdOrderByCreatedAtDesc(currentOwnerOrNull());
return rows.stream().map(e -> Map.<String, Object>of(
"id", e.getId().toString(),
"kind", e.getKind(),
"name", e.getName() == null ? "" : e.getName(),
"version", e.getSchemaVersion(),
"ownerId", e.getOwnerId() == null ? "" : e.getOwnerId(),
"createdAt", e.getCreatedAt().toString(),
"updatedAt", e.getUpdatedAt().toString())).toList();
}
@GetMapping(value = "/persist/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
public DesignDocument loadById(@PathVariable("id") String id) throws Exception {
UUID uuid = parseUuid(id);
DesignEntity entity = repository.findById(uuid)
.orElseThrow(() -> new ResponseStatusException(
org.springframework.http.HttpStatus.NOT_FOUND, "design not found: " + id));
if (!isAdmin() && entity.getOwnerId() != null
&& !entity.getOwnerId().equals(currentOwnerOrNull())) {
throw new ResponseStatusException(
org.springframework.http.HttpStatus.FORBIDDEN, "not the owner");
}
JsonNode payload = mapper.readTree(entity.getPayloadJson());
return new DesignDocument(entity.getKind(), entity.getSchemaVersion(), payload);
}
@DeleteMapping(value = "/persist/{id}")
public ResponseEntity<Void> deleteById(@PathVariable("id") String id) {
UUID uuid = parseUuid(id);
DesignEntity entity = repository.findById(uuid)
.orElseThrow(() -> new ResponseStatusException(
org.springframework.http.HttpStatus.NOT_FOUND, "design not found: " + id));
if (!isAdmin() && entity.getOwnerId() != null
&& !entity.getOwnerId().equals(currentOwnerOrNull())) {
throw new ResponseStatusException(
org.springframework.http.HttpStatus.FORBIDDEN, "not the owner");
}
repository.delete(entity);
return ResponseEntity.noContent().build();
}
// -------- Helpers --------
private static UUID parseUuid(String id) {
try { return UUID.fromString(id); }
catch (IllegalArgumentException e) {
throw new ResponseStatusException(
org.springframework.http.HttpStatus.BAD_REQUEST, "invalid UUID: " + id);
}
}
private static String currentOwnerOrNull() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) return null;
String name = auth.getName();
return (name == null || "anonymousUser".equals(name)) ? null : name;
}
private static boolean isAdmin() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) return false;
return auth.getAuthorities().stream()
.anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority()));
}
private static void validate(DesignDocument doc) {
if (doc == null) {
throw new IllegalArgumentException("design document must not be null");
}
if (doc.kind() == null || doc.kind().isBlank()) {
throw new IllegalArgumentException("design document 'kind' must not be empty");
}
if (!DesignDocument.isKnownKind(doc.kind())) {
throw new IllegalArgumentException("unknown design kind: " + doc.kind());
}
if (doc.payload() == null || doc.payload().isNull()) {
throw new IllegalArgumentException("design document 'payload' must not be empty");
}
}
}