From 764d8861f6ef0755d07b5c9c22ada2ae63993954 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Sun, 11 Jan 2026 22:19:09 +0100 Subject: [PATCH] feat(api): add update urls support to services api - added update urls support to services api - remove old stale domains update code --- .../Controllers/Api/ServicesController.php | 192 ++++++++++++++---- openapi.json | 34 ++++ openapi.yaml | 8 + 3 files changed, 195 insertions(+), 39 deletions(-) diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 4025898b9..56812c94c 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -12,6 +12,7 @@ use App\Models\Server; use App\Models\Service; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; use Symfony\Component\Yaml\Yaml; @@ -37,6 +38,67 @@ private function removeSensitiveData($service) return serializeApiResponse($service); } + private function applyServiceUrls(Service $service, array $urls, string $teamId): ?array + { + $errors = []; + + foreach ($urls as $item) { + $name = data_get($item, 'name'); + $urls = data_get($item, 'url'); + + if (blank($name)) { + $errors[] = 'Service container name is required to apply URLs.'; + + continue; + } + + $application = $service->applications()->where('name', $name)->first(); + if (! $application) { + $errors[] = "Service container with '{$name}' not found."; + + continue; + } + + if (filled($urls)) { + $urls = str($urls)->replaceStart(',', '')->replaceEnd(',', '')->trim(); + $urls = str($urls)->explode(',')->map(function ($url) use (&$errors) { + $url = trim($url); + if (! filter_var($url, FILTER_VALIDATE_URL)) { + $errors[] = 'Invalid URL: '.$url; + + return str($url)->lower(); + } + $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; + if (! in_array(strtolower($scheme), ['http', 'https'])) { + $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; + } + + return str($url)->lower(); + })->filter(fn ($u) => $u->isNotEmpty())->unique()->implode(','); + + if ($urls && empty($errors)) { + $result = checkIfDomainIsAlreadyUsedViaAPI(collect(explode(',', $urls)), $teamId, $application->uuid); + if ($result['hasConflicts']) { + foreach ($result['conflicts'] as $conflict) { + $errors[] = $conflict['message']; + } + } + } + } else { + $urls = null; + } + + $application->fqdn = $urls; + $application->save(); + } + + if (! empty($errors)) { + return ['errors' => $errors]; + } + + return null; + } + #[OA\Get( summary: 'List', description: 'List all services.', @@ -115,6 +177,17 @@ public function services(Request $request) 'destination_uuid' => ['type' => 'string', 'description' => 'Destination UUID. Required if server has multiple destinations.'], 'instant_deploy' => ['type' => 'boolean', 'default' => false, 'description' => 'Start the service immediately after creation.'], 'docker_compose_raw' => ['type' => 'string', 'description' => 'The base64 encoded Docker Compose content.'], + 'urls' => [ + 'type' => 'array', + 'description' => 'Array of URLs to be applied to containers of a service.', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], + 'url' => ['type' => 'string', 'description' => 'Comma-separated list of domains (e.g. "http://app.coolify.io,https://app2.coolify.io").'], + ], + ), + ], ], ), ), @@ -152,7 +225,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']; + $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -165,7 +238,7 @@ public function create_service(Request $request) if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $validator = customApiValidator($request->all(), [ + $validationRules = [ 'type' => 'string|required_without:docker_compose_raw', 'docker_compose_raw' => 'string|required_without:type', 'project_uuid' => 'string|required', @@ -176,7 +249,15 @@ public function create_service(Request $request) 'name' => 'string|max:255', 'description' => 'string|nullable', 'instant_deploy' => 'boolean', - ]); + 'urls' => 'array|nullable', + 'urls.*' => 'array:name,url', + 'urls.*.name' => 'string|required', + 'urls.*.url' => 'string|nullable', + ]; + $validationMessages = [ + 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', + ]; + $validator = Validator::make($request->all(), $validationRules, $validationMessages); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -296,29 +377,31 @@ public function create_service(Request $request) // Apply service-specific application prerequisites applyServiceApplicationPrerequisites($service); + if ($request->has('urls') && is_array($request->urls)) { + $urlResult = $this->applyServiceUrls($service, $request->urls, $teamId); + if ($urlResult !== null) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $urlResult['errors'], + ], 422); + } + } + if ($instantDeploy) { StartService::dispatch($service); } - $domains = $service->applications()->get()->pluck('fqdn')->sort(); - $domains = $domains->map(function ($domain) { - if (count(explode(':', $domain)) > 2) { - return str($domain)->beforeLast(':')->value(); - } - - return $domain; - }); return response()->json([ 'uuid' => $service->uuid, - 'domains' => $domains, - ]); + 'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(), + ])->setStatusCode(201); } 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']; + $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls']; - $validator = customApiValidator($request->all(), [ + $validationRules = [ 'project_uuid' => 'string|required', 'environment_name' => 'string|nullable', 'environment_uuid' => 'string|nullable', @@ -329,7 +412,15 @@ public function create_service(Request $request) 'instant_deploy' => 'boolean', 'connect_to_docker_network' => 'boolean', 'docker_compose_raw' => 'string|required', - ]); + 'urls' => 'array|nullable', + 'urls.*' => 'array:name,url', + 'urls.*.name' => 'string|required', + 'urls.*.url' => 'string|nullable', + ]; + $validationMessages = [ + 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', + ]; + $validator = Validator::make($request->all(), $validationRules, $validationMessages); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -423,22 +514,24 @@ public function create_service(Request $request) $service->save(); $service->parse(isNew: true); + + if ($request->has('urls') && is_array($request->urls)) { + $urlResult = $this->applyServiceUrls($service, $request->urls, $teamId); + if ($urlResult !== null) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $urlResult['errors'], + ], 422); + } + } + if ($instantDeploy) { StartService::dispatch($service); } - $domains = $service->applications()->get()->pluck('fqdn')->sort(); - $domains = $domains->map(function ($domain) { - if (count(explode(':', $domain)) > 2) { - return str($domain)->beforeLast(':')->value(); - } - - return $domain; - })->values(); - return response()->json([ 'uuid' => $service->uuid, - 'domains' => $domains, + 'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(), ])->setStatusCode(201); } elseif (filled($request->type)) { return response()->json([ @@ -622,6 +715,17 @@ public function delete_by_uuid(Request $request) 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the service should be deployed instantly.'], 'connect_to_docker_network' => ['type' => 'boolean', 'default' => false, 'description' => 'Connect the service to the predefined docker network.'], 'docker_compose_raw' => ['type' => 'string', 'description' => 'The base64 encoded Docker Compose content.'], + 'urls' => [ + 'type' => 'array', + 'description' => 'Array of URLs to be applied to containers of a service.', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], + 'url' => ['type' => 'string', 'description' => 'Comma-separated list of domains (e.g. "http://app.coolify.io,https://app2.coolify.io").'], + ], + ), + ], ], ) ), @@ -681,15 +785,23 @@ public function update_by_uuid(Request $request) $this->authorize('update', $service); - $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network']; + $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls']; - $validator = customApiValidator($request->all(), [ + $validationRules = [ 'name' => 'string|max:255', 'description' => 'string|nullable', 'instant_deploy' => 'boolean', 'connect_to_docker_network' => 'boolean', 'docker_compose_raw' => 'string|nullable', - ]); + 'urls' => 'array|nullable', + 'urls.*' => 'array:name,url', + 'urls.*.name' => 'string|required', + 'urls.*.url' => 'string|nullable', + ]; + $validationMessages = [ + 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', + ]; + $validator = Validator::make($request->all(), $validationRules, $validationMessages); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -753,22 +865,24 @@ public function update_by_uuid(Request $request) $service->save(); $service->parse(); + + if ($request->has('urls') && is_array($request->urls)) { + $urlResult = $this->applyServiceUrls($service, $request->urls, $teamId); + if ($urlResult !== null) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $urlResult['errors'], + ], 422); + } + } + if ($request->instant_deploy) { StartService::dispatch($service); } - $domains = $service->applications()->get()->pluck('fqdn')->sort(); - $domains = $domains->map(function ($domain) { - if (count(explode(':', $domain)) > 2) { - return str($domain)->beforeLast(':')->value(); - } - - return $domain; - })->values(); - return response()->json([ 'uuid' => $service->uuid, - 'domains' => $domains, + 'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(), ])->setStatusCode(200); } diff --git a/openapi.json b/openapi.json index 9fdec634f..e9207e5b9 100644 --- a/openapi.json +++ b/openapi.json @@ -8887,6 +8887,23 @@ "docker_compose_raw": { "type": "string", "description": "The base64 encoded Docker Compose content." + }, + "urls": { + "type": "array", + "description": "Array of URLs to be applied to containers of a service.", + "items": { + "properties": { + "name": { + "type": "string", + "description": "The service name as defined in docker-compose." + }, + "url": { + "type": "string", + "description": "Comma-separated list of domains (e.g. \"http:\/\/app.coolify.io,https:\/\/app2.coolify.io\")." + } + }, + "type": "object" + } } }, "type": "object" @@ -9137,6 +9154,23 @@ "docker_compose_raw": { "type": "string", "description": "The base64 encoded Docker Compose content." + }, + "urls": { + "type": "array", + "description": "Array of URLs to be applied to containers of a service.", + "items": { + "properties": { + "name": { + "type": "string", + "description": "The service name as defined in docker-compose." + }, + "url": { + "type": "string", + "description": "Comma-separated list of domains (e.g. \"http:\/\/app.coolify.io,https:\/\/app2.coolify.io\")." + } + }, + "type": "object" + } } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index 383481a8d..735ebad21 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5606,6 +5606,10 @@ paths: docker_compose_raw: type: string description: 'The base64 encoded Docker Compose content.' + urls: + type: array + description: 'Array of URLs to be applied to containers of a service.' + items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, url: { type: string, description: 'Comma-separated list of domains (e.g. "http://app.coolify.io,https://app2.coolify.io").' } }, type: object } type: object responses: '201': @@ -5773,6 +5777,10 @@ paths: docker_compose_raw: type: string description: 'The base64 encoded Docker Compose content.' + urls: + type: array + description: 'Array of URLs to be applied to containers of a service.' + items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, url: { type: string, description: 'Comma-separated list of domains (e.g. "http://app.coolify.io,https://app2.coolify.io").' } }, type: object } type: object responses: '200':