diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index 77f4e626f9..daa7d17e4c 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -33,6 +33,18 @@
class ApplicationsController extends Controller
{
+ use Concerns\HandlesTagsApi;
+
+ protected function findTaggableResource(string $uuid, int|string $teamId): mixed
+ {
+ return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
+ }
+
+ protected function tagResourceNotFoundMessage(): string
+ {
+ return 'Application not found.';
+ }
+
private function removeSensitiveData($application)
{
$application->makeHidden([
@@ -230,6 +242,7 @@ public function applications(Request $request)
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
],
)
@@ -396,6 +409,7 @@ public function create_public_application(Request $request)
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
],
)
@@ -562,6 +576,7 @@ public function create_private_gh_app_application(Request $request)
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'],
],
)
@@ -700,6 +715,7 @@ public function create_private_deploy_key_application(Request $request)
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
],
)
),
@@ -834,6 +850,7 @@ public function create_dockerfile_application(Request $request)
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
],
)
),
@@ -1009,7 +1026,7 @@ private function create_application(Request $request, $type)
if ($return instanceof JsonResponse) {
return $return;
}
- $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
+ $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags', 'is_preserve_repository_enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@@ -1023,6 +1040,8 @@ private function create_application(Request $request, $type)
'http_basic_auth_username' => 'string|nullable',
'http_basic_auth_password' => 'string|nullable',
'autogenerate_domain' => 'boolean',
+ 'tags' => 'array|nullable',
+ 'tags.*' => 'string|min:2',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -1285,6 +1304,9 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
+ if ($request->has('tags')) {
+ $this->attachTagsToResource($application, $request->tags, $teamId);
+ }
$application->isConfigurationChanged(true);
if ($instantDeploy) {
@@ -1515,6 +1537,9 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
+ if ($request->has('tags')) {
+ $this->attachTagsToResource($application, $request->tags, $teamId);
+ }
$application->isConfigurationChanged(true);
if ($instantDeploy) {
@@ -1715,6 +1740,9 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
+ if ($request->has('tags')) {
+ $this->attachTagsToResource($application, $request->tags, $teamId);
+ }
$application->isConfigurationChanged(true);
if ($instantDeploy) {
@@ -1826,6 +1854,9 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
+ if ($request->has('tags')) {
+ $this->attachTagsToResource($application, $request->tags, $teamId);
+ }
$application->isConfigurationChanged(true);
if ($instantDeploy) {
@@ -1936,6 +1967,9 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
+ if ($request->has('tags')) {
+ $this->attachTagsToResource($application, $request->tags, $teamId);
+ }
$application->isConfigurationChanged(true);
if ($instantDeploy) {
@@ -1959,7 +1993,7 @@ private function create_application(Request $request, $type)
'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
} elseif ($type === 'dockercompose') {
- $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override', 'is_container_label_escape_enabled'];
+ $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override', 'is_container_label_escape_enabled', 'tags'];
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
@@ -2033,6 +2067,10 @@ private function create_application(Request $request, $type)
// Apply service-specific application prerequisites
applyServiceApplicationPrerequisites($service);
+ if ($request->has('tags')) {
+ $this->attachTagsToResource($service, $request->tags, $teamId);
+ }
+
if ($instantDeploy) {
StartService::dispatch($service);
}
@@ -4474,4 +4512,148 @@ public function delete_storage(Request $request): JsonResponse
return response()->json(['message' => 'Storage deleted.']);
}
+
+ #[OA\Get(
+ summary: 'List Tags',
+ description: 'List tags for an application by UUID.',
+ path: '/applications/{uuid}/tags',
+ operationId: 'list-tags-by-application-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Applications'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the application.',
+ 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 an application by UUID.',
+ path: '/applications/{uuid}/tags',
+ operationId: 'create-tag-by-application-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Applications'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the application.',
+ 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 an application by UUID.',
+ path: '/applications/{uuid}/tags/{tag_uuid}',
+ operationId: 'delete-tag-by-application-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Applications'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the application.',
+ 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);
+ }
}
diff --git a/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php
new file mode 100644
index 0000000000..b6d6d259ad
--- /dev/null
+++ b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php
@@ -0,0 +1,142 @@
+findTaggableResource($request->route('uuid'), $teamId);
+ if (! $resource) {
+ return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
+ }
+
+ $this->authorize('view', $resource);
+
+ return response()->json($resource->tags->map(TagsController::serializeTag(...)));
+ }
+
+ public function createTag(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $return = validateIncomingRequest($request);
+ if ($return instanceof \Illuminate\Http\JsonResponse) {
+ return $return;
+ }
+
+ $resource = $this->findTaggableResource($request->route('uuid'), $teamId);
+ if (! $resource) {
+ return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
+ }
+
+ $this->authorize('update', $resource);
+
+ if ($request->has('tag_name') && $request->has('tag_names')) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['tag_name' => ['Provide either tag_name or tag_names, not both.']],
+ ], 422);
+ }
+
+ $validator = Validator::make($request->all(), [
+ 'tag_name' => 'required_without:tag_names|string|min:2',
+ 'tag_names' => 'required_without:tag_name|array|min:1',
+ 'tag_names.*' => 'string|min:2',
+ ]);
+
+ $extraFields = array_diff(array_keys($request->all()), ['tag_name', 'tag_names']);
+ if ($validator->fails() || ! empty($extraFields)) {
+ $errors = $validator->errors();
+ if (! empty($extraFields)) {
+ foreach ($extraFields as $field) {
+ $errors->add($field, 'This field is not allowed.');
+ }
+ }
+
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $errors,
+ ], 422);
+ }
+
+ $tagNames = $request->has('tag_names') ? $request->tag_names : [$request->tag_name];
+
+ $this->attachTagsToResource($resource, $tagNames, $teamId);
+
+ return response()->json($resource->refresh()->tags->map(TagsController::serializeTag(...)))->setStatusCode(201);
+ }
+
+ public function deleteTag(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $resource = $this->findTaggableResource($request->route('uuid'), $teamId);
+ if (! $resource) {
+ return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
+ }
+
+ $this->authorize('update', $resource);
+
+ $tag = Tag::where('team_id', $teamId)->where('uuid', $request->route('tag_uuid'))->first();
+ if (! $tag) {
+ return response()->json(['message' => 'Tag not found.'], 404);
+ }
+
+ $resource->tags()->detach($tag->id);
+
+ if (DB::table('taggables')->where('tag_id', $tag->id)->count() === 0) {
+ $tag->delete();
+ }
+
+ return response()->json(['message' => 'Tag removed.']);
+ }
+
+ protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void
+ {
+ foreach ($tagNames as $tagName) {
+ $tagName = strtolower(strip_tags($tagName));
+ if (strlen($tagName) < 2) {
+ continue;
+ }
+
+ $tag = Tag::where('team_id', $teamId)->where('name', $tagName)->first();
+ if (! $tag) {
+ $tag = Tag::create([
+ 'name' => $tagName,
+ 'team_id' => $teamId,
+ ]);
+ }
+
+ $resource->tags()->syncWithoutDetaching([$tag->id]);
+ }
+ }
+}
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index 1b5cd0d44c..aa135749af 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -27,6 +27,18 @@
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 @@ public function update_backup(Request $request)
'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 @@ public function create_database_postgresql(Request $request)
'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 @@ public function create_database_clickhouse(Request $request)
'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 @@ public function create_database_dragonfly(Request $request)
'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 @@ public function create_database_redis(Request $request)
'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 @@ public function create_database_keydb(Request $request)
'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 @@ public function create_database_mariadb(Request $request)
'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 @@ public function create_database_mysql(Request $request)
'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 @@ public function create_database(Request $request, NewDatabaseTypes $type)
'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 @@ public function create_database(Request $request, NewDatabaseTypes $type)
}
}
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',
@@ -1755,6 +1777,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
+ if ($request->has('tags')) {
+ $this->attachTagsToResource($database, $request->tags, $teamId);
+ }
$database->refresh();
$payload = [
'uuid' => $database->uuid,
@@ -1766,7 +1791,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
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',
@@ -1810,6 +1835,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
+ if ($request->has('tags')) {
+ $this->attachTagsToResource($database, $request->tags, $teamId);
+ }
$database->refresh();
$payload = [
@@ -1822,7 +1850,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
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',
@@ -1869,6 +1897,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
+ if ($request->has('tags')) {
+ $this->attachTagsToResource($database, $request->tags, $teamId);
+ }
$database->refresh();
$payload = [
@@ -1881,7 +1912,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
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',
@@ -1925,6 +1956,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
+ if ($request->has('tags')) {
+ $this->attachTagsToResource($database, $request->tags, $teamId);
+ }
$database->refresh();
$payload = [
@@ -1937,7 +1971,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
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',
]);
@@ -1962,12 +1996,15 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
+ if ($request->has('tags')) {
+ $this->attachTagsToResource($database, $request->tags, $teamId);
+ }
return response()->json(serializeApiResponse([
'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',
@@ -2011,6 +2048,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
+ if ($request->has('tags')) {
+ $this->attachTagsToResource($database, $request->tags, $teamId);
+ }
$database->refresh();
$payload = [
@@ -2023,7 +2063,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
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',
@@ -2047,6 +2087,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
+ if ($request->has('tags')) {
+ $this->attachTagsToResource($database, $request->tags, $teamId);
+ }
$database->refresh();
$payload = [
@@ -2059,7 +2102,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
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',
@@ -2105,6 +2148,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
+ if ($request->has('tags')) {
+ $this->attachTagsToResource($database, $request->tags, $teamId);
+ }
$database->refresh();
$payload = [
@@ -3856,4 +3902,148 @@ public function delete_storage(Request $request): JsonResponse
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);
+ }
}
diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php
index fbf4b9e56f..7b60a6d0cc 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -22,6 +22,18 @@
class ServicesController extends Controller
{
+ use Concerns\HandlesTagsApi;
+
+ protected function findTaggableResource(string $uuid, int|string $teamId): mixed
+ {
+ return Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($uuid)->first();
+ }
+
+ protected function tagResourceNotFoundMessage(): string
+ {
+ return 'Service not found.';
+ }
+
private function removeSensitiveData($service)
{
$service->makeHidden([
@@ -227,6 +239,7 @@ public function services(Request $request)
],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'],
+ 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the service.'],
],
),
),
@@ -293,7 +306,7 @@ public function services(Request $request)
)]
public function create_service(Request $request)
{
- $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
+ $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -323,6 +336,8 @@ public function create_service(Request $request)
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
+ 'tags' => 'array|nullable',
+ 'tags.*' => 'string|min:2',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@@ -482,6 +497,10 @@ public function create_service(Request $request)
}
}
+ if ($request->has('tags')) {
+ $this->attachTagsToResource($service, $request->tags, $teamId);
+ }
+
if ($instantDeploy) {
StartService::dispatch($service);
}
@@ -494,7 +513,7 @@ public function create_service(Request $request)
return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
} elseif (filled($request->docker_compose_raw)) {
- $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
+ $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags'];
$validationRules = [
'project_uuid' => 'string|required',
@@ -646,6 +665,10 @@ public function create_service(Request $request)
}
}
+ if ($request->has('tags')) {
+ $this->attachTagsToResource($service, $request->tags, $teamId);
+ }
+
if ($instantDeploy) {
StartService::dispatch($service);
}
@@ -2458,4 +2481,148 @@ public function delete_storage(Request $request): JsonResponse
return response()->json(['message' => 'Storage deleted.']);
}
+
+ #[OA\Get(
+ summary: 'List Tags',
+ description: 'List tags for a service by UUID.',
+ path: '/services/{uuid}/tags',
+ operationId: 'list-tags-by-service-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Services'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the service.',
+ 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 service by UUID.',
+ path: '/services/{uuid}/tags',
+ operationId: 'create-tag-by-service-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Services'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the service.',
+ 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 service by UUID.',
+ path: '/services/{uuid}/tags/{tag_uuid}',
+ operationId: 'delete-tag-by-service-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Services'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the service.',
+ 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);
+ }
}
diff --git a/app/Http/Controllers/Api/TagsController.php b/app/Http/Controllers/Api/TagsController.php
new file mode 100644
index 0000000000..173a8ab7bc
--- /dev/null
+++ b/app/Http/Controllers/Api/TagsController.php
@@ -0,0 +1,61 @@
+ $tag->uuid,
+ 'name' => $tag->name,
+ 'created_at' => $tag->created_at,
+ 'updated_at' => $tag->updated_at,
+ ];
+ }
+
+ #[OA\Get(
+ summary: 'List',
+ description: 'List all tags for the current team.',
+ path: '/tags',
+ operationId: 'list-tags',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Tags'],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'All tags for the current team.',
+ 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'),
+ ]
+ )]
+ public function tags(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $tags = Tag::where('team_id', $teamId)->orderBy('name')->get();
+
+ return response()->json($tags->map(self::serializeTag(...)));
+ }
+}
diff --git a/app/Models/Tag.php b/app/Models/Tag.php
index 3594d1072a..221ef15bb1 100644
--- a/app/Models/Tag.php
+++ b/app/Models/Tag.php
@@ -3,7 +3,18 @@
namespace App\Models;
use App\Traits\HasSafeStringAttribute;
-
+use OpenApi\Attributes as OA;
+
+#[OA\Schema(
+ description: 'Tag model',
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'uuid', type: 'string'),
+ new OA\Property(property: 'name', type: 'string'),
+ new OA\Property(property: 'created_at', type: 'string'),
+ new OA\Property(property: 'updated_at', type: 'string'),
+ ]
+)]
class Tag extends BaseModel
{
use HasSafeStringAttribute;
diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php
index c10ed6158d..961148a898 100644
--- a/bootstrap/helpers/api.php
+++ b/bootstrap/helpers/api.php
@@ -196,4 +196,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('is_container_label_escape_enabled');
$request->offsetUnset('is_preserve_repository_enabled');
$request->offsetUnset('docker_compose_raw');
+ $request->offsetUnset('tags');
}
diff --git a/routes/api.php b/routes/api.php
index 0d3edcced1..716bcf286b 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -13,6 +13,7 @@
use App\Http\Controllers\Api\SecurityController;
use App\Http\Controllers\Api\ServersController;
use App\Http\Controllers\Api\ServicesController;
+use App\Http\Controllers\Api\TagsController;
use App\Http\Controllers\Api\TeamController;
use App\Http\Middleware\ApiAllowed;
use App\Jobs\PushServerUpdateJob;
@@ -98,6 +99,8 @@
Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']);
+ Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']);
+
Route::get('/applications', [ApplicationsController::class, 'applications'])->middleware(['api.ability:read']);
Route::post('/applications/public', [ApplicationsController::class, 'create_public_application'])->middleware(['api.ability:write']);
Route::post('/applications/private-github-app', [ApplicationsController::class, 'create_private_gh_app_application'])->middleware(['api.ability:write']);
@@ -125,6 +128,10 @@
Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']);
Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:write']);
+ Route::get('/applications/{uuid}/tags', [ApplicationsController::class, 'tags'])->middleware(['api.ability:read']);
+ Route::post('/applications/{uuid}/tags', [ApplicationsController::class, 'create_tag'])->middleware(['api.ability:write']);
+ Route::delete('/applications/{uuid}/tags/{tag_uuid}', [ApplicationsController::class, 'delete_tag'])->middleware(['api.ability:write']);
+
Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
@@ -167,6 +174,10 @@
Route::patch('/databases/{uuid}/envs', [DatabasesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}/envs/{env_uuid}', [DatabasesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
+ Route::get('/databases/{uuid}/tags', [DatabasesController::class, 'tags'])->middleware(['api.ability:read']);
+ Route::post('/databases/{uuid}/tags', [DatabasesController::class, 'create_tag'])->middleware(['api.ability:write']);
+ Route::delete('/databases/{uuid}/tags/{tag_uuid}', [DatabasesController::class, 'delete_tag'])->middleware(['api.ability:write']);
+
Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
@@ -189,6 +200,10 @@
Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
+ Route::get('/services/{uuid}/tags', [ServicesController::class, 'tags'])->middleware(['api.ability:read']);
+ Route::post('/services/{uuid}/tags', [ServicesController::class, 'create_tag'])->middleware(['api.ability:write']);
+ Route::delete('/services/{uuid}/tags/{tag_uuid}', [ServicesController::class, 'delete_tag'])->middleware(['api.ability:write']);
+
Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
diff --git a/tests/Feature/TagApiTest.php b/tests/Feature/TagApiTest.php
new file mode 100644
index 0000000000..dd62c10621
--- /dev/null
+++ b/tests/Feature/TagApiTest.php
@@ -0,0 +1,410 @@
+ 0]);
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ session(['currentTeam' => $this->team]);
+
+ $this->token = $this->user->createToken('test-token', ['*']);
+ $this->bearerToken = $this->token->plainTextToken;
+
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+
+ $this->application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+});
+
+function tagApiAuthHeaders($bearerToken): array
+{
+ return [
+ 'Authorization' => 'Bearer '.$bearerToken,
+ 'Content-Type' => 'application/json',
+ ];
+}
+
+describe('GET /api/v1/tags', function () {
+ test('returns all tags for current team', function () {
+ Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
+ Tag::create(['name' => 'staging', 'team_id' => $this->team->id]);
+
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->getJson('/api/v1/tags');
+
+ $response->assertStatus(200);
+ $response->assertJsonCount(2);
+ $response->assertJsonFragment(['name' => 'production']);
+ $response->assertJsonFragment(['name' => 'staging']);
+ });
+
+ test('returns empty array when no tags exist', function () {
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->getJson('/api/v1/tags');
+
+ $response->assertStatus(200);
+ $response->assertJsonCount(0);
+ });
+
+ test('does not return tags from other teams', function () {
+ $otherTeam = Team::factory()->create();
+ Tag::create(['name' => 'other-team-tag', 'team_id' => $otherTeam->id]);
+ Tag::create(['name' => 'my-tag', 'team_id' => $this->team->id]);
+
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->getJson('/api/v1/tags');
+
+ $response->assertStatus(200);
+ $response->assertJsonCount(1);
+ $response->assertJsonFragment(['name' => 'my-tag']);
+ $response->assertJsonMissing(['name' => 'other-team-tag']);
+ });
+});
+
+describe('GET /api/v1/applications/{uuid}/tags', function () {
+ test('returns tags for an application', function () {
+ $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
+ $this->application->tags()->attach($tag->id);
+
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->getJson("/api/v1/applications/{$this->application->uuid}/tags");
+
+ $response->assertStatus(200);
+ $response->assertJsonCount(1);
+ $response->assertJsonFragment(['name' => 'production']);
+ });
+
+ test('returns 404 for non-existent application', function () {
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->getJson('/api/v1/applications/non-existent-uuid/tags');
+
+ $response->assertStatus(404);
+ });
+
+ test('returns empty array when application has no tags', function () {
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->getJson("/api/v1/applications/{$this->application->uuid}/tags");
+
+ $response->assertStatus(200);
+ $response->assertJsonCount(0);
+ });
+});
+
+describe('POST /api/v1/applications/{uuid}/tags', function () {
+ test('adds a single tag via tag_name', function () {
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
+ 'tag_name' => 'production',
+ ]);
+
+ $response->assertStatus(201);
+ $response->assertJsonCount(1);
+ $response->assertJsonFragment(['name' => 'production']);
+
+ expect($this->application->tags()->count())->toBe(1);
+ });
+
+ test('adds multiple tags via tag_names array', function () {
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
+ 'tag_names' => ['production', 'frontend'],
+ ]);
+
+ $response->assertStatus(201);
+ $response->assertJsonCount(2);
+
+ expect($this->application->tags()->count())->toBe(2);
+ });
+
+ test('reuses existing team tag instead of creating duplicate', function () {
+ Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
+
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
+ 'tag_name' => 'production',
+ ]);
+
+ $response->assertStatus(201);
+ expect(Tag::where('team_id', $this->team->id)->where('name', 'production')->count())->toBe(1);
+ });
+
+ test('rejects tag_name shorter than 2 characters', function () {
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
+ 'tag_name' => 'x',
+ ]);
+
+ $response->assertStatus(422);
+ });
+
+ test('rejects both tag_name and tag_names provided simultaneously', function () {
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
+ 'tag_name' => 'production',
+ 'tag_names' => ['staging'],
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonFragment(['tag_name' => ['Provide either tag_name or tag_names, not both.']]);
+ });
+
+ test('skips duplicate tag already on resource', function () {
+ $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
+ $this->application->tags()->attach($tag->id);
+
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
+ 'tag_name' => 'production',
+ ]);
+
+ $response->assertStatus(201);
+ expect($this->application->tags()->count())->toBe(1);
+ });
+
+ test('returns 404 for non-existent application', function () {
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->postJson('/api/v1/applications/non-existent-uuid/tags', [
+ 'tag_name' => 'production',
+ ]);
+
+ $response->assertStatus(404);
+ });
+});
+
+describe('DELETE /api/v1/applications/{uuid}/tags/{tag_uuid}', function () {
+ test('removes tag from application', function () {
+ $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
+ $this->application->tags()->attach($tag->id);
+
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
+
+ $response->assertStatus(200);
+ expect($this->application->tags()->count())->toBe(0);
+ });
+
+ test('garbage-collects orphaned tag', function () {
+ $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
+ $this->application->tags()->attach($tag->id);
+
+ $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
+
+ expect(Tag::find($tag->id))->toBeNull();
+ });
+
+ test('keeps tag if still used by other resources', function () {
+ $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
+ $this->application->tags()->attach($tag->id);
+
+ $otherApp = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+ $otherApp->tags()->attach($tag->id);
+
+ $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
+
+ expect(Tag::find($tag->id))->not->toBeNull();
+ });
+
+ test('returns 404 for non-existent tag', function () {
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/non-existent-uuid");
+
+ $response->assertStatus(404);
+ });
+});
+
+describe('GET /api/v1/databases/{uuid}/tags', function () {
+ test('returns tags for a database', function () {
+ $database = StandalonePostgresql::create([
+ 'name' => 'test-pg',
+ 'postgres_password' => 'testpassword',
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $tag = Tag::create(['name' => 'database-tag', 'team_id' => $this->team->id]);
+ $database->tags()->attach($tag->id);
+
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->getJson("/api/v1/databases/{$database->uuid}/tags");
+
+ $response->assertStatus(200);
+ $response->assertJsonCount(1);
+ $response->assertJsonFragment(['name' => 'database-tag']);
+ });
+});
+
+describe('POST /api/v1/databases/{uuid}/tags', function () {
+ test('adds tag to database', function () {
+ $database = StandalonePostgresql::create([
+ 'name' => 'test-pg',
+ 'postgres_password' => 'testpassword',
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->postJson("/api/v1/databases/{$database->uuid}/tags", [
+ 'tag_name' => 'database-tag',
+ ]);
+
+ $response->assertStatus(201);
+ expect($database->tags()->count())->toBe(1);
+ });
+});
+
+describe('DELETE /api/v1/databases/{uuid}/tags/{tag_uuid}', function () {
+ test('removes tag from database', function () {
+ $database = StandalonePostgresql::create([
+ 'name' => 'test-pg',
+ 'postgres_password' => 'testpassword',
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $tag = Tag::create(['name' => 'database-tag', 'team_id' => $this->team->id]);
+ $database->tags()->attach($tag->id);
+
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->deleteJson("/api/v1/databases/{$database->uuid}/tags/{$tag->uuid}");
+
+ $response->assertStatus(200);
+ expect($database->tags()->count())->toBe(0);
+ });
+});
+
+describe('GET /api/v1/services/{uuid}/tags', function () {
+ test('returns tags for a service', function () {
+ $service = Service::factory()->create([
+ 'server_id' => $this->server->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ $tag = Tag::create(['name' => 'service-tag', 'team_id' => $this->team->id]);
+ $service->tags()->attach($tag->id);
+
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->getJson("/api/v1/services/{$service->uuid}/tags");
+
+ $response->assertStatus(200);
+ $response->assertJsonCount(1);
+ $response->assertJsonFragment(['name' => 'service-tag']);
+ });
+});
+
+describe('POST /api/v1/services/{uuid}/tags', function () {
+ test('adds tag to service', function () {
+ $service = Service::factory()->create([
+ 'server_id' => $this->server->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->postJson("/api/v1/services/{$service->uuid}/tags", [
+ 'tag_name' => 'service-tag',
+ ]);
+
+ $response->assertStatus(201);
+ expect($service->tags()->count())->toBe(1);
+ });
+});
+
+describe('DELETE /api/v1/services/{uuid}/tags/{tag_uuid}', function () {
+ test('removes tag from service', function () {
+ $service = Service::factory()->create([
+ 'server_id' => $this->server->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ $tag = Tag::create(['name' => 'service-tag', 'team_id' => $this->team->id]);
+ $service->tags()->attach($tag->id);
+
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->deleteJson("/api/v1/services/{$service->uuid}/tags/{$tag->uuid}");
+
+ $response->assertStatus(200);
+ expect($service->tags()->count())->toBe(0);
+ });
+});
+
+describe('Tag name sanitization', function () {
+ test('strips HTML tags from tag names', function () {
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
+ 'tag_name' => 'production',
+ ]);
+
+ $response->assertStatus(201);
+ $response->assertJsonFragment(['name' => 'alert("xss")production']);
+ $response->assertJsonMissing(['name' => '