feat(api): add tag management endpoints for applications, databases, and services

Add CRUD tag endpoints (GET/POST/DELETE) as sub-resources for applications,
databases, and services. Add team-level GET /tags endpoint. Extend all
resource creation endpoints to accept an optional tags array.

Uses a shared HandlesTagsApi trait to avoid duplication across controllers.
Tags are race-safe via syncWithoutDetaching(), garbage-collected when orphaned,
and sanitized (strip_tags + lowercase).
This commit is contained in:
Michael Jathe
2026-03-29 16:02:05 +02:00
parent 96ae9ade23
commit 23d5b854e9
9 changed files with 1191 additions and 12 deletions
@@ -27,6 +27,18 @@ use OpenApi\Attributes as OA;
class DatabasesController extends Controller
{
use Concerns\HandlesTagsApi;
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
{
return queryDatabaseByUuidWithinTeam($uuid, $teamId);
}
protected function tagResourceNotFoundMessage(): string
{
return 'Database not found.';
}
private function removeSensitiveData($database)
{
$database->makeHidden([
@@ -1079,6 +1091,7 @@ class DatabasesController extends Controller
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1147,6 +1160,7 @@ class DatabasesController extends Controller
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1214,6 +1228,7 @@ class DatabasesController extends Controller
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1282,6 +1297,7 @@ class DatabasesController extends Controller
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1350,6 +1366,7 @@ class DatabasesController extends Controller
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1421,6 +1438,7 @@ class DatabasesController extends Controller
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1492,6 +1510,7 @@ class DatabasesController extends Controller
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1560,6 +1579,7 @@ class DatabasesController extends Controller
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
],
),
)
@@ -1689,6 +1709,8 @@ class DatabasesController extends Controller
'limits_cpuset' => 'string|nullable',
'limits_cpu_shares' => 'numeric',
'instant_deploy' => 'boolean',
'tags' => 'array|nullable',
'tags.*' => 'string|min:2',
]);
if ($validator->failed()) {
return response()->json([
@@ -1707,7 +1729,7 @@ class DatabasesController extends Controller
}
}
if ($type === NewDatabaseTypes::POSTGRESQL) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf'];
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'tags'];
$validator = customApiValidator($request->all(), [
'postgres_user' => 'string',
'postgres_password' => 'string',
@@ -1752,6 +1774,9 @@ class DatabasesController extends Controller
$request->offsetSet('postgres_conf', $postgresConf);
}
$database = create_standalone_postgresql($environment->id, $destination->uuid, $request->all());
if ($request->has('tags')) {
$this->attachTagsToResource($database, $request->tags, $teamId);
}
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1766,7 +1791,7 @@ class DatabasesController extends Controller
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MARIADB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'tags'];
$validator = customApiValidator($request->all(), [
'clickhouse_admin_user' => 'string',
'clickhouse_admin_password' => 'string',
@@ -1807,6 +1832,9 @@ class DatabasesController extends Controller
$request->offsetSet('mariadb_conf', $mariadbConf);
}
$database = create_standalone_mariadb($environment->id, $destination->uuid, $request->all());
if ($request->has('tags')) {
$this->attachTagsToResource($database, $request->tags, $teamId);
}
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1822,7 +1850,7 @@ class DatabasesController extends Controller
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MYSQL) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags'];
$validator = customApiValidator($request->all(), [
'mysql_root_password' => 'string',
'mysql_password' => 'string',
@@ -1866,6 +1894,9 @@ class DatabasesController extends Controller
$request->offsetSet('mysql_conf', $mysqlConf);
}
$database = create_standalone_mysql($environment->id, $destination->uuid, $request->all());
if ($request->has('tags')) {
$this->attachTagsToResource($database, $request->tags, $teamId);
}
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1881,7 +1912,7 @@ class DatabasesController extends Controller
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::REDIS) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf', 'tags'];
$validator = customApiValidator($request->all(), [
'redis_password' => 'string',
'redis_conf' => 'string',
@@ -1922,6 +1953,9 @@ class DatabasesController extends Controller
$request->offsetSet('redis_conf', $redisConf);
}
$database = create_standalone_redis($environment->id, $destination->uuid, $request->all());
if ($request->has('tags')) {
$this->attachTagsToResource($database, $request->tags, $teamId);
}
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1937,7 +1971,7 @@ class DatabasesController extends Controller
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::DRAGONFLY) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password', 'tags'];
$validator = customApiValidator($request->all(), [
'dragonfly_password' => 'string',
]);
@@ -1959,6 +1993,9 @@ class DatabasesController extends Controller
removeUnnecessaryFieldsFromRequest($request);
$database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->all());
if ($request->has('tags')) {
$this->attachTagsToResource($database, $request->tags, $teamId);
}
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1967,7 +2004,7 @@ class DatabasesController extends Controller
'uuid' => $database->uuid,
]))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::KEYDB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf'];
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf', 'tags'];
$validator = customApiValidator($request->all(), [
'keydb_password' => 'string',
'keydb_conf' => 'string',
@@ -2008,6 +2045,9 @@ class DatabasesController extends Controller
$request->offsetSet('keydb_conf', $keydbConf);
}
$database = create_standalone_keydb($environment->id, $destination->uuid, $request->all());
if ($request->has('tags')) {
$this->attachTagsToResource($database, $request->tags, $teamId);
}
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -2023,7 +2063,7 @@ class DatabasesController extends Controller
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password', 'tags'];
$validator = customApiValidator($request->all(), [
'clickhouse_admin_user' => 'string',
'clickhouse_admin_password' => 'string',
@@ -2044,6 +2084,9 @@ class DatabasesController extends Controller
}
removeUnnecessaryFieldsFromRequest($request);
$database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->all());
if ($request->has('tags')) {
$this->attachTagsToResource($database, $request->tags, $teamId);
}
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -2059,7 +2102,7 @@ class DatabasesController extends Controller
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MONGODB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'tags'];
$validator = customApiValidator($request->all(), [
'mongo_conf' => 'string',
'mongo_initdb_root_username' => 'string',
@@ -2102,6 +2145,9 @@ class DatabasesController extends Controller
$request->offsetSet('mongo_conf', $mongoConf);
}
$database = create_standalone_mongodb($environment->id, $destination->uuid, $request->all());
if ($request->has('tags')) {
$this->attachTagsToResource($database, $request->tags, $teamId);
}
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -3856,4 +3902,148 @@ class DatabasesController extends Controller
return response()->json(['message' => 'Storage deleted.']);
}
#[OA\Get(
summary: 'List Tags',
description: 'List tags for a database by UUID.',
path: '/databases/{uuid}/tags',
operationId: 'list-tags-by-database-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'List of tags.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Tag')
)
),
]
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function tags(Request $request): JsonResponse
{
return $this->listTags($request);
}
#[OA\Post(
summary: 'Create Tag',
description: 'Add tag(s) to a database by UUID.',
path: '/databases/{uuid}/tags',
operationId: 'create-tag-by-database-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
requestBody: new OA\RequestBody(
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'],
'tag_names' => [
'type' => 'array',
'items' => new OA\Items(type: 'string'),
'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.',
],
],
)
),
]
),
responses: [
new OA\Response(
response: 201,
description: 'Tags added successfully.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Tag')
)
),
]
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function create_tag(Request $request): JsonResponse
{
return $this->createTag($request);
}
#[OA\Delete(
summary: 'Delete Tag',
description: 'Remove a tag from a database by UUID.',
path: '/databases/{uuid}/tags/{tag_uuid}',
operationId: 'delete-tag-by-database-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'tag_uuid',
in: 'path',
description: 'UUID of the tag.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Tag removed.',
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function delete_tag(Request $request): JsonResponse
{
return $this->deleteTag($request);
}
}