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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -3,8 +3,19 @@ package com.cameleer.server.core.runtime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class EnvironmentService {
|
||||
|
||||
/**
|
||||
* Slug must start with a lowercase letter or digit, contain only lowercase
|
||||
* letters, digits, and hyphens, and be 1–64 characters long. Slugs are
|
||||
* immutable after creation — they appear in URLs, Docker network names,
|
||||
* container names, and ClickHouse partition keys, so renaming is not
|
||||
* supported by design.
|
||||
*/
|
||||
private static final Pattern SLUG_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9-]{0,63}$");
|
||||
|
||||
private final EnvironmentRepository repo;
|
||||
|
||||
public EnvironmentService(EnvironmentRepository repo) {
|
||||
@@ -22,6 +33,10 @@ public class EnvironmentService {
|
||||
}
|
||||
|
||||
public UUID create(String slug, String displayName, boolean production) {
|
||||
if (slug == null || !SLUG_PATTERN.matcher(slug).matches()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Invalid slug: must match ^[a-z0-9][a-z0-9-]{0,63}$ (lowercase letters, digits, hyphens)");
|
||||
}
|
||||
if (repo.findBySlug(slug).isPresent()) {
|
||||
throw new IllegalArgumentException("Environment with slug '" + slug + "' already exists");
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ export function useCreateEnvironment() {
|
||||
export function useUpdateEnvironment() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...req }: UpdateEnvironmentRequest & { id: string }) =>
|
||||
adminFetch<Environment>(`/environments/${id}`, {
|
||||
mutationFn: ({ slug, ...req }: UpdateEnvironmentRequest & { slug: string }) =>
|
||||
adminFetch<Environment>(`/environments/${slug}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(req),
|
||||
}),
|
||||
@@ -58,8 +58,8 @@ export function useUpdateEnvironment() {
|
||||
export function useUpdateDefaultContainerConfig() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, config }: { id: string; config: Record<string, unknown> }) =>
|
||||
adminFetch<Environment>(`/environments/${id}/default-container-config`, {
|
||||
mutationFn: ({ slug, config }: { slug: string; config: Record<string, unknown> }) =>
|
||||
adminFetch<Environment>(`/environments/${slug}/default-container-config`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(config),
|
||||
}),
|
||||
@@ -70,8 +70,8 @@ export function useUpdateDefaultContainerConfig() {
|
||||
export function useUpdateJarRetention() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, jarRetentionCount }: { id: string; jarRetentionCount: number | null }) =>
|
||||
adminFetch<Environment>(`/environments/${id}/jar-retention`, {
|
||||
mutationFn: ({ slug, jarRetentionCount }: { slug: string; jarRetentionCount: number | null }) =>
|
||||
adminFetch<Environment>(`/environments/${slug}/jar-retention`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ jarRetentionCount }),
|
||||
}),
|
||||
@@ -82,8 +82,8 @@ export function useUpdateJarRetention() {
|
||||
export function useDeleteEnvironment() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
adminFetch<void>(`/environments/${id}`, { method: 'DELETE' }),
|
||||
mutationFn: (slug: string) =>
|
||||
adminFetch<void>(`/environments/${slug}`, { method: 'DELETE' }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'environments'] }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function EnvironmentsPage() {
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteEnv.mutateAsync(deleteTarget.id);
|
||||
await deleteEnv.mutateAsync(deleteTarget.slug);
|
||||
toast({ title: 'Environment deleted', description: deleteTarget.slug, variant: 'warning' });
|
||||
if (selectedId === deleteTarget.id) setSelectedId(null);
|
||||
setDeleteTarget(null);
|
||||
@@ -116,7 +116,7 @@ export default function EnvironmentsPage() {
|
||||
if (!selected) return;
|
||||
try {
|
||||
await updateEnv.mutateAsync({
|
||||
id: selected.id,
|
||||
slug: selected.slug,
|
||||
displayName: newName,
|
||||
production: selected.production,
|
||||
enabled: selected.enabled,
|
||||
@@ -131,7 +131,7 @@ export default function EnvironmentsPage() {
|
||||
if (!selected) return;
|
||||
try {
|
||||
await updateEnv.mutateAsync({
|
||||
id: selected.id,
|
||||
slug: selected.slug,
|
||||
displayName: selected.displayName,
|
||||
production: value,
|
||||
enabled: selected.enabled,
|
||||
@@ -146,7 +146,7 @@ export default function EnvironmentsPage() {
|
||||
if (!selected) return;
|
||||
try {
|
||||
await updateEnv.mutateAsync({
|
||||
id: selected.id,
|
||||
slug: selected.slug,
|
||||
displayName: selected.displayName,
|
||||
production: selected.production,
|
||||
enabled: value,
|
||||
@@ -300,7 +300,7 @@ export default function EnvironmentsPage() {
|
||||
|
||||
<DefaultResourcesSection environment={selected} onSave={async (config) => {
|
||||
try {
|
||||
await updateDefaults.mutateAsync({ id: selected.id, config });
|
||||
await updateDefaults.mutateAsync({ slug: selected.slug, config });
|
||||
toast({ title: 'Default resources updated', variant: 'success' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to update defaults', variant: 'error', duration: 86_400_000 });
|
||||
@@ -309,7 +309,7 @@ export default function EnvironmentsPage() {
|
||||
|
||||
<JarRetentionSection environment={selected} onSave={async (count) => {
|
||||
try {
|
||||
await updateRetention.mutateAsync({ id: selected.id, jarRetentionCount: count });
|
||||
await updateRetention.mutateAsync({ slug: selected.slug, jarRetentionCount: count });
|
||||
toast({ title: 'Retention policy updated', variant: 'success' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to update retention', variant: 'error', duration: 86_400_000 });
|
||||
|
||||
Reference in New Issue
Block a user