From 11b35ba3c15b992e3b4431ffdb3b5ca08f81bece Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:56:33 +0200 Subject: [PATCH] feat(api): add tags to resource creation Normalize tag names before attaching them, reject names that are too short after sanitization, and return 404 when removing tags not attached to the resource. Adds a per-team unique tag-name index and migrates duplicate tags onto the kept record before creating the constraint. --- .../Api/ApplicationsController.php | 27 +- .../Api/Concerns/HandlesTagsApi.php | 70 +- .../Controllers/Api/DatabasesController.php | 41 +- .../Controllers/Api/ServicesController.php | 7 + app/Livewire/Project/Shared/Tags.php | 4 +- app/Models/Tag.php | 8 + ...d_team_name_unique_index_to_tags_table.php | 76 ++ openapi.json | 675 ++++++++++++++++++ openapi.yaml | 439 ++++++++++++ templates/service-templates-latest.json | 6 +- templates/service-templates.json | 6 +- tests/Feature/TagApiTest.php | 187 ++++- 12 files changed, 1466 insertions(+), 80 deletions(-) create mode 100644 database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index fed63d798..0192befd1 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -969,6 +969,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)) { @@ -1226,8 +1233,8 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } - if ($request->has('tags')) { - $this->attachTagsToResource($application, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); } $application->isConfigurationChanged(true); @@ -1472,8 +1479,8 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } - if ($request->has('tags')) { - $this->attachTagsToResource($application, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); } $application->isConfigurationChanged(true); @@ -1688,8 +1695,8 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } - if ($request->has('tags')) { - $this->attachTagsToResource($application, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); } $application->isConfigurationChanged(true); @@ -1815,8 +1822,8 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } - if ($request->has('tags')) { - $this->attachTagsToResource($application, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); } $application->isConfigurationChanged(true); @@ -1941,8 +1948,8 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } - if ($request->has('tags')) { - $this->attachTagsToResource($application, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); } $application->isConfigurationChanged(true); diff --git a/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php index b6d6d259a..4c15d0726 100644 --- a/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php +++ b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php @@ -6,7 +6,6 @@ use App\Models\Tag; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; trait HandlesTagsApi @@ -46,7 +45,7 @@ public function createTag(Request $request): JsonResponse } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -65,9 +64,9 @@ public function createTag(Request $request): JsonResponse } $validator = Validator::make($request->all(), [ - 'tag_name' => 'required_without:tag_names|string|min:2', + 'tag_name' => 'required_without:tag_names|string', 'tag_names' => 'required_without:tag_name|array|min:1', - 'tag_names.*' => 'string|min:2', + 'tag_names.*' => 'string', ]); $extraFields = array_diff(array_keys($request->all()), ['tag_name', 'tag_names']); @@ -85,7 +84,14 @@ public function createTag(Request $request): JsonResponse ], 422); } - $tagNames = $request->has('tag_names') ? $request->tag_names : [$request->tag_name]; + $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); @@ -111,32 +117,58 @@ public function deleteTag(Request $request): JsonResponse return response()->json(['message' => 'Tag not found.'], 404); } - $resource->tags()->detach($tag->id); - - if (DB::table('taggables')->where('tag_id', $tag->id)->count() === 0) { - $tag->delete(); + 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 ($tagNames as $tagName) { - $tagName = strtolower(strip_tags($tagName)); - if (strlen($tagName) < 2) { + foreach ($this->normalizeTagNames($tagNames) as $tagName) { + if (mb_strlen($tagName) < 2) { continue; } - $tag = Tag::where('team_id', $teamId)->where('name', $tagName)->first(); - if (! $tag) { - $tag = Tag::create([ - 'name' => $tagName, - 'team_id' => $teamId, - ]); - } + $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 a8c89ca13..d3dd77955 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -1663,7 +1663,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)) { @@ -1771,6 +1771,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([ @@ -1830,8 +1837,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } - if ($request->has('tags')) { - $this->attachTagsToResource($database, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); } $database->refresh(); $payload = [ @@ -1901,8 +1908,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } - if ($request->has('tags')) { - $this->attachTagsToResource($database, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); } $database->refresh(); @@ -1973,8 +1980,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } - if ($request->has('tags')) { - $this->attachTagsToResource($database, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); } $database->refresh(); @@ -2042,8 +2049,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } - if ($request->has('tags')) { - $this->attachTagsToResource($database, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); } $database->refresh(); @@ -2092,8 +2099,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } - if ($request->has('tags')) { - $this->attachTagsToResource($database, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); } return response()->json(serializeApiResponse([ @@ -2144,8 +2151,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } - if ($request->has('tags')) { - $this->attachTagsToResource($database, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); } $database->refresh(); @@ -2193,8 +2200,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } - if ($request->has('tags')) { - $this->attachTagsToResource($database, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); } $database->refresh(); @@ -2264,8 +2271,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } - if ($request->has('tags')) { - $this->attachTagsToResource($database, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); } $database->refresh(); diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 60ef6b3eb..46e93ca1e 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -352,6 +352,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.', @@ -533,6 +538,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.', 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 0080b79d6..d5cccabd8 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\HasSafeStringAttribute; +use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; #[OA\Schema( @@ -34,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/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/templates/service-templates-latest.json b/templates/service-templates-latest.json index 8d1a3369b..0a2dfda9d 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -803,7 +803,7 @@ "category": "backend", "logo": "svgs/convex.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-09T19:26:30+05:30", + "template_last_updated_at": "2026-06-12T10:45:52+02:00", "port": "6791" }, "cryptgeon": { @@ -1779,7 +1779,7 @@ "category": "devtools", "logo": "svgs/gitea.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-06-01T07:54:27-05:00" + "template_last_updated_at": "2026-06-06T00:11:24+02:00" }, "gitea-with-mariadb": { "documentation": "https://docs.gitea.com?utm_source=coolify.io", @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, "invoice-ninja": { diff --git a/templates/service-templates.json b/templates/service-templates.json index 2ad3008f5..4d97d8d5e 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -803,7 +803,7 @@ "category": "backend", "logo": "svgs/convex.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-09T19:26:30+05:30", + "template_last_updated_at": "2026-06-12T10:45:52+02:00", "port": "6791" }, "cryptgeon": { @@ -1779,7 +1779,7 @@ "category": "devtools", "logo": "svgs/gitea.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-06-01T07:54:27-05:00" + "template_last_updated_at": "2026-06-06T00:11:24+02:00" }, "gitea-with-mariadb": { "documentation": "https://docs.gitea.com?utm_source=coolify.io", @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, "invoice-ninja": { diff --git a/tests/Feature/TagApiTest.php b/tests/Feature/TagApiTest.php index dd62c1062..448d7917f 100644 --- a/tests/Feature/TagApiTest.php +++ b/tests/Feature/TagApiTest.php @@ -1,5 +1,6 @@ 0]); + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true])); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); @@ -55,7 +58,7 @@ function tagApiAuthHeaders($bearerToken): array $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) ->getJson('/api/v1/tags'); - $response->assertStatus(200); + $response->assertOk(); $response->assertJsonCount(2); $response->assertJsonFragment(['name' => 'production']); $response->assertJsonFragment(['name' => 'staging']); @@ -65,7 +68,7 @@ function tagApiAuthHeaders($bearerToken): array $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) ->getJson('/api/v1/tags'); - $response->assertStatus(200); + $response->assertOk(); $response->assertJsonCount(0); }); @@ -77,7 +80,7 @@ function tagApiAuthHeaders($bearerToken): array $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) ->getJson('/api/v1/tags'); - $response->assertStatus(200); + $response->assertOk(); $response->assertJsonCount(1); $response->assertJsonFragment(['name' => 'my-tag']); $response->assertJsonMissing(['name' => 'other-team-tag']); @@ -92,7 +95,7 @@ function tagApiAuthHeaders($bearerToken): array $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) ->getJson("/api/v1/applications/{$this->application->uuid}/tags"); - $response->assertStatus(200); + $response->assertOk(); $response->assertJsonCount(1); $response->assertJsonFragment(['name' => 'production']); }); @@ -101,14 +104,14 @@ function tagApiAuthHeaders($bearerToken): array $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) ->getJson('/api/v1/applications/non-existent-uuid/tags'); - $response->assertStatus(404); + $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->assertStatus(200); + $response->assertOk(); $response->assertJsonCount(0); }); }); @@ -120,7 +123,7 @@ function tagApiAuthHeaders($bearerToken): array 'tag_name' => 'production', ]); - $response->assertStatus(201); + $response->assertCreated(); $response->assertJsonCount(1); $response->assertJsonFragment(['name' => 'production']); @@ -133,7 +136,7 @@ function tagApiAuthHeaders($bearerToken): array 'tag_names' => ['production', 'frontend'], ]); - $response->assertStatus(201); + $response->assertCreated(); $response->assertJsonCount(2); expect($this->application->tags()->count())->toBe(2); @@ -147,7 +150,7 @@ function tagApiAuthHeaders($bearerToken): array 'tag_name' => 'production', ]); - $response->assertStatus(201); + $response->assertCreated(); expect(Tag::where('team_id', $this->team->id)->where('name', 'production')->count())->toBe(1); }); @@ -157,7 +160,7 @@ function tagApiAuthHeaders($bearerToken): array 'tag_name' => 'x', ]); - $response->assertStatus(422); + $response->assertUnprocessable(); }); test('rejects both tag_name and tag_names provided simultaneously', function () { @@ -167,7 +170,7 @@ function tagApiAuthHeaders($bearerToken): array 'tag_names' => ['staging'], ]); - $response->assertStatus(422); + $response->assertUnprocessable(); $response->assertJsonFragment(['tag_name' => ['Provide either tag_name or tag_names, not both.']]); }); @@ -180,7 +183,7 @@ function tagApiAuthHeaders($bearerToken): array 'tag_name' => 'production', ]); - $response->assertStatus(201); + $response->assertCreated(); expect($this->application->tags()->count())->toBe(1); }); @@ -190,7 +193,7 @@ function tagApiAuthHeaders($bearerToken): array 'tag_name' => 'production', ]); - $response->assertStatus(404); + $response->assertNotFound(); }); }); @@ -202,7 +205,7 @@ function tagApiAuthHeaders($bearerToken): array $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) ->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}"); - $response->assertStatus(200); + $response->assertOk(); expect($this->application->tags()->count())->toBe(0); }); @@ -216,6 +219,16 @@ function tagApiAuthHeaders($bearerToken): array 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); @@ -237,7 +250,7 @@ function tagApiAuthHeaders($bearerToken): array $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) ->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/non-existent-uuid"); - $response->assertStatus(404); + $response->assertNotFound(); }); }); @@ -257,7 +270,7 @@ function tagApiAuthHeaders($bearerToken): array $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) ->getJson("/api/v1/databases/{$database->uuid}/tags"); - $response->assertStatus(200); + $response->assertOk(); $response->assertJsonCount(1); $response->assertJsonFragment(['name' => 'database-tag']); }); @@ -278,7 +291,7 @@ function tagApiAuthHeaders($bearerToken): array 'tag_name' => 'database-tag', ]); - $response->assertStatus(201); + $response->assertCreated(); expect($database->tags()->count())->toBe(1); }); }); @@ -299,7 +312,7 @@ function tagApiAuthHeaders($bearerToken): array $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) ->deleteJson("/api/v1/databases/{$database->uuid}/tags/{$tag->uuid}"); - $response->assertStatus(200); + $response->assertOk(); expect($database->tags()->count())->toBe(0); }); }); @@ -319,7 +332,7 @@ function tagApiAuthHeaders($bearerToken): array $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) ->getJson("/api/v1/services/{$service->uuid}/tags"); - $response->assertStatus(200); + $response->assertOk(); $response->assertJsonCount(1); $response->assertJsonFragment(['name' => 'service-tag']); }); @@ -339,7 +352,7 @@ function tagApiAuthHeaders($bearerToken): array 'tag_name' => 'service-tag', ]); - $response->assertStatus(201); + $response->assertCreated(); expect($service->tags()->count())->toBe(1); }); }); @@ -359,11 +372,91 @@ function tagApiAuthHeaders($bearerToken): array $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) ->deleteJson("/api/v1/services/{$service->uuid}/tags/{$tag->uuid}"); - $response->assertStatus(200); + $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)) @@ -371,7 +464,7 @@ function tagApiAuthHeaders($bearerToken): array 'tag_name' => 'production', ]); - $response->assertStatus(201); + $response->assertCreated(); $response->assertJsonFragment(['name' => 'alert("xss")production']); $response->assertJsonMissing(['name' => '