feat(api): add tag management endpoints for applications, databases, and services
Add CRUD tag endpoints (GET/POST/DELETE) as sub-resources for applications, databases, and services. Add team-level GET /tags endpoint. Extend all resource creation endpoints to accept an optional tags array. Uses a shared HandlesTagsApi trait to avoid duplication across controllers. Tags are race-safe via syncWithoutDetaching(), garbage-collected when orphaned, and sanitized (strip_tags + lowercase).
This commit is contained in:
parent
96ae9ade23
commit
23d5b854e9
9 changed files with 1191 additions and 12 deletions
|
|
@ -33,6 +33,18 @@
|
|||
|
||||
class ApplicationsController extends Controller
|
||||
{
|
||||
use Concerns\HandlesTagsApi;
|
||||
|
||||
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
|
||||
{
|
||||
return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
|
||||
}
|
||||
|
||||
protected function tagResourceNotFoundMessage(): string
|
||||
{
|
||||
return 'Application not found.';
|
||||
}
|
||||
|
||||
private function removeSensitiveData($application)
|
||||
{
|
||||
$application->makeHidden([
|
||||
|
|
@ -230,6 +242,7 @@ public function applications(Request $request)
|
|||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
|
|
@ -395,6 +408,7 @@ public function create_public_application(Request $request)
|
|||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
|
|
@ -560,6 +574,7 @@ public function create_private_gh_app_application(Request $request)
|
|||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
|
|
@ -697,6 +712,7 @@ public function create_private_deploy_key_application(Request $request)
|
|||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
|
|
@ -831,6 +847,7 @@ public function create_dockerfile_application(Request $request)
|
|||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'],
|
||||
],
|
||||
)
|
||||
),
|
||||
|
|
@ -1006,7 +1023,7 @@ private function create_application(Request $request, $type)
|
|||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'tags'];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255',
|
||||
|
|
@ -1020,6 +1037,8 @@ private function create_application(Request $request, $type)
|
|||
'http_basic_auth_username' => 'string|nullable',
|
||||
'http_basic_auth_password' => 'string|nullable',
|
||||
'autogenerate_domain' => 'boolean',
|
||||
'tags' => 'array|nullable',
|
||||
'tags.*' => 'string|min:2',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
|
|
@ -1277,6 +1296,9 @@ private function create_application(Request $request, $type)
|
|||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->save();
|
||||
}
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($application, $request->tags, $teamId);
|
||||
}
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
|
|
@ -1503,6 +1525,9 @@ private function create_application(Request $request, $type)
|
|||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->save();
|
||||
}
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($application, $request->tags, $teamId);
|
||||
}
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
|
|
@ -1699,6 +1724,9 @@ private function create_application(Request $request, $type)
|
|||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->save();
|
||||
}
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($application, $request->tags, $teamId);
|
||||
}
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
|
|
@ -1810,6 +1838,9 @@ private function create_application(Request $request, $type)
|
|||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->save();
|
||||
}
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($application, $request->tags, $teamId);
|
||||
}
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
|
|
@ -1920,6 +1951,9 @@ private function create_application(Request $request, $type)
|
|||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->save();
|
||||
}
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($application, $request->tags, $teamId);
|
||||
}
|
||||
$application->isConfigurationChanged(true);
|
||||
|
||||
if ($instantDeploy) {
|
||||
|
|
@ -1943,7 +1977,7 @@ private function create_application(Request $request, $type)
|
|||
'domains' => data_get($application, 'fqdn'),
|
||||
]))->setStatusCode(201);
|
||||
} elseif ($type === 'dockercompose') {
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override', 'is_container_label_escape_enabled'];
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override', 'is_container_label_escape_enabled', 'tags'];
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
|
|
@ -2017,6 +2051,10 @@ private function create_application(Request $request, $type)
|
|||
// Apply service-specific application prerequisites
|
||||
applyServiceApplicationPrerequisites($service);
|
||||
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($service, $request->tags, $teamId);
|
||||
}
|
||||
|
||||
if ($instantDeploy) {
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
|
|
@ -4454,4 +4492,148 @@ public function delete_storage(Request $request): JsonResponse
|
|||
|
||||
return response()->json(['message' => 'Storage deleted.']);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Tags',
|
||||
description: 'List tags for an application by UUID.',
|
||||
path: '/applications/{uuid}/tags',
|
||||
operationId: 'list-tags-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of tags.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function tags(Request $request): JsonResponse
|
||||
{
|
||||
return $this->listTags($request);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Tag',
|
||||
description: 'Add tag(s) to an application by UUID.',
|
||||
path: '/applications/{uuid}/tags',
|
||||
operationId: 'create-tag-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'],
|
||||
'tag_names' => [
|
||||
'type' => 'array',
|
||||
'items' => new OA\Items(type: 'string'),
|
||||
'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.',
|
||||
],
|
||||
],
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Tags added successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function create_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->createTag($request);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Tag',
|
||||
description: 'Remove a tag from an application by UUID.',
|
||||
path: '/applications/{uuid}/tags/{tag_uuid}',
|
||||
operationId: 'delete-tag-by-application-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the application.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'tag_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the tag.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Tag removed.',
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function delete_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->deleteTag($request);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
142
app/Http/Controllers/Api/Concerns/HandlesTagsApi.php
Normal file
142
app/Http/Controllers/Api/Concerns/HandlesTagsApi.php
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Concerns;
|
||||
|
||||
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
|
||||
{
|
||||
/**
|
||||
* Find the taggable resource by UUID within the team.
|
||||
*/
|
||||
abstract protected function findTaggableResource(string $uuid, int|string $teamId): mixed;
|
||||
|
||||
/**
|
||||
* Get the 404 message for the taggable resource.
|
||||
*/
|
||||
abstract protected function tagResourceNotFoundMessage(): string;
|
||||
|
||||
public function listTags(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
return response()->json($resource->tags->map(TagsController::serializeTag(...)));
|
||||
}
|
||||
|
||||
public function createTag(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
if ($request->has('tag_name') && $request->has('tag_names')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['tag_name' => ['Provide either tag_name or tag_names, not both.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'tag_name' => 'required_without:tag_names|string|min:2',
|
||||
'tag_names' => 'required_without:tag_name|array|min:1',
|
||||
'tag_names.*' => 'string|min:2',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), ['tag_name', 'tag_names']);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extraFields)) {
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tagNames = $request->has('tag_names') ? $request->tag_names : [$request->tag_name];
|
||||
|
||||
$this->attachTagsToResource($resource, $tagNames, $teamId);
|
||||
|
||||
return response()->json($resource->refresh()->tags->map(TagsController::serializeTag(...)))->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function deleteTag(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$tag = Tag::where('team_id', $teamId)->where('uuid', $request->route('tag_uuid'))->first();
|
||||
if (! $tag) {
|
||||
return response()->json(['message' => 'Tag not found.'], 404);
|
||||
}
|
||||
|
||||
$resource->tags()->detach($tag->id);
|
||||
|
||||
if (DB::table('taggables')->where('tag_id', $tag->id)->count() === 0) {
|
||||
$tag->delete();
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Tag removed.']);
|
||||
}
|
||||
|
||||
protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void
|
||||
{
|
||||
foreach ($tagNames as $tagName) {
|
||||
$tagName = strtolower(strip_tags($tagName));
|
||||
if (strlen($tagName) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tag = Tag::where('team_id', $teamId)->where('name', $tagName)->first();
|
||||
if (! $tag) {
|
||||
$tag = Tag::create([
|
||||
'name' => $tagName,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
}
|
||||
|
||||
$resource->tags()->syncWithoutDetaching([$tag->id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,18 @@
|
|||
|
||||
class DatabasesController extends Controller
|
||||
{
|
||||
use Concerns\HandlesTagsApi;
|
||||
|
||||
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
|
||||
{
|
||||
return queryDatabaseByUuidWithinTeam($uuid, $teamId);
|
||||
}
|
||||
|
||||
protected function tagResourceNotFoundMessage(): string
|
||||
{
|
||||
return 'Database not found.';
|
||||
}
|
||||
|
||||
private function removeSensitiveData($database)
|
||||
{
|
||||
$database->makeHidden([
|
||||
|
|
@ -1079,6 +1091,7 @@ public function update_backup(Request $request)
|
|||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -1147,6 +1160,7 @@ public function create_database_postgresql(Request $request)
|
|||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -1214,6 +1228,7 @@ public function create_database_clickhouse(Request $request)
|
|||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -1282,6 +1297,7 @@ public function create_database_dragonfly(Request $request)
|
|||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -1350,6 +1366,7 @@ public function create_database_redis(Request $request)
|
|||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -1421,6 +1438,7 @@ public function create_database_keydb(Request $request)
|
|||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -1492,6 +1510,7 @@ public function create_database_mariadb(Request $request)
|
|||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -1560,6 +1579,7 @@ public function create_database_mysql(Request $request)
|
|||
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
|
||||
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
|
||||
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -1689,6 +1709,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
'limits_cpuset' => 'string|nullable',
|
||||
'limits_cpu_shares' => 'numeric',
|
||||
'instant_deploy' => 'boolean',
|
||||
'tags' => 'array|nullable',
|
||||
'tags.*' => 'string|min:2',
|
||||
]);
|
||||
if ($validator->failed()) {
|
||||
return response()->json([
|
||||
|
|
@ -1707,7 +1729,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
}
|
||||
}
|
||||
if ($type === NewDatabaseTypes::POSTGRESQL) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'postgres_user' => 'string',
|
||||
'postgres_password' => 'string',
|
||||
|
|
@ -1752,6 +1774,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
$request->offsetSet('postgres_conf', $postgresConf);
|
||||
}
|
||||
$database = create_standalone_postgresql($environment->id, $destination->uuid, $request->all());
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($database, $request->tags, $teamId);
|
||||
}
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
|
|
@ -1766,7 +1791,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::MARIADB) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'clickhouse_admin_user' => 'string',
|
||||
'clickhouse_admin_password' => 'string',
|
||||
|
|
@ -1807,6 +1832,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
$request->offsetSet('mariadb_conf', $mariadbConf);
|
||||
}
|
||||
$database = create_standalone_mariadb($environment->id, $destination->uuid, $request->all());
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($database, $request->tags, $teamId);
|
||||
}
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
|
|
@ -1822,7 +1850,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::MYSQL) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'mysql_root_password' => 'string',
|
||||
'mysql_password' => 'string',
|
||||
|
|
@ -1866,6 +1894,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
$request->offsetSet('mysql_conf', $mysqlConf);
|
||||
}
|
||||
$database = create_standalone_mysql($environment->id, $destination->uuid, $request->all());
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($database, $request->tags, $teamId);
|
||||
}
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
|
|
@ -1881,7 +1912,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::REDIS) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'redis_password' => 'string',
|
||||
'redis_conf' => 'string',
|
||||
|
|
@ -1922,6 +1953,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
$request->offsetSet('redis_conf', $redisConf);
|
||||
}
|
||||
$database = create_standalone_redis($environment->id, $destination->uuid, $request->all());
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($database, $request->tags, $teamId);
|
||||
}
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
|
|
@ -1937,7 +1971,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::DRAGONFLY) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'dragonfly_password' => 'string',
|
||||
]);
|
||||
|
|
@ -1959,6 +1993,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
|
||||
removeUnnecessaryFieldsFromRequest($request);
|
||||
$database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->all());
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($database, $request->tags, $teamId);
|
||||
}
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
|
|
@ -1967,7 +2004,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
'uuid' => $database->uuid,
|
||||
]))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::KEYDB) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'keydb_password' => 'string',
|
||||
'keydb_conf' => 'string',
|
||||
|
|
@ -2008,6 +2045,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
$request->offsetSet('keydb_conf', $keydbConf);
|
||||
}
|
||||
$database = create_standalone_keydb($environment->id, $destination->uuid, $request->all());
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($database, $request->tags, $teamId);
|
||||
}
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
|
|
@ -2023,7 +2063,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'clickhouse_admin_user' => 'string',
|
||||
'clickhouse_admin_password' => 'string',
|
||||
|
|
@ -2044,6 +2084,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
}
|
||||
removeUnnecessaryFieldsFromRequest($request);
|
||||
$database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->all());
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($database, $request->tags, $teamId);
|
||||
}
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
|
|
@ -2059,7 +2102,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
} elseif ($type === NewDatabaseTypes::MONGODB) {
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
|
||||
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'tags'];
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'mongo_conf' => 'string',
|
||||
'mongo_initdb_root_username' => 'string',
|
||||
|
|
@ -2102,6 +2145,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
$request->offsetSet('mongo_conf', $mongoConf);
|
||||
}
|
||||
$database = create_standalone_mongodb($environment->id, $destination->uuid, $request->all());
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($database, $request->tags, $teamId);
|
||||
}
|
||||
if ($instantDeploy) {
|
||||
StartDatabase::dispatch($database);
|
||||
}
|
||||
|
|
@ -3856,4 +3902,148 @@ public function delete_storage(Request $request): JsonResponse
|
|||
|
||||
return response()->json(['message' => 'Storage deleted.']);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Tags',
|
||||
description: 'List tags for a database by UUID.',
|
||||
path: '/databases/{uuid}/tags',
|
||||
operationId: 'list-tags-by-database-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the database.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of tags.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function tags(Request $request): JsonResponse
|
||||
{
|
||||
return $this->listTags($request);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Tag',
|
||||
description: 'Add tag(s) to a database by UUID.',
|
||||
path: '/databases/{uuid}/tags',
|
||||
operationId: 'create-tag-by-database-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the database.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'],
|
||||
'tag_names' => [
|
||||
'type' => 'array',
|
||||
'items' => new OA\Items(type: 'string'),
|
||||
'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.',
|
||||
],
|
||||
],
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Tags added successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function create_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->createTag($request);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Tag',
|
||||
description: 'Remove a tag from a database by UUID.',
|
||||
path: '/databases/{uuid}/tags/{tag_uuid}',
|
||||
operationId: 'delete-tag-by-database-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the database.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'tag_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the tag.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Tag removed.',
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function delete_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->deleteTag($request);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,18 @@
|
|||
|
||||
class ServicesController extends Controller
|
||||
{
|
||||
use Concerns\HandlesTagsApi;
|
||||
|
||||
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
|
||||
{
|
||||
return Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($uuid)->first();
|
||||
}
|
||||
|
||||
protected function tagResourceNotFoundMessage(): string
|
||||
{
|
||||
return 'Service not found.';
|
||||
}
|
||||
|
||||
private function removeSensitiveData($service)
|
||||
{
|
||||
$service->makeHidden([
|
||||
|
|
@ -227,6 +239,7 @@ public function services(Request $request)
|
|||
],
|
||||
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
|
||||
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the service.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -293,7 +306,7 @@ public function services(Request $request)
|
|||
)]
|
||||
public function create_service(Request $request)
|
||||
{
|
||||
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
|
||||
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
|
|
@ -323,6 +336,8 @@ public function create_service(Request $request)
|
|||
'urls.*.url' => 'string|nullable',
|
||||
'force_domain_override' => 'boolean',
|
||||
'is_container_label_escape_enabled' => 'boolean',
|
||||
'tags' => 'array|nullable',
|
||||
'tags.*' => 'string|min:2',
|
||||
];
|
||||
$validationMessages = [
|
||||
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
|
||||
|
|
@ -482,6 +497,10 @@ public function create_service(Request $request)
|
|||
}
|
||||
}
|
||||
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($service, $request->tags, $teamId);
|
||||
}
|
||||
|
||||
if ($instantDeploy) {
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
|
|
@ -494,7 +513,7 @@ public function create_service(Request $request)
|
|||
|
||||
return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
|
||||
} elseif (filled($request->docker_compose_raw)) {
|
||||
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
|
||||
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags'];
|
||||
|
||||
$validationRules = [
|
||||
'project_uuid' => 'string|required',
|
||||
|
|
@ -646,6 +665,10 @@ public function create_service(Request $request)
|
|||
}
|
||||
}
|
||||
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($service, $request->tags, $teamId);
|
||||
}
|
||||
|
||||
if ($instantDeploy) {
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
|
|
@ -2458,4 +2481,148 @@ public function delete_storage(Request $request): JsonResponse
|
|||
|
||||
return response()->json(['message' => 'Storage deleted.']);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Tags',
|
||||
description: 'List tags for a service by UUID.',
|
||||
path: '/services/{uuid}/tags',
|
||||
operationId: 'list-tags-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Services'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the service.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of tags.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function tags(Request $request): JsonResponse
|
||||
{
|
||||
return $this->listTags($request);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Tag',
|
||||
description: 'Add tag(s) to a service by UUID.',
|
||||
path: '/services/{uuid}/tags',
|
||||
operationId: 'create-tag-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Services'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the service.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'],
|
||||
'tag_names' => [
|
||||
'type' => 'array',
|
||||
'items' => new OA\Items(type: 'string'),
|
||||
'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.',
|
||||
],
|
||||
],
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Tags added successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function create_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->createTag($request);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Tag',
|
||||
description: 'Remove a tag from a service by UUID.',
|
||||
path: '/services/{uuid}/tags/{tag_uuid}',
|
||||
operationId: 'delete-tag-by-service-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Services'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the service.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'tag_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the tag.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Tag removed.',
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function delete_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->deleteTag($request);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
app/Http/Controllers/Api/TagsController.php
Normal file
61
app/Http/Controllers/Api/TagsController.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class TagsController extends Controller
|
||||
{
|
||||
public static function serializeTag(Tag $tag): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $tag->uuid,
|
||||
'name' => $tag->name,
|
||||
'created_at' => $tag->created_at,
|
||||
'updated_at' => $tag->updated_at,
|
||||
];
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List',
|
||||
description: 'List all tags for the current team.',
|
||||
path: '/tags',
|
||||
operationId: 'list-tags',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Tags'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'All tags for the current team.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
]
|
||||
)]
|
||||
public function tags(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$tags = Tag::where('team_id', $teamId)->orderBy('name')->get();
|
||||
|
||||
return response()->json($tags->map(self::serializeTag(...)));
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,18 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
description: 'Tag model',
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'uuid', type: 'string'),
|
||||
new OA\Property(property: 'name', type: 'string'),
|
||||
new OA\Property(property: 'created_at', type: 'string'),
|
||||
new OA\Property(property: 'updated_at', type: 'string'),
|
||||
]
|
||||
)]
|
||||
class Tag extends BaseModel
|
||||
{
|
||||
use HasSafeStringAttribute;
|
||||
|
|
|
|||
|
|
@ -194,4 +194,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
|
|||
$request->offsetUnset('autogenerate_domain');
|
||||
$request->offsetUnset('is_container_label_escape_enabled');
|
||||
$request->offsetUnset('docker_compose_raw');
|
||||
$request->offsetUnset('tags');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
use App\Http\Controllers\Api\SecurityController;
|
||||
use App\Http\Controllers\Api\ServersController;
|
||||
use App\Http\Controllers\Api\ServicesController;
|
||||
use App\Http\Controllers\Api\TagsController;
|
||||
use App\Http\Controllers\Api\TeamController;
|
||||
use App\Http\Middleware\ApiAllowed;
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
|
|
@ -98,6 +99,8 @@
|
|||
|
||||
Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']);
|
||||
|
||||
Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']);
|
||||
|
||||
Route::get('/applications', [ApplicationsController::class, 'applications'])->middleware(['api.ability:read']);
|
||||
Route::post('/applications/public', [ApplicationsController::class, 'create_public_application'])->middleware(['api.ability:write']);
|
||||
Route::post('/applications/private-github-app', [ApplicationsController::class, 'create_private_gh_app_application'])->middleware(['api.ability:write']);
|
||||
|
|
@ -125,6 +128,10 @@
|
|||
Route::patch('/applications/{uuid}/storages', [ApplicationsController::class, 'update_storage'])->middleware(['api.ability:write']);
|
||||
Route::delete('/applications/{uuid}/storages/{storage_uuid}', [ApplicationsController::class, 'delete_storage'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/applications/{uuid}/tags', [ApplicationsController::class, 'tags'])->middleware(['api.ability:read']);
|
||||
Route::post('/applications/{uuid}/tags', [ApplicationsController::class, 'create_tag'])->middleware(['api.ability:write']);
|
||||
Route::delete('/applications/{uuid}/tags/{tag_uuid}', [ApplicationsController::class, 'delete_tag'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
|
||||
|
|
@ -167,6 +174,10 @@
|
|||
Route::patch('/databases/{uuid}/envs', [DatabasesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/databases/{uuid}/envs/{env_uuid}', [DatabasesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/databases/{uuid}/tags', [DatabasesController::class, 'tags'])->middleware(['api.ability:read']);
|
||||
Route::post('/databases/{uuid}/tags', [DatabasesController::class, 'create_tag'])->middleware(['api.ability:write']);
|
||||
Route::delete('/databases/{uuid}/tags/{tag_uuid}', [DatabasesController::class, 'delete_tag'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
|
||||
|
|
@ -189,6 +200,10 @@
|
|||
Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/services/{uuid}/tags', [ServicesController::class, 'tags'])->middleware(['api.ability:read']);
|
||||
Route::post('/services/{uuid}/tags', [ServicesController::class, 'create_tag'])->middleware(['api.ability:write']);
|
||||
Route::delete('/services/{uuid}/tags/{tag_uuid}', [ServicesController::class, 'delete_tag'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
|
||||
|
|
|
|||
410
tests/Feature/TagApiTest.php
Normal file
410
tests/Feature/TagApiTest.php
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
});
|
||||
|
||||
function tagApiAuthHeaders($bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
describe('GET /api/v1/tags', function () {
|
||||
test('returns all tags for current team', function () {
|
||||
Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
Tag::create(['name' => 'staging', 'team_id' => $this->team->id]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/tags');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(2);
|
||||
$response->assertJsonFragment(['name' => 'production']);
|
||||
$response->assertJsonFragment(['name' => 'staging']);
|
||||
});
|
||||
|
||||
test('returns empty array when no tags exist', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/tags');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(0);
|
||||
});
|
||||
|
||||
test('does not return tags from other teams', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
Tag::create(['name' => 'other-team-tag', 'team_id' => $otherTeam->id]);
|
||||
Tag::create(['name' => 'my-tag', 'team_id' => $this->team->id]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/tags');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'my-tag']);
|
||||
$response->assertJsonMissing(['name' => 'other-team-tag']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/applications/{uuid}/tags', function () {
|
||||
test('returns tags for an application', function () {
|
||||
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$this->application->uuid}/tags");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'production']);
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent application', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/applications/non-existent-uuid/tags');
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('returns empty array when application has no tags', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$this->application->uuid}/tags");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/{uuid}/tags', function () {
|
||||
test('adds a single tag via tag_name', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'production']);
|
||||
|
||||
expect($this->application->tags()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('adds multiple tags via tag_names array', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_names' => ['production', 'frontend'],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonCount(2);
|
||||
|
||||
expect($this->application->tags()->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('reuses existing team tag instead of creating duplicate', function () {
|
||||
Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
expect(Tag::where('team_id', $this->team->id)->where('name', 'production')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('rejects tag_name shorter than 2 characters', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => 'x',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('rejects both tag_name and tag_names provided simultaneously', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => 'production',
|
||||
'tag_names' => ['staging'],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonFragment(['tag_name' => ['Provide either tag_name or tag_names, not both.']]);
|
||||
});
|
||||
|
||||
test('skips duplicate tag already on resource', function () {
|
||||
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
expect($this->application->tags()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent application', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/applications/non-existent-uuid/tags', [
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/applications/{uuid}/tags/{tag_uuid}', function () {
|
||||
test('removes tag from application', function () {
|
||||
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
expect($this->application->tags()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('garbage-collects orphaned tag', function () {
|
||||
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
|
||||
$this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
expect(Tag::find($tag->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('keeps tag if still used by other resources', function () {
|
||||
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
|
||||
$otherApp = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$otherApp->tags()->attach($tag->id);
|
||||
|
||||
$this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
expect(Tag::find($tag->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent tag', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/non-existent-uuid");
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/databases/{uuid}/tags', function () {
|
||||
test('returns tags for a database', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'test-pg',
|
||||
'postgres_password' => 'testpassword',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$tag = Tag::create(['name' => 'database-tag', 'team_id' => $this->team->id]);
|
||||
$database->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/databases/{$database->uuid}/tags");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'database-tag']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/databases/{uuid}/tags', function () {
|
||||
test('adds tag to database', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'test-pg',
|
||||
'postgres_password' => 'testpassword',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/databases/{$database->uuid}/tags", [
|
||||
'tag_name' => 'database-tag',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
expect($database->tags()->count())->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/databases/{uuid}/tags/{tag_uuid}', function () {
|
||||
test('removes tag from database', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'test-pg',
|
||||
'postgres_password' => 'testpassword',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$tag = Tag::create(['name' => 'database-tag', 'team_id' => $this->team->id]);
|
||||
$database->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/databases/{$database->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
expect($database->tags()->count())->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/services/{uuid}/tags', function () {
|
||||
test('returns tags for a service', function () {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$tag = Tag::create(['name' => 'service-tag', 'team_id' => $this->team->id]);
|
||||
$service->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/services/{$service->uuid}/tags");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'service-tag']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/services/{uuid}/tags', function () {
|
||||
test('adds tag to service', function () {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/services/{$service->uuid}/tags", [
|
||||
'tag_name' => 'service-tag',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
expect($service->tags()->count())->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/services/{uuid}/tags/{tag_uuid}', function () {
|
||||
test('removes tag from service', function () {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$tag = Tag::create(['name' => 'service-tag', 'team_id' => $this->team->id]);
|
||||
$service->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/services/{$service->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
expect($service->tags()->count())->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tag name sanitization', function () {
|
||||
test('strips HTML tags from tag names', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => '<script>alert("xss")</script>production',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonFragment(['name' => 'alert("xss")production']);
|
||||
$response->assertJsonMissing(['name' => '<script>']);
|
||||
});
|
||||
|
||||
test('lowercases tag names', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => 'Production',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonFragment(['name' => 'production']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cross-resource tag isolation', function () {
|
||||
test('cannot delete tag via wrong resource', function () {
|
||||
$tag = Tag::create(['name' => 'shared-tag', 'team_id' => $this->team->id]);
|
||||
|
||||
$otherApp = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$otherApp->tags()->attach($tag->id);
|
||||
|
||||
// Tag is not attached to $this->application, but we try to delete via it
|
||||
$this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
// 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
|
||||
expect(Tag::find($tag->id))->not->toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue