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.
This commit is contained in:
Andras Bacsai 2026-07-07 13:56:33 +02:00
parent f617e58401
commit 11b35ba3c1
12 changed files with 1466 additions and 80 deletions

View file

@ -969,6 +969,13 @@ private function create_application(Request $request, $type)
], 422); ], 422);
} }
$return = $this->validateTagsParameter($request);
if ($return instanceof JsonResponse) {
return $return;
}
$tagNames = $request->input('tags') ?? [];
$environmentUuid = $request->environment_uuid; $environmentUuid = $request->environment_uuid;
$environmentName = $request->environment_name; $environmentName = $request->environment_name;
if (blank($environmentUuid) && blank($environmentName)) { 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->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save(); $application->save();
} }
if ($request->has('tags')) { if ($tagNames !== []) {
$this->attachTagsToResource($application, $request->tags, $teamId); $this->attachTagsToResource($application, $tagNames, $teamId);
} }
$application->isConfigurationChanged(true); $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->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save(); $application->save();
} }
if ($request->has('tags')) { if ($tagNames !== []) {
$this->attachTagsToResource($application, $request->tags, $teamId); $this->attachTagsToResource($application, $tagNames, $teamId);
} }
$application->isConfigurationChanged(true); $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->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save(); $application->save();
} }
if ($request->has('tags')) { if ($tagNames !== []) {
$this->attachTagsToResource($application, $request->tags, $teamId); $this->attachTagsToResource($application, $tagNames, $teamId);
} }
$application->isConfigurationChanged(true); $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->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save(); $application->save();
} }
if ($request->has('tags')) { if ($tagNames !== []) {
$this->attachTagsToResource($application, $request->tags, $teamId); $this->attachTagsToResource($application, $tagNames, $teamId);
} }
$application->isConfigurationChanged(true); $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->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save(); $application->save();
} }
if ($request->has('tags')) { if ($tagNames !== []) {
$this->attachTagsToResource($application, $request->tags, $teamId); $this->attachTagsToResource($application, $tagNames, $teamId);
} }
$application->isConfigurationChanged(true); $application->isConfigurationChanged(true);

View file

@ -6,7 +6,6 @@
use App\Models\Tag; use App\Models\Tag;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
trait HandlesTagsApi trait HandlesTagsApi
@ -46,7 +45,7 @@ public function createTag(Request $request): JsonResponse
} }
$return = validateIncomingRequest($request); $return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) { if ($return instanceof JsonResponse) {
return $return; return $return;
} }
@ -65,9 +64,9 @@ public function createTag(Request $request): JsonResponse
} }
$validator = Validator::make($request->all(), [ $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' => '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']); $extraFields = array_diff(array_keys($request->all()), ['tag_name', 'tag_names']);
@ -85,7 +84,14 @@ public function createTag(Request $request): JsonResponse
], 422); ], 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); $this->attachTagsToResource($resource, $tagNames, $teamId);
@ -111,32 +117,58 @@ public function deleteTag(Request $request): JsonResponse
return response()->json(['message' => 'Tag not found.'], 404); return response()->json(['message' => 'Tag not found.'], 404);
} }
$resource->tags()->detach($tag->id); if (! $resource->tags()->whereKey($tag->id)->exists()) {
return response()->json(['message' => 'Tag not found on resource.'], 404);
if (DB::table('taggables')->where('tag_id', $tag->id)->count() === 0) {
$tag->delete();
} }
$resource->tags()->detach($tag->id);
$tag->deleteIfOrphaned();
return response()->json(['message' => 'Tag removed.']); return response()->json(['message' => 'Tag removed.']);
} }
protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void
{ {
foreach ($tagNames as $tagName) { foreach ($this->normalizeTagNames($tagNames) as $tagName) {
$tagName = strtolower(strip_tags($tagName)); if (mb_strlen($tagName) < 2) {
if (strlen($tagName) < 2) {
continue; continue;
} }
$tag = Tag::where('team_id', $teamId)->where('name', $tagName)->first(); $tag = Tag::query()->createOrFirst([
if (! $tag) { 'team_id' => $teamId,
$tag = Tag::create([ 'name' => $tagName,
'name' => $tagName, ]);
'team_id' => $teamId,
]);
}
$resource->tags()->syncWithoutDetaching([$tag->id]); $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();
}
} }

View file

@ -1663,7 +1663,7 @@ public function create_database_mongodb(Request $request)
public function create_database(Request $request, NewDatabaseTypes $type) 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(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@ -1771,6 +1771,13 @@ public function create_database(Request $request, NewDatabaseTypes $type)
'errors' => $validator->errors(), 'errors' => $validator->errors(),
], 422); ], 422);
} }
$return = $this->validateTagsParameter($request);
if ($return instanceof JsonResponse) {
return $return;
}
$tagNames = $request->input('tags') ?? [];
if ($request->public_port) { if ($request->public_port) {
if ($request->public_port < 1024 || $request->public_port > 65535) { if ($request->public_port < 1024 || $request->public_port > 65535) {
return response()->json([ return response()->json([
@ -1830,8 +1837,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($request->has('tags')) { if ($tagNames !== []) {
$this->attachTagsToResource($database, $request->tags, $teamId); $this->attachTagsToResource($database, $tagNames, $teamId);
} }
$database->refresh(); $database->refresh();
$payload = [ $payload = [
@ -1901,8 +1908,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($request->has('tags')) { if ($tagNames !== []) {
$this->attachTagsToResource($database, $request->tags, $teamId); $this->attachTagsToResource($database, $tagNames, $teamId);
} }
$database->refresh(); $database->refresh();
@ -1973,8 +1980,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($request->has('tags')) { if ($tagNames !== []) {
$this->attachTagsToResource($database, $request->tags, $teamId); $this->attachTagsToResource($database, $tagNames, $teamId);
} }
$database->refresh(); $database->refresh();
@ -2042,8 +2049,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($request->has('tags')) { if ($tagNames !== []) {
$this->attachTagsToResource($database, $request->tags, $teamId); $this->attachTagsToResource($database, $tagNames, $teamId);
} }
$database->refresh(); $database->refresh();
@ -2092,8 +2099,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($request->has('tags')) { if ($tagNames !== []) {
$this->attachTagsToResource($database, $request->tags, $teamId); $this->attachTagsToResource($database, $tagNames, $teamId);
} }
return response()->json(serializeApiResponse([ return response()->json(serializeApiResponse([
@ -2144,8 +2151,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($request->has('tags')) { if ($tagNames !== []) {
$this->attachTagsToResource($database, $request->tags, $teamId); $this->attachTagsToResource($database, $tagNames, $teamId);
} }
$database->refresh(); $database->refresh();
@ -2193,8 +2200,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($request->has('tags')) { if ($tagNames !== []) {
$this->attachTagsToResource($database, $request->tags, $teamId); $this->attachTagsToResource($database, $tagNames, $teamId);
} }
$database->refresh(); $database->refresh();
@ -2264,8 +2271,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($request->has('tags')) { if ($tagNames !== []) {
$this->attachTagsToResource($database, $request->tags, $teamId); $this->attachTagsToResource($database, $tagNames, $teamId);
} }
$database->refresh(); $database->refresh();

View file

@ -352,6 +352,11 @@ public function create_service(Request $request)
], 422); ], 422);
} }
$return = $this->validateTagsParameter($request);
if ($return instanceof JsonResponse) {
return $return;
}
if (filled($request->type) && filled($request->docker_compose_raw)) { if (filled($request->type) && filled($request->docker_compose_raw)) {
return response()->json([ return response()->json([
'message' => 'You cannot provide both service type and docker_compose_raw. Use one or the other.', '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', 'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean', 'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean', 'is_container_label_escape_enabled' => 'boolean',
'tags' => 'array|nullable',
'tags.*' => 'string|min:2',
]; ];
$validationMessages = [ $validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',

View file

@ -91,9 +91,7 @@ public function deleteTag(string $id)
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
$this->resource->tags()->detach($id); $this->resource->tags()->detach($id);
$found_more_tags = Tag::ownedByCurrentTeam()->find($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?->deleteIfOrphaned();
$found_more_tags->delete();
}
$this->refresh(); $this->refresh();
$this->dispatch('success', 'Tag deleted.'); $this->dispatch('success', 'Tag deleted.');
} catch (\Exception $e) { } catch (\Exception $e) {

View file

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Illuminate\Support\Facades\DB;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
#[OA\Schema( #[OA\Schema(
@ -34,6 +35,13 @@ public static function ownedByCurrentTeam()
return Tag::whereTeamId(currentTeam()->id)->orderBy('name'); 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() public function applications()
{ {
return $this->morphedByMany(Application::class, 'taggable'); return $this->morphedByMany(Application::class, 'taggable');

View file

@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if ($this->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');
}
};

View file

@ -412,6 +412,13 @@
"default": true, "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." "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": { "is_preserve_repository_enabled": {
"type": "boolean", "type": "boolean",
"default": false, "default": false,
@ -866,6 +873,13 @@
"default": true, "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." "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": { "is_preserve_repository_enabled": {
"type": "boolean", "type": "boolean",
"default": false, "default": false,
@ -1320,6 +1334,13 @@
"default": true, "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." "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": { "is_preserve_repository_enabled": {
"type": "boolean", "type": "boolean",
"default": false, "default": false,
@ -1678,6 +1699,13 @@
"type": "boolean", "type": "boolean",
"default": true, "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." "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" "type": "object"
@ -2017,6 +2045,13 @@
"type": "boolean", "type": "boolean",
"default": true, "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." "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" "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": { "\/cloud-tokens": {
"get": { "get": {
"tags": [ "tags": [
@ -5018,6 +5226,13 @@
"instant_deploy": { "instant_deploy": {
"type": "boolean", "type": "boolean",
"description": "Instant deploy the database" "description": "Instant deploy the database"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tags to assign to the database."
} }
}, },
"type": "object" "type": "object"
@ -5150,6 +5365,13 @@
"instant_deploy": { "instant_deploy": {
"type": "boolean", "type": "boolean",
"description": "Instant deploy the database" "description": "Instant deploy the database"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tags to assign to the database."
} }
}, },
"type": "object" "type": "object"
@ -5278,6 +5500,13 @@
"instant_deploy": { "instant_deploy": {
"type": "boolean", "type": "boolean",
"description": "Instant deploy the database" "description": "Instant deploy the database"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tags to assign to the database."
} }
}, },
"type": "object" "type": "object"
@ -5410,6 +5639,13 @@
"instant_deploy": { "instant_deploy": {
"type": "boolean", "type": "boolean",
"description": "Instant deploy the database" "description": "Instant deploy the database"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tags to assign to the database."
} }
}, },
"type": "object" "type": "object"
@ -5542,6 +5778,13 @@
"instant_deploy": { "instant_deploy": {
"type": "boolean", "type": "boolean",
"description": "Instant deploy the database" "description": "Instant deploy the database"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tags to assign to the database."
} }
}, },
"type": "object" "type": "object"
@ -5686,6 +5929,13 @@
"instant_deploy": { "instant_deploy": {
"type": "boolean", "type": "boolean",
"description": "Instant deploy the database" "description": "Instant deploy the database"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tags to assign to the database."
} }
}, },
"type": "object" "type": "object"
@ -5830,6 +6080,13 @@
"instant_deploy": { "instant_deploy": {
"type": "boolean", "type": "boolean",
"description": "Instant deploy the database" "description": "Instant deploy the database"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tags to assign to the database."
} }
}, },
"type": "object" "type": "object"
@ -5962,6 +6219,13 @@
"instant_deploy": { "instant_deploy": {
"type": "boolean", "type": "boolean",
"description": "Instant deploy the database" "description": "Instant deploy the database"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "Tags to assign to the database."
} }
}, },
"type": "object" "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": { "\/deployments": {
"get": { "get": {
"tags": [ "tags": [
@ -10968,6 +11405,13 @@
"type": "boolean", "type": "boolean",
"default": true, "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." "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" "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": { "\/teams": {
"get": { "get": {
"tags": [ "tags": [
@ -13610,6 +14263,24 @@
}, },
"type": "object" "type": "object"
}, },
"Tag": {
"description": "Tag model",
"properties": {
"uuid": {
"type": "string"
},
"name": {
"type": "string"
},
"created_at": {
"type": "string"
},
"updated_at": {
"type": "string"
}
},
"type": "object"
},
"Team": { "Team": {
"description": "Team model", "description": "Team model",
"properties": { "properties": {
@ -13864,6 +14535,10 @@
"name": "Services", "name": "Services",
"description": "Services" "description": "Services"
}, },
{
"name": "Tags",
"description": "Tags"
},
{ {
"name": "Teams", "name": "Teams",
"description": "Teams" "description": "Teams"

View file

@ -293,6 +293,10 @@ paths:
type: boolean type: boolean
default: true 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.' 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: is_preserve_repository_enabled:
type: boolean type: boolean
default: false default: false
@ -583,6 +587,10 @@ paths:
type: boolean type: boolean
default: true 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.' 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: is_preserve_repository_enabled:
type: boolean type: boolean
default: false default: false
@ -873,6 +881,10 @@ paths:
type: boolean type: boolean
default: true 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.' 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: is_preserve_repository_enabled:
type: boolean type: boolean
default: false default: false
@ -1104,6 +1116,10 @@ paths:
type: boolean type: boolean
default: true 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.' 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 type: object
responses: responses:
'201': '201':
@ -1321,6 +1337,10 @@ paths:
type: boolean type: boolean
default: true 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.' 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 type: object
responses: responses:
'201': '201':
@ -2376,6 +2396,121 @@ paths:
security: security:
- -
bearerAuth: [] 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: /cloud-tokens:
get: get:
tags: tags:
@ -3244,6 +3379,10 @@ paths:
instant_deploy: instant_deploy:
type: boolean type: boolean
description: 'Instant deploy the database' description: 'Instant deploy the database'
tags:
type: array
items: { type: string }
description: 'Tags to assign to the database.'
type: object type: object
responses: responses:
'200': '200':
@ -3339,6 +3478,10 @@ paths:
instant_deploy: instant_deploy:
type: boolean type: boolean
description: 'Instant deploy the database' description: 'Instant deploy the database'
tags:
type: array
items: { type: string }
description: 'Tags to assign to the database.'
type: object type: object
responses: responses:
'200': '200':
@ -3431,6 +3574,10 @@ paths:
instant_deploy: instant_deploy:
type: boolean type: boolean
description: 'Instant deploy the database' description: 'Instant deploy the database'
tags:
type: array
items: { type: string }
description: 'Tags to assign to the database.'
type: object type: object
responses: responses:
'200': '200':
@ -3526,6 +3673,10 @@ paths:
instant_deploy: instant_deploy:
type: boolean type: boolean
description: 'Instant deploy the database' description: 'Instant deploy the database'
tags:
type: array
items: { type: string }
description: 'Tags to assign to the database.'
type: object type: object
responses: responses:
'200': '200':
@ -3621,6 +3772,10 @@ paths:
instant_deploy: instant_deploy:
type: boolean type: boolean
description: 'Instant deploy the database' description: 'Instant deploy the database'
tags:
type: array
items: { type: string }
description: 'Tags to assign to the database.'
type: object type: object
responses: responses:
'200': '200':
@ -3725,6 +3880,10 @@ paths:
instant_deploy: instant_deploy:
type: boolean type: boolean
description: 'Instant deploy the database' description: 'Instant deploy the database'
tags:
type: array
items: { type: string }
description: 'Tags to assign to the database.'
type: object type: object
responses: responses:
'200': '200':
@ -3829,6 +3988,10 @@ paths:
instant_deploy: instant_deploy:
type: boolean type: boolean
description: 'Instant deploy the database' description: 'Instant deploy the database'
tags:
type: array
items: { type: string }
description: 'Tags to assign to the database.'
type: object type: object
responses: responses:
'200': '200':
@ -3924,6 +4087,10 @@ paths:
instant_deploy: instant_deploy:
type: boolean type: boolean
description: 'Instant deploy the database' description: 'Instant deploy the database'
tags:
type: array
items: { type: string }
description: 'Tags to assign to the database.'
type: object type: object
responses: responses:
'200': '200':
@ -4636,6 +4803,121 @@ paths:
security: security:
- -
bearerAuth: [] 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: /deployments:
get: get:
tags: tags:
@ -7008,6 +7290,10 @@ paths:
type: boolean type: boolean
default: true 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.' 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 type: object
responses: responses:
'201': '201':
@ -7860,6 +8146,144 @@ paths:
security: security:
- -
bearerAuth: [] 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: /teams:
get: get:
tags: tags:
@ -8740,6 +9164,18 @@ components:
type: string type: string
description: 'The date and time when the service was deleted.' description: 'The date and time when the service was deleted.'
type: object type: object
Tag:
description: 'Tag model'
properties:
uuid:
type: string
name:
type: string
created_at:
type: string
updated_at:
type: string
type: object
Team: Team:
description: 'Team model' description: 'Team model'
properties: properties:
@ -8911,6 +9347,9 @@ tags:
- -
name: Services name: Services
description: Services description: Services
-
name: Tags
description: Tags
- -
name: Teams name: Teams
description: Teams description: Teams

View file

@ -803,7 +803,7 @@
"category": "backend", "category": "backend",
"logo": "svgs/convex.svg", "logo": "svgs/convex.svg",
"minversion": "0.0.0", "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" "port": "6791"
}, },
"cryptgeon": { "cryptgeon": {
@ -1779,7 +1779,7 @@
"category": "devtools", "category": "devtools",
"logo": "svgs/gitea.svg", "logo": "svgs/gitea.svg",
"minversion": "0.0.0", "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": { "gitea-with-mariadb": {
"documentation": "https://docs.gitea.com?utm_source=coolify.io", "documentation": "https://docs.gitea.com?utm_source=coolify.io",
@ -2361,7 +2361,7 @@
"category": "automation", "category": "automation",
"logo": "svgs/inngest.png", "logo": "svgs/inngest.png",
"minversion": "0.0.0", "minversion": "0.0.0",
"template_last_updated_at": null, "template_last_updated_at": "2026-07-02T13:25:47+02:00",
"port": "8288" "port": "8288"
}, },
"invoice-ninja": { "invoice-ninja": {

View file

@ -803,7 +803,7 @@
"category": "backend", "category": "backend",
"logo": "svgs/convex.svg", "logo": "svgs/convex.svg",
"minversion": "0.0.0", "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" "port": "6791"
}, },
"cryptgeon": { "cryptgeon": {
@ -1779,7 +1779,7 @@
"category": "devtools", "category": "devtools",
"logo": "svgs/gitea.svg", "logo": "svgs/gitea.svg",
"minversion": "0.0.0", "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": { "gitea-with-mariadb": {
"documentation": "https://docs.gitea.com?utm_source=coolify.io", "documentation": "https://docs.gitea.com?utm_source=coolify.io",
@ -2361,7 +2361,7 @@
"category": "automation", "category": "automation",
"logo": "svgs/inngest.png", "logo": "svgs/inngest.png",
"minversion": "0.0.0", "minversion": "0.0.0",
"template_last_updated_at": null, "template_last_updated_at": "2026-07-02T13:25:47+02:00",
"port": "8288" "port": "8288"
}, },
"invoice-ninja": { "invoice-ninja": {

View file

@ -1,5 +1,6 @@
<?php <?php
use App\Livewire\Project\Shared\Tags as TagsComponent;
use App\Models\Application; use App\Models\Application;
use App\Models\Environment; use App\Models\Environment;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
@ -11,12 +12,14 @@
use App\Models\Tag; use App\Models\Tag;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function () { beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]); InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]));
$this->team = Team::factory()->create(); $this->team = Team::factory()->create();
$this->user = User::factory()->create(); $this->user = User::factory()->create();
@ -55,7 +58,7 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson('/api/v1/tags'); ->getJson('/api/v1/tags');
$response->assertStatus(200); $response->assertOk();
$response->assertJsonCount(2); $response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'production']); $response->assertJsonFragment(['name' => 'production']);
$response->assertJsonFragment(['name' => 'staging']); $response->assertJsonFragment(['name' => 'staging']);
@ -65,7 +68,7 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson('/api/v1/tags'); ->getJson('/api/v1/tags');
$response->assertStatus(200); $response->assertOk();
$response->assertJsonCount(0); $response->assertJsonCount(0);
}); });
@ -77,7 +80,7 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson('/api/v1/tags'); ->getJson('/api/v1/tags');
$response->assertStatus(200); $response->assertOk();
$response->assertJsonCount(1); $response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'my-tag']); $response->assertJsonFragment(['name' => 'my-tag']);
$response->assertJsonMissing(['name' => 'other-team-tag']); $response->assertJsonMissing(['name' => 'other-team-tag']);
@ -92,7 +95,7 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}/tags"); ->getJson("/api/v1/applications/{$this->application->uuid}/tags");
$response->assertStatus(200); $response->assertOk();
$response->assertJsonCount(1); $response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'production']); $response->assertJsonFragment(['name' => 'production']);
}); });
@ -101,14 +104,14 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson('/api/v1/applications/non-existent-uuid/tags'); ->getJson('/api/v1/applications/non-existent-uuid/tags');
$response->assertStatus(404); $response->assertNotFound();
}); });
test('returns empty array when application has no tags', function () { test('returns empty array when application has no tags', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}/tags"); ->getJson("/api/v1/applications/{$this->application->uuid}/tags");
$response->assertStatus(200); $response->assertOk();
$response->assertJsonCount(0); $response->assertJsonCount(0);
}); });
}); });
@ -120,7 +123,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_name' => 'production', 'tag_name' => 'production',
]); ]);
$response->assertStatus(201); $response->assertCreated();
$response->assertJsonCount(1); $response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'production']); $response->assertJsonFragment(['name' => 'production']);
@ -133,7 +136,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_names' => ['production', 'frontend'], 'tag_names' => ['production', 'frontend'],
]); ]);
$response->assertStatus(201); $response->assertCreated();
$response->assertJsonCount(2); $response->assertJsonCount(2);
expect($this->application->tags()->count())->toBe(2); expect($this->application->tags()->count())->toBe(2);
@ -147,7 +150,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_name' => 'production', 'tag_name' => 'production',
]); ]);
$response->assertStatus(201); $response->assertCreated();
expect(Tag::where('team_id', $this->team->id)->where('name', 'production')->count())->toBe(1); 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', 'tag_name' => 'x',
]); ]);
$response->assertStatus(422); $response->assertUnprocessable();
}); });
test('rejects both tag_name and tag_names provided simultaneously', function () { test('rejects both tag_name and tag_names provided simultaneously', function () {
@ -167,7 +170,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_names' => ['staging'], 'tag_names' => ['staging'],
]); ]);
$response->assertStatus(422); $response->assertUnprocessable();
$response->assertJsonFragment(['tag_name' => ['Provide either tag_name or tag_names, not both.']]); $response->assertJsonFragment(['tag_name' => ['Provide either tag_name or tag_names, not both.']]);
}); });
@ -180,7 +183,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_name' => 'production', 'tag_name' => 'production',
]); ]);
$response->assertStatus(201); $response->assertCreated();
expect($this->application->tags()->count())->toBe(1); expect($this->application->tags()->count())->toBe(1);
}); });
@ -190,7 +193,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_name' => 'production', 'tag_name' => 'production',
]); ]);
$response->assertStatus(404); $response->assertNotFound();
}); });
}); });
@ -202,7 +205,7 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}"); ->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
$response->assertStatus(200); $response->assertOk();
expect($this->application->tags()->count())->toBe(0); expect($this->application->tags()->count())->toBe(0);
}); });
@ -216,6 +219,16 @@ function tagApiAuthHeaders($bearerToken): array
expect(Tag::find($tag->id))->toBeNull(); 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 () { test('keeps tag if still used by other resources', function () {
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]); $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
$this->application->tags()->attach($tag->id); $this->application->tags()->attach($tag->id);
@ -237,7 +250,7 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/non-existent-uuid"); ->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)) $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson("/api/v1/databases/{$database->uuid}/tags"); ->getJson("/api/v1/databases/{$database->uuid}/tags");
$response->assertStatus(200); $response->assertOk();
$response->assertJsonCount(1); $response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'database-tag']); $response->assertJsonFragment(['name' => 'database-tag']);
}); });
@ -278,7 +291,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_name' => 'database-tag', 'tag_name' => 'database-tag',
]); ]);
$response->assertStatus(201); $response->assertCreated();
expect($database->tags()->count())->toBe(1); expect($database->tags()->count())->toBe(1);
}); });
}); });
@ -299,7 +312,7 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/databases/{$database->uuid}/tags/{$tag->uuid}"); ->deleteJson("/api/v1/databases/{$database->uuid}/tags/{$tag->uuid}");
$response->assertStatus(200); $response->assertOk();
expect($database->tags()->count())->toBe(0); expect($database->tags()->count())->toBe(0);
}); });
}); });
@ -319,7 +332,7 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson("/api/v1/services/{$service->uuid}/tags"); ->getJson("/api/v1/services/{$service->uuid}/tags");
$response->assertStatus(200); $response->assertOk();
$response->assertJsonCount(1); $response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'service-tag']); $response->assertJsonFragment(['name' => 'service-tag']);
}); });
@ -339,7 +352,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_name' => 'service-tag', 'tag_name' => 'service-tag',
]); ]);
$response->assertStatus(201); $response->assertCreated();
expect($service->tags()->count())->toBe(1); expect($service->tags()->count())->toBe(1);
}); });
}); });
@ -359,11 +372,91 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/services/{$service->uuid}/tags/{$tag->uuid}"); ->deleteJson("/api/v1/services/{$service->uuid}/tags/{$tag->uuid}");
$response->assertStatus(200); $response->assertOk();
expect($service->tags()->count())->toBe(0); 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' => ['<b>x</b>'],
]);
$response->assertUnprocessable();
});
});
describe('Tag name sanitization', function () { describe('Tag name sanitization', function () {
test('strips HTML tags from tag names', function () { test('strips HTML tags from tag names', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
@ -371,7 +464,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_name' => '<script>alert("xss")</script>production', 'tag_name' => '<script>alert("xss")</script>production',
]); ]);
$response->assertStatus(201); $response->assertCreated();
$response->assertJsonFragment(['name' => 'alert("xss")production']); $response->assertJsonFragment(['name' => 'alert("xss")production']);
$response->assertJsonMissing(['name' => '<script>']); $response->assertJsonMissing(['name' => '<script>']);
}); });
@ -382,9 +475,52 @@ function tagApiAuthHeaders($bearerToken): array
'tag_name' => 'Production', 'tag_name' => 'Production',
]); ]);
$response->assertStatus(201); $response->assertCreated();
$response->assertJsonFragment(['name' => 'production']); $response->assertJsonFragment(['name' => 'production']);
}); });
test('rejects tag names that are too short after sanitization', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_name' => '<b>x</b>',
]);
$response->assertUnprocessable();
expect($this->application->tags()->count())->toBe(0);
});
});
describe('Tag uniqueness', function () {
test('prevents duplicate tag names per team at the database level', function () {
Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
expect(fn () => Tag::create(['name' => 'production', 'team_id' => $this->team->id]))
->toThrow(QueryException::class);
});
});
describe('Tag cleanup across resource types', function () {
test('does not delete a tag still attached to a database when removed from an application in the UI', function () {
$this->actingAs($this->user);
$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' => 'shared-tag', 'team_id' => $this->team->id]);
$this->application->tags()->attach($tag->id);
$database->tags()->attach($tag->id);
Livewire::test(TagsComponent::class, ['resource' => $this->application])
->call('deleteTag', (string) $tag->id);
expect(Tag::find($tag->id))->not->toBeNull()
->and($database->tags()->count())->toBe(1);
});
}); });
describe('Cross-resource tag isolation', function () { describe('Cross-resource tag isolation', function () {
@ -399,9 +535,10 @@ function tagApiAuthHeaders($bearerToken): array
$otherApp->tags()->attach($tag->id); $otherApp->tags()->attach($tag->id);
// Tag is not attached to $this->application, but we try to delete via it // Tag is not attached to $this->application, but we try to delete via it
$this->withHeaders(tagApiAuthHeaders($this->bearerToken)) $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}"); ->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
$response->assertNotFound();
// Tag should still exist on the other app (detach on non-attached is a no-op) // Tag should still exist on the other app (detach on non-attached is a no-op)
expect($otherApp->tags()->count())->toBe(1); expect($otherApp->tags()->count())->toBe(1);
// Tag should NOT be garbage-collected since otherApp still uses it // Tag should NOT be garbage-collected since otherApp still uses it