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:
@@ -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