diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index ab063ca8d..e10bc0658 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 exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume { if (request()->attributes->get('can_read_sensitive', false) === true) { @@ -280,6 +292,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.'], ], ) @@ -447,6 +460,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.'], ], ) @@ -614,6 +628,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.'], ], ) @@ -753,6 +768,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.'], ], ) ), @@ -888,6 +904,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.'], ], ) ), @@ -964,7 +981,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', 'is_preview_deployments_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', 'is_preview_deployments_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', @@ -978,6 +995,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); @@ -995,6 +1014,13 @@ private function create_application(Request $request, $type) ], 422); } + $return = $this->validateTagsParameter($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $tagNames = $request->input('tags') ?? []; + $environmentUuid = $request->environment_uuid; $environmentName = $request->environment_name; if (blank($environmentUuid) && blank($environmentName)) { @@ -1252,6 +1278,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1495,6 +1524,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1708,6 +1740,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1832,6 +1867,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1955,6 +1993,9 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); + } $application->isConfigurationChanged(true); if ($instantDeploy) { @@ -1986,6 +2027,7 @@ private function create_application(Request $request, $type) 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), ]))->setStatusCode(201); + } return response()->json(['message' => 'Invalid type.'], 400); @@ -4654,4 +4696,148 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons return response()->json(['message' => 'Preview deletion request queued.']); } + + #[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 000000000..4c15d0726 --- /dev/null +++ b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php @@ -0,0 +1,174 @@ +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 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', + 'tag_names' => 'required_without:tag_name|array|min:1', + 'tag_names.*' => 'string', + ]); + + $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 = $this->normalizeTagNames($request->has('tag_names') ? $request->tag_names : [$request->tag_name]); + $invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2); + if (! empty($invalidTags)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['tag_name' => ['Each tag name must be at least 2 characters after sanitization.']], + ], 422); + } + + $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); + } + + if (! $resource->tags()->whereKey($tag->id)->exists()) { + return response()->json(['message' => 'Tag not found on resource.'], 404); + } + + $resource->tags()->detach($tag->id); + $tag->deleteIfOrphaned(); + + return response()->json(['message' => 'Tag removed.']); + } + + protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void + { + foreach ($this->normalizeTagNames($tagNames) as $tagName) { + if (mb_strlen($tagName) < 2) { + continue; + } + + $tag = Tag::query()->createOrFirst([ + 'team_id' => $teamId, + 'name' => $tagName, + ]); + + $resource->tags()->syncWithoutDetaching([$tag->id]); + } + } + + protected function validateTagsParameter(Request $request): ?JsonResponse + { + if (! $request->has('tags')) { + return null; + } + + $tagNames = $this->normalizeTagNames($request->input('tags', [])); + $invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2); + if (! empty($invalidTags)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['tags' => ['Each tag name must be at least 2 characters after sanitization.']], + ], 422); + } + + $request->merge(['tags' => $tagNames]); + + return null; + } + + protected function normalizeTagNames(array $tagNames): array + { + return collect($tagNames) + ->map(fn ($tagName): string => strtolower(trim(strip_tags((string) $tagName)))) + ->unique() + ->values() + ->all(); + } +} diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index d6622f29e..3cd2f0a73 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -28,6 +28,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 exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume { if (request()->attributes->get('can_read_sensitive', false) === true) { @@ -1220,6 +1232,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.'], ], ), ) @@ -1288,6 +1301,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.'], ], ), ) @@ -1355,6 +1369,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.'], ], ), ) @@ -1423,6 +1438,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.'], ], ), ) @@ -1491,6 +1507,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.'], ], ), ) @@ -1562,6 +1579,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.'], ], ), ) @@ -1633,6 +1651,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.'], ], ), ) @@ -1701,6 +1720,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.'], ], ), ) @@ -1731,7 +1751,7 @@ public function create_database_mongodb(Request $request) public function create_database(Request $request, NewDatabaseTypes $type) { - $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', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', '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', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -1830,6 +1850,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([ @@ -1837,6 +1859,13 @@ public function create_database(Request $request, NewDatabaseTypes $type) 'errors' => $validator->errors(), ], 422); } + $return = $this->validateTagsParameter($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $tagNames = $request->input('tags') ?? []; + if ($request->public_port) { if ($request->public_port < 1024 || $request->public_port > 65535) { return response()->json([ @@ -1848,7 +1877,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' => ValidationPatterns::databaseIdentifierRules(required: false), 'postgres_password' => ValidationPatterns::databasePasswordRules(required: false), @@ -1896,6 +1925,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ 'uuid' => $database->uuid, @@ -1917,7 +1949,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(), [ 'mariadb_conf' => 'string', 'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false), @@ -1964,6 +1996,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ @@ -1986,7 +2021,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' => ValidationPatterns::databasePasswordRules(required: false), 'mysql_password' => ValidationPatterns::databasePasswordRules(required: false), @@ -2033,6 +2068,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ @@ -2055,7 +2093,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' => ValidationPatterns::databasePasswordRules(required: false), 'redis_conf' => 'string', @@ -2099,6 +2137,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ @@ -2121,7 +2162,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' => ValidationPatterns::databasePasswordRules(required: false), ]); @@ -2146,12 +2187,15 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $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' => ValidationPatterns::databasePasswordRules(required: false), 'keydb_conf' => 'string', @@ -2195,6 +2239,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ @@ -2217,7 +2264,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' => ValidationPatterns::databaseIdentifierRules(required: false), 'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false), @@ -2241,6 +2288,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ @@ -2263,7 +2313,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' => ValidationPatterns::databaseIdentifierRules(required: false), @@ -2309,6 +2359,9 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); + } $database->refresh(); $payload = [ @@ -4342,4 +4395,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 24aca896b..e32415e79 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -24,6 +24,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 exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume { if (request()->attributes->get('can_read_sensitive', false) === true) { @@ -276,6 +288,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.'], ], ), ), @@ -342,7 +355,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)) { @@ -372,6 +385,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.', @@ -393,6 +408,11 @@ public function create_service(Request $request) ], 422); } + $return = $this->validateTagsParameter($request); + if ($return instanceof JsonResponse) { + return $return; + } + if (filled($request->type) && filled($request->docker_compose_raw)) { return response()->json([ 'message' => 'You cannot provide both service type and docker_compose_raw. Use one or the other.', @@ -531,6 +551,10 @@ public function create_service(Request $request) } } + if ($request->has('tags')) { + $this->attachTagsToResource($service, $request->tags, $teamId); + } + if ($instantDeploy) { StartService::dispatch($service); } @@ -551,7 +575,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', @@ -570,6 +594,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.', @@ -703,6 +729,10 @@ public function create_service(Request $request) } } + if ($request->has('tags')) { + $this->attachTagsToResource($service, $request->tags, $teamId); + } + if ($instantDeploy) { StartService::dispatch($service); } @@ -2798,4 +2828,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 000000000..173a8ab7b --- /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/Livewire/Project/Shared/Tags.php b/app/Livewire/Project/Shared/Tags.php index 37b8b277a..61d04e20b 100644 --- a/app/Livewire/Project/Shared/Tags.php +++ b/app/Livewire/Project/Shared/Tags.php @@ -91,9 +91,7 @@ public function deleteTag(string $id) $this->authorize('update', $this->resource); $this->resource->tags()->detach($id); $found_more_tags = Tag::ownedByCurrentTeam()->find($id); - if ($found_more_tags && $found_more_tags->applications()->count() == 0 && $found_more_tags->services()->count() == 0) { - $found_more_tags->delete(); - } + $found_more_tags?->deleteIfOrphaned(); $this->refresh(); $this->dispatch('success', 'Tag deleted.'); } catch (\Exception $e) { diff --git a/app/Models/Tag.php b/app/Models/Tag.php index e6fbd3a06..d5cccabd8 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -3,7 +3,19 @@ namespace App\Models; use App\Traits\HasSafeStringAttribute; +use Illuminate\Support\Facades\DB; +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; @@ -23,6 +35,13 @@ public static function ownedByCurrentTeam() return Tag::whereTeamId(currentTeam()->id)->orderBy('name'); } + public function deleteIfOrphaned(): void + { + if (DB::table('taggables')->where('tag_id', $this->id)->doesntExist()) { + $this->delete(); + } + } + public function applications() { return $this->morphedByMany(Application::class, 'taggable'); diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index c75fbc705..f8a2b0d66 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -222,4 +222,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('is_preserve_repository_enabled'); $request->offsetUnset('include_source_commit_in_build'); $request->offsetUnset('docker_compose_raw'); + $request->offsetUnset('tags'); } diff --git a/database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php b/database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php new file mode 100644 index 000000000..a25fdc18b --- /dev/null +++ b/database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php @@ -0,0 +1,76 @@ +indexExists()) { + return; + } + + DB::table('tags') + ->select('team_id', 'name', DB::raw('MIN(id) as keep_id'), DB::raw('COUNT(*) as tag_count')) + ->whereNotNull('team_id') + ->groupBy('team_id', 'name') + ->havingRaw('COUNT(*) > 1') + ->orderBy('keep_id') + ->cursor() + ->each(function ($duplicate): void { + DB::table('tags') + ->select('id') + ->where('team_id', $duplicate->team_id) + ->where('name', $duplicate->name) + ->where('id', '!=', $duplicate->keep_id) + ->orderBy('id') + ->cursor() + ->each(function ($duplicateTag) use ($duplicate): void { + DB::table('taggables') + ->where('tag_id', $duplicateTag->id) + ->orderBy('taggable_id') + ->cursor() + ->each(function ($taggable) use ($duplicate): void { + DB::table('taggables')->updateOrInsert([ + 'tag_id' => $duplicate->keep_id, + 'taggable_id' => $taggable->taggable_id, + 'taggable_type' => $taggable->taggable_type, + ]); + }); + + DB::table('taggables')->where('tag_id', $duplicateTag->id)->delete(); + DB::table('tags')->where('id', $duplicateTag->id)->delete(); + }); + }); + + Schema::table('tags', function (Blueprint $table) { + $table->unique(['team_id', 'name'], 'tags_team_id_name_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! $this->indexExists()) { + return; + } + + Schema::table('tags', function (Blueprint $table) { + $table->dropUnique('tags_team_id_name_unique'); + }); + } + + private function indexExists(): bool + { + return collect(Schema::getIndexes('tags')) + ->contains(fn (array $index): bool => $index['name'] === 'tags_team_id_name_unique'); + } +}; diff --git a/openapi.json b/openapi.json index 00c1e3733..3b324a546 100644 --- a/openapi.json +++ b/openapi.json @@ -412,6 +412,13 @@ "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": { + "type": "string" + }, + "description": "Tags to assign to the application." + }, "is_preserve_repository_enabled": { "type": "boolean", "default": false, @@ -866,6 +873,13 @@ "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": { + "type": "string" + }, + "description": "Tags to assign to the application." + }, "is_preserve_repository_enabled": { "type": "boolean", "default": false, @@ -1320,6 +1334,13 @@ "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": { + "type": "string" + }, + "description": "Tags to assign to the application." + }, "is_preserve_repository_enabled": { "type": "boolean", "default": false, @@ -1678,6 +1699,13 @@ "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": { + "type": "string" + }, + "description": "Tags to assign to the application." } }, "type": "object" @@ -2017,6 +2045,13 @@ "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": { + "type": "string" + }, + "description": "Tags to assign to the application." } }, "type": "object" @@ -3720,6 +3755,179 @@ ] } }, + "\/applications\/{uuid}\/tags": { + "get": { + "tags": [ + "Applications" + ], + "summary": "List Tags", + "description": "List tags for an application by UUID.", + "operationId": "list-tags-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of tags.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Applications" + ], + "summary": "Create Tag", + "description": "Add tag(s) to an application by UUID.", + "operationId": "create-tag-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "tag_name": { + "type": "string", + "description": "The tag name (min 2 characters). Required if tag_names is not provided." + }, + "tag_names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of tag names (each min 2 characters). Required if tag_name is not provided." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Tags added successfully.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/tags\/{tag_uuid}": { + "delete": { + "tags": [ + "Applications" + ], + "summary": "Delete Tag", + "description": "Remove a tag from an application by UUID.", + "operationId": "delete-tag-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "tag_uuid", + "in": "path", + "description": "UUID of the tag.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tag removed." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/cloud-tokens": { "get": { "tags": [ @@ -5018,6 +5226,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5150,6 +5365,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5278,6 +5500,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5410,6 +5639,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5542,6 +5778,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5686,6 +5929,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5830,6 +6080,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5962,6 +6219,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -7106,6 +7370,179 @@ ] } }, + "\/databases\/{uuid}\/tags": { + "get": { + "tags": [ + "Databases" + ], + "summary": "List Tags", + "description": "List tags for a database by UUID.", + "operationId": "list-tags-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of tags.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Databases" + ], + "summary": "Create Tag", + "description": "Add tag(s) to a database by UUID.", + "operationId": "create-tag-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "tag_name": { + "type": "string", + "description": "The tag name (min 2 characters). Required if tag_names is not provided." + }, + "tag_names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of tag names (each min 2 characters). Required if tag_name is not provided." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Tags added successfully.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/tags\/{tag_uuid}": { + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete Tag", + "description": "Remove a tag from a database by UUID.", + "operationId": "delete-tag-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "tag_uuid", + "in": "path", + "description": "UUID of the tag.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tag removed." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/deployments": { "get": { "tags": [ @@ -10968,6 +11405,13 @@ "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": { + "type": "string" + }, + "description": "Tags to assign to the service." } }, "type": "object" @@ -12390,6 +12834,215 @@ ] } }, + "\/services\/{uuid}\/tags": { + "get": { + "tags": [ + "Services" + ], + "summary": "List Tags", + "description": "List tags for a service by UUID.", + "operationId": "list-tags-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of tags.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Services" + ], + "summary": "Create Tag", + "description": "Add tag(s) to a service by UUID.", + "operationId": "create-tag-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "tag_name": { + "type": "string", + "description": "The tag name (min 2 characters). Required if tag_names is not provided." + }, + "tag_names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of tag names (each min 2 characters). Required if tag_name is not provided." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Tags added successfully.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/tags\/{tag_uuid}": { + "delete": { + "tags": [ + "Services" + ], + "summary": "Delete Tag", + "description": "Remove a tag from a service by UUID.", + "operationId": "delete-tag-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "tag_uuid", + "in": "path", + "description": "UUID of the tag.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tag removed." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/tags": { + "get": { + "tags": [ + "Tags" + ], + "summary": "List", + "description": "List all tags for the current team.", + "operationId": "list-tags", + "responses": { + "200": { + "description": "All tags for the current team.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/teams": { "get": { "tags": [ @@ -13610,6 +14263,24 @@ }, "type": "object" }, + "Tag": { + "description": "Tag model", + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "type": "object" + }, "Team": { "description": "Team model", "properties": { @@ -13864,6 +14535,10 @@ "name": "Services", "description": "Services" }, + { + "name": "Tags", + "description": "Tags" + }, { "name": "Teams", "description": "Teams" diff --git a/openapi.yaml b/openapi.yaml index dea6b8589..03486bb40 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -293,6 +293,10 @@ paths: 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: { type: string } + description: 'Tags to assign to the application.' is_preserve_repository_enabled: type: boolean default: false @@ -583,6 +587,10 @@ paths: 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: { type: string } + description: 'Tags to assign to the application.' is_preserve_repository_enabled: type: boolean default: false @@ -873,6 +881,10 @@ paths: 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: { type: string } + description: 'Tags to assign to the application.' is_preserve_repository_enabled: type: boolean default: false @@ -1104,6 +1116,10 @@ paths: 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: { type: string } + description: 'Tags to assign to the application.' type: object responses: '201': @@ -1321,6 +1337,10 @@ paths: 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: { type: string } + description: 'Tags to assign to the application.' type: object responses: '201': @@ -2376,6 +2396,121 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/tags': + get: + tags: + - Applications + summary: 'List Tags' + description: 'List tags for an application by UUID.' + operationId: list-tags-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + responses: + '200': + description: 'List of tags.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Applications + summary: 'Create Tag' + description: 'Add tag(s) to an application by UUID.' + operationId: create-tag-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + tag_name: + type: string + description: 'The tag name (min 2 characters). Required if tag_names is not provided.' + tag_names: + type: array + items: { type: string } + description: 'Array of tag names (each min 2 characters). Required if tag_name is not provided.' + type: object + responses: + '201': + description: 'Tags added successfully.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/tags/{tag_uuid}': + delete: + tags: + - Applications + summary: 'Delete Tag' + description: 'Remove a tag from an application by UUID.' + operationId: delete-tag-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: tag_uuid + in: path + description: 'UUID of the tag.' + required: true + schema: + type: string + responses: + '200': + description: 'Tag removed.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /cloud-tokens: get: tags: @@ -3244,6 +3379,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3339,6 +3478,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3431,6 +3574,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3526,6 +3673,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3621,6 +3772,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3725,6 +3880,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3829,6 +3988,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3924,6 +4087,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -4636,6 +4803,121 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/tags': + get: + tags: + - Databases + summary: 'List Tags' + description: 'List tags for a database by UUID.' + operationId: list-tags-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + responses: + '200': + description: 'List of tags.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Databases + summary: 'Create Tag' + description: 'Add tag(s) to a database by UUID.' + operationId: create-tag-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + tag_name: + type: string + description: 'The tag name (min 2 characters). Required if tag_names is not provided.' + tag_names: + type: array + items: { type: string } + description: 'Array of tag names (each min 2 characters). Required if tag_name is not provided.' + type: object + responses: + '201': + description: 'Tags added successfully.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/tags/{tag_uuid}': + delete: + tags: + - Databases + summary: 'Delete Tag' + description: 'Remove a tag from a database by UUID.' + operationId: delete-tag-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + - + name: tag_uuid + in: path + description: 'UUID of the tag.' + required: true + schema: + type: string + responses: + '200': + description: 'Tag removed.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /deployments: get: tags: @@ -7008,6 +7290,10 @@ paths: 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: { type: string } + description: 'Tags to assign to the service.' type: object responses: '201': @@ -7860,6 +8146,144 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/tags': + get: + tags: + - Services + summary: 'List Tags' + description: 'List tags for a service by UUID.' + operationId: list-tags-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + responses: + '200': + description: 'List of tags.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Services + summary: 'Create Tag' + description: 'Add tag(s) to a service by UUID.' + operationId: create-tag-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + tag_name: + type: string + description: 'The tag name (min 2 characters). Required if tag_names is not provided.' + tag_names: + type: array + items: { type: string } + description: 'Array of tag names (each min 2 characters). Required if tag_name is not provided.' + type: object + responses: + '201': + description: 'Tags added successfully.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/tags/{tag_uuid}': + delete: + tags: + - Services + summary: 'Delete Tag' + description: 'Remove a tag from a service by UUID.' + operationId: delete-tag-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: tag_uuid + in: path + description: 'UUID of the tag.' + required: true + schema: + type: string + responses: + '200': + description: 'Tag removed.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /tags: + get: + tags: + - Tags + summary: List + description: 'List all tags for the current team.' + operationId: list-tags + responses: + '200': + description: 'All tags for the current team.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] /teams: get: tags: @@ -8740,6 +9164,18 @@ components: type: string description: 'The date and time when the service was deleted.' type: object + Tag: + description: 'Tag model' + properties: + uuid: + type: string + name: + type: string + created_at: + type: string + updated_at: + type: string + type: object Team: description: 'Team model' properties: @@ -8911,6 +9347,9 @@ tags: - name: Services description: Services + - + name: Tags + description: Tags - name: Teams description: Teams diff --git a/routes/api.php b/routes/api.php index a0034f0c6..5da528d0a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -15,6 +15,7 @@ use App\Http\Controllers\Api\SentinelController; 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 Illuminate\Support\Facades\Route; @@ -108,6 +109,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']); @@ -130,6 +133,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']); @@ -175,6 +182,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']); @@ -198,6 +209,10 @@ Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); Route::get('/services/{uuid}/logs', [ServicesController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); + 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 000000000..448d7917f --- /dev/null +++ b/tests/Feature/TagApiTest.php @@ -0,0 +1,547 @@ + InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true])); + + $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->assertOk(); + $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->assertOk(); + $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->assertOk(); + $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->assertOk(); + $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->assertNotFound(); + }); + + 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->assertOk(); + $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->assertCreated(); + $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->assertCreated(); + $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->assertCreated(); + 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->assertUnprocessable(); + }); + + 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->assertUnprocessable(); + $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->assertCreated(); + 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->assertNotFound(); + }); +}); + +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->assertOk(); + 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('returns 404 when deleting a tag that is not attached to the application', function () { + $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}"); + + $response->assertNotFound(); + expect(Tag::find($tag->id))->not->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->assertNotFound(); + }); +}); + +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->assertOk(); + $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->assertCreated(); + 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->assertOk(); + 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->assertOk(); + $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->assertCreated(); + 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->assertOk(); + expect($service->tags()->count())->toBe(0); + }); +}); + +describe('Resource creation tags', function () { + test('adds tags while creating a public application', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson('/api/v1/applications/public', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'git_repository' => 'https://gitlab.com/coolify/test-static-app', + 'git_branch' => 'main', + 'build_pack' => 'static', + 'ports_exposes' => '80', + 'autogenerate_domain' => false, + 'tags' => ['production', 'frontend'], + ]); + + $response->assertSuccessful(); + + $application = Application::whereUuid($response->json('uuid'))->firstOrFail(); + + expect($application->tags()->pluck('name')->sort()->values()->all()) + ->toBe(['frontend', 'production']); + }); + + test('adds tags while creating a postgresql database', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson('/api/v1/databases/postgresql', [ + 'server_uuid' => $this->server->uuid, + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'instant_deploy' => false, + 'tags' => ['database', 'production'], + ]); + + $response->assertSuccessful(); + + $database = StandalonePostgresql::whereUuid($response->json('uuid'))->firstOrFail(); + + expect($database->tags()->pluck('name')->sort()->values()->all()) + ->toBe(['database', 'production']); + }); + + test('adds tags while creating a docker compose service', function () { + $compose = base64_encode(<<<'YAML' +services: + nginx: + image: nginx:alpine +YAML); + + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson('/api/v1/services', [ + 'server_uuid' => $this->server->uuid, + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'docker_compose_raw' => $compose, + 'instant_deploy' => false, + 'tags' => ['service', 'production'], + ]); + + $response->assertCreated(); + + $service = Service::whereUuid($response->json('uuid'))->firstOrFail(); + + expect($service->tags()->pluck('name')->sort()->values()->all()) + ->toBe(['production', 'service']); + }); + + test('rejects creation tags that are too short after sanitization', function () { + $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) + ->postJson('/api/v1/databases/postgresql', [ + 'server_uuid' => $this->server->uuid, + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'instant_deploy' => false, + 'tags' => ['x'], + ]); + + $response->assertUnprocessable(); + }); +}); + +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->assertCreated(); + $response->assertJsonFragment(['name' => 'alert("xss")production']); + $response->assertJsonMissing(['name' => '