feat!: environment admin URLs use slug; validate and immutabilize slug

UUID-based admin paths were the only remaining UUID-in-URL pattern in
the API. Migrates /api/v1/admin/environments/{id} to /{envSlug} so
slugs are the single environment identifier in every URL. UUIDs stay
internal to the database.

- Controller: @PathVariable UUID id → @PathVariable String envSlug on
  get/update/delete and the two nested endpoints (default-container-
  config, jar-retention). Handlers resolve slug → Environment via
  EnvironmentService.getBySlug, then delegate to existing UUID-based
  service methods.
- Service: create() now validates slug against ^[a-z0-9][a-z0-9-]{0,63}$
  and returns 400 on invalid slugs. Rationale documented in the class:
  slugs are immutable after creation because they appear in URLs,
  Docker network names, container names, and ClickHouse partition keys.
- UpdateEnvironmentRequest has no slug field and Jackson's default
  ignore-unknown behavior drops any slug supplied in a PUT body;
  regression test (updateEnvironment_withSlugInBody_ignoresSlug)
  documents this invariant.
- SPA: mutation args change from { id } to { slug }. EnvironmentsPage
  still uses env.id for local selection state (UUID from DB) but
  passes env.slug to every mutation.

BREAKING CHANGE: /api/v1/admin/environments/{id:UUID}/... paths removed.
Clients must use /{envSlug}/... (slug from the environments list).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-16 23:23:31 +02:00
parent fcb53dd010
commit 6b5ee10944
5 changed files with 102 additions and 61 deletions

View File

@@ -12,7 +12,6 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/admin/environments")
@@ -33,13 +32,13 @@ public class EnvironmentAdminController {
return ResponseEntity.ok(environmentService.listAll());
}
@GetMapping("/{id}")
@Operation(summary = "Get environment by ID")
@GetMapping("/{envSlug}")
@Operation(summary = "Get environment by slug")
@ApiResponse(responseCode = "200", description = "Environment found")
@ApiResponse(responseCode = "404", description = "Environment not found")
public ResponseEntity<Environment> getEnvironment(@PathVariable UUID id) {
public ResponseEntity<Environment> getEnvironment(@PathVariable String envSlug) {
try {
return ResponseEntity.ok(environmentService.getById(id));
return ResponseEntity.ok(environmentService.getBySlug(envSlug));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
@@ -48,24 +47,28 @@ public class EnvironmentAdminController {
@PostMapping
@Operation(summary = "Create a new environment")
@ApiResponse(responseCode = "201", description = "Environment created")
@ApiResponse(responseCode = "400", description = "Slug already exists")
@ApiResponse(responseCode = "400", description = "Invalid slug or slug already exists")
public ResponseEntity<?> createEnvironment(@RequestBody CreateEnvironmentRequest request) {
try {
UUID id = environmentService.create(request.slug(), request.displayName(), request.production());
return ResponseEntity.status(201).body(environmentService.getById(id));
environmentService.create(request.slug(), request.displayName(), request.production());
return ResponseEntity.status(201).body(environmentService.getBySlug(request.slug()));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@PutMapping("/{id}")
@Operation(summary = "Update an environment")
@PutMapping("/{envSlug}")
@Operation(summary = "Update an environment's mutable fields (displayName, production, enabled)",
description = "Slug is immutable after creation and cannot be changed. "
+ "Any slug field in the request body is ignored.")
@ApiResponse(responseCode = "200", description = "Environment updated")
@ApiResponse(responseCode = "404", description = "Environment not found")
public ResponseEntity<?> updateEnvironment(@PathVariable UUID id, @RequestBody UpdateEnvironmentRequest request) {
public ResponseEntity<?> updateEnvironment(@PathVariable String envSlug,
@RequestBody UpdateEnvironmentRequest request) {
try {
environmentService.update(id, request.displayName(), request.production(), request.enabled());
return ResponseEntity.ok(environmentService.getById(id));
Environment current = environmentService.getBySlug(envSlug);
environmentService.update(current.id(), request.displayName(), request.production(), request.enabled());
return ResponseEntity.ok(environmentService.getBySlug(envSlug));
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {
return ResponseEntity.notFound().build();
@@ -74,14 +77,15 @@ public class EnvironmentAdminController {
}
}
@DeleteMapping("/{id}")
@DeleteMapping("/{envSlug}")
@Operation(summary = "Delete an environment")
@ApiResponse(responseCode = "204", description = "Environment deleted")
@ApiResponse(responseCode = "400", description = "Cannot delete default environment")
@ApiResponse(responseCode = "404", description = "Environment not found")
public ResponseEntity<?> deleteEnvironment(@PathVariable UUID id) {
public ResponseEntity<?> deleteEnvironment(@PathVariable String envSlug) {
try {
environmentService.delete(id);
Environment current = environmentService.getBySlug(envSlug);
environmentService.delete(current.id());
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {
@@ -106,17 +110,18 @@ public class EnvironmentAdminController {
}
}
@PutMapping("/{id}/default-container-config")
@PutMapping("/{envSlug}/default-container-config")
@Operation(summary = "Update default container config for an environment")
@ApiResponse(responseCode = "200", description = "Default container config updated")
@ApiResponse(responseCode = "400", description = "Invalid configuration")
@ApiResponse(responseCode = "404", description = "Environment not found")
public ResponseEntity<?> updateDefaultContainerConfig(@PathVariable UUID id,
public ResponseEntity<?> updateDefaultContainerConfig(@PathVariable String envSlug,
@RequestBody Map<String, Object> defaultContainerConfig) {
try {
validateContainerConfig(defaultContainerConfig);
environmentService.updateDefaultContainerConfig(id, defaultContainerConfig);
return ResponseEntity.ok(environmentService.getById(id));
Environment current = environmentService.getBySlug(envSlug);
environmentService.updateDefaultContainerConfig(current.id(), defaultContainerConfig);
return ResponseEntity.ok(environmentService.getBySlug(envSlug));
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {
return ResponseEntity.notFound().build();
@@ -125,15 +130,16 @@ public class EnvironmentAdminController {
}
}
@PutMapping("/{id}/jar-retention")
@PutMapping("/{envSlug}/jar-retention")
@Operation(summary = "Update JAR retention policy for an environment")
@ApiResponse(responseCode = "200", description = "Retention policy updated")
@ApiResponse(responseCode = "404", description = "Environment not found")
public ResponseEntity<?> updateJarRetention(@PathVariable UUID id,
public ResponseEntity<?> updateJarRetention(@PathVariable String envSlug,
@RequestBody JarRetentionRequest request) {
try {
environmentService.updateJarRetentionCount(id, request.jarRetentionCount());
return ResponseEntity.ok(environmentService.getById(id));
Environment current = environmentService.getBySlug(envSlug);
environmentService.updateJarRetentionCount(current.id(), request.jarRetentionCount());
return ResponseEntity.ok(environmentService.getBySlug(envSlug));
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {
return ResponseEntity.notFound().build();

View File

@@ -97,29 +97,65 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT {
String createJson = """
{"slug": "update-test", "displayName": "Before", "production": false}
""";
ResponseEntity<String> createResponse = restTemplate.exchange(
restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>(createJson, securityHelper.authHeaders(adminJwt)),
String.class);
JsonNode created = objectMapper.readTree(createResponse.getBody());
String envId = created.path("id").asText();
// Update it
// Update it by slug
String updateJson = """
{"displayName": "After", "production": true, "enabled": false}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments/" + envId, HttpMethod.PUT,
"/api/v1/admin/environments/update-test", HttpMethod.PUT,
new HttpEntity<>(updateJson, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("slug").asText()).isEqualTo("update-test");
assertThat(body.path("displayName").asText()).isEqualTo("After");
assertThat(body.path("production").asBoolean()).isTrue();
assertThat(body.path("enabled").asBoolean()).isFalse();
}
@Test
void updateEnvironment_withSlugInBody_ignoresSlug() throws Exception {
String createJson = """
{"slug": "slug-immutable-test", "displayName": "Original", "production": false}
""";
restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>(createJson, securityHelper.authHeaders(adminJwt)),
String.class);
// Attempt to change slug via body — Jackson drops the unknown field
String updateJson = """
{"slug": "hacked", "displayName": "Renamed", "production": false, "enabled": true}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments/slug-immutable-test", HttpMethod.PUT,
new HttpEntity<>(updateJson, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("slug").asText()).isEqualTo("slug-immutable-test");
}
@Test
void createEnvironment_invalidSlug_returns400() {
String json = """
{"slug": "Invalid Slug!", "displayName": "Bad"}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void createEnvironment_duplicateSlug_returns400() {
String json = """
@@ -142,25 +178,9 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT {
}
@Test
void deleteEnvironment_defaultEnv_returns400() throws Exception {
// Find the default environment ID
ResponseEntity<String> listResponse = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
JsonNode envs = objectMapper.readTree(listResponse.getBody());
String defaultId = null;
for (JsonNode env : envs) {
if ("default".equals(env.path("slug").asText())) {
defaultId = env.path("id").asText();
break;
}
}
assertThat(defaultId).isNotNull();
void deleteEnvironment_defaultEnv_returns400() {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments/" + defaultId, HttpMethod.DELETE,
"/api/v1/admin/environments/default", HttpMethod.DELETE,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);