mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-22 16:23:51 +00:00
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:
@@ -969,6 +969,13 @@ class ApplicationsController extends Controller
|
||||
], 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 @@ class ApplicationsController extends Controller
|
||||
$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 @@ class ApplicationsController extends Controller
|
||||
$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 @@ class ApplicationsController extends Controller
|
||||
$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 @@ class ApplicationsController extends Controller
|
||||
$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 @@ class ApplicationsController extends Controller
|
||||
$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);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ use App\Http\Controllers\Api\TagsController;
|
||||
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 @@ trait HandlesTagsApi
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -65,9 +64,9 @@ trait HandlesTagsApi
|
||||
}
|
||||
|
||||
$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 @@ trait HandlesTagsApi
|
||||
], 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 @@ trait HandlesTagsApi
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1663,7 +1663,7 @@ class DatabasesController extends Controller
|
||||
|
||||
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 @@ class DatabasesController extends Controller
|
||||
'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 @@ class DatabasesController extends Controller
|
||||
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 @@ class DatabasesController extends Controller
|
||||
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 @@ class DatabasesController extends Controller
|
||||
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 @@ class DatabasesController extends Controller
|
||||
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 @@ class DatabasesController extends Controller
|
||||
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 @@ class DatabasesController extends Controller
|
||||
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 @@ class DatabasesController extends Controller
|
||||
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 @@ class DatabasesController extends Controller
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($database, $request->tags, $teamId);
|
||||
if ($tagNames !== []) {
|
||||
$this->attachTagsToResource($database, $tagNames, $teamId);
|
||||
}
|
||||
|
||||
$database->refresh();
|
||||
|
||||
@@ -352,6 +352,11 @@ class ServicesController extends Controller
|
||||
], 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 @@ class ServicesController extends Controller
|
||||
'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.',
|
||||
|
||||
@@ -91,9 +91,7 @@ class Tags extends Component
|
||||
$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) {
|
||||
|
||||
@@ -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 @@ class Tag extends BaseModel
|
||||
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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
+675
@@ -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"
|
||||
|
||||
+439
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+162
-25
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\Tags as TagsComponent;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
@@ -11,12 +12,14 @@ use App\Models\StandalonePostgresql;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 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 @@ describe('GET /api/v1/tags', function () {
|
||||
$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 @@ describe('GET /api/v1/tags', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/tags');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(0);
|
||||
});
|
||||
|
||||
@@ -77,7 +80,7 @@ describe('GET /api/v1/tags', function () {
|
||||
$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 @@ describe('GET /api/v1/applications/{uuid}/tags', function () {
|
||||
$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 @@ describe('GET /api/v1/applications/{uuid}/tags', function () {
|
||||
$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 @@ describe('POST /api/v1/applications/{uuid}/tags', function () {
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertCreated();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'production']);
|
||||
|
||||
@@ -133,7 +136,7 @@ describe('POST /api/v1/applications/{uuid}/tags', function () {
|
||||
'tag_names' => ['production', 'frontend'],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertCreated();
|
||||
$response->assertJsonCount(2);
|
||||
|
||||
expect($this->application->tags()->count())->toBe(2);
|
||||
@@ -147,7 +150,7 @@ describe('POST /api/v1/applications/{uuid}/tags', function () {
|
||||
'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 @@ describe('POST /api/v1/applications/{uuid}/tags', function () {
|
||||
'tag_name' => 'x',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('rejects both tag_name and tag_names provided simultaneously', function () {
|
||||
@@ -167,7 +170,7 @@ describe('POST /api/v1/applications/{uuid}/tags', function () {
|
||||
'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 @@ describe('POST /api/v1/applications/{uuid}/tags', function () {
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertCreated();
|
||||
expect($this->application->tags()->count())->toBe(1);
|
||||
});
|
||||
|
||||
@@ -190,7 +193,7 @@ describe('POST /api/v1/applications/{uuid}/tags', function () {
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertNotFound();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -202,7 +205,7 @@ describe('DELETE /api/v1/applications/{uuid}/tags/{tag_uuid}', function () {
|
||||
$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 @@ describe('DELETE /api/v1/applications/{uuid}/tags/{tag_uuid}', function () {
|
||||
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 @@ describe('DELETE /api/v1/applications/{uuid}/tags/{tag_uuid}', function () {
|
||||
$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 @@ describe('GET /api/v1/databases/{uuid}/tags', function () {
|
||||
$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 @@ describe('POST /api/v1/databases/{uuid}/tags', function () {
|
||||
'tag_name' => 'database-tag',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertCreated();
|
||||
expect($database->tags()->count())->toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -299,7 +312,7 @@ describe('DELETE /api/v1/databases/{uuid}/tags/{tag_uuid}', function () {
|
||||
$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 @@ describe('GET /api/v1/services/{uuid}/tags', function () {
|
||||
$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 @@ describe('POST /api/v1/services/{uuid}/tags', function () {
|
||||
'tag_name' => 'service-tag',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertCreated();
|
||||
expect($service->tags()->count())->toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -359,11 +372,91 @@ describe('DELETE /api/v1/services/{uuid}/tags/{tag_uuid}', function () {
|
||||
$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' => ['<b>x</b>'],
|
||||
]);
|
||||
|
||||
$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 @@ describe('Tag name sanitization', function () {
|
||||
'tag_name' => '<script>alert("xss")</script>production',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertCreated();
|
||||
$response->assertJsonFragment(['name' => 'alert("xss")production']);
|
||||
$response->assertJsonMissing(['name' => '<script>']);
|
||||
});
|
||||
@@ -382,9 +475,52 @@ describe('Tag name sanitization', function () {
|
||||
'tag_name' => 'Production',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertCreated();
|
||||
$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 () {
|
||||
@@ -399,9 +535,10 @@ describe('Cross-resource tag isolation', function () {
|
||||
$otherApp->tags()->attach($tag->id);
|
||||
|
||||
// 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}");
|
||||
|
||||
$response->assertNotFound();
|
||||
// Tag should still exist on the other app (detach on non-attached is a no-op)
|
||||
expect($otherApp->tags()->count())->toBe(1);
|
||||
// Tag should NOT be garbage-collected since otherApp still uses it
|
||||
|
||||
Reference in New Issue
Block a user