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 1/5] 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': From 0628268875bd463fdb2ec01779dd2b14347291f6 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 13 Jan 2026 19:25:58 +0100 Subject: [PATCH 2/5] feat(api): improve service urls update - add force_domain_override functionality and docs - delete service on creation if there is URL conflicts as otherwise we will have stale services (we need to create the service because we need to parse it and more) --- .../Controllers/Api/ServicesController.php | 172 ++++++++++++++---- openapi.json | 118 ++++++++++++ openapi.yaml | 28 +++ 3 files changed, 287 insertions(+), 31 deletions(-) diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 56812c94c..09547eb1e 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -38,13 +38,14 @@ private function removeSensitiveData($service) return serializeApiResponse($service); } - private function applyServiceUrls(Service $service, array $urls, string $teamId): ?array + private function applyServiceUrls(Service $service, array $urls, string $teamId, bool $forceDomainOverride = false): ?array { $errors = []; + $conflicts = []; - foreach ($urls as $item) { - $name = data_get($item, 'name'); - $urls = data_get($item, 'url'); + foreach ($urls as $url) { + $name = data_get($url, 'name'); + $urls = data_get($url, 'url'); if (blank($name)) { $errors[] = 'Service container name is required to apply URLs.'; @@ -66,7 +67,7 @@ private function applyServiceUrls(Service $service, array $urls, string $teamId) if (! filter_var($url, FILTER_VALIDATE_URL)) { $errors[] = 'Invalid URL: '.$url; - return str($url)->lower(); + return $url; } $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; if (! in_array(strtolower($scheme), ['http', 'https'])) { @@ -74,16 +75,26 @@ private function applyServiceUrls(Service $service, array $urls, string $teamId) } 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']; - } - } + if (count($errors) > 0) { + continue; } + + $result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $application->uuid); + if (isset($result['error'])) { + $errors[] = $result['error']; + + continue; + } + + if ($result['hasConflicts'] && ! $forceDomainOverride) { + $conflicts = array_merge($conflicts, $result['conflicts']); + + continue; + } + + $urls = $urls->filter(fn ($u) => filled($u))->unique()->implode(','); } else { $urls = null; } @@ -96,6 +107,13 @@ private function applyServiceUrls(Service $service, array $urls, string $teamId) return ['errors' => $errors]; } + if (! empty($conflicts)) { + return [ + 'conflicts' => $conflicts, + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ]; + } + return null; } @@ -188,6 +206,7 @@ public function services(Request $request) ], ), ], + 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], ], ), ), @@ -217,6 +236,35 @@ public function services(Request $request) response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 409, + description: 'Domain conflicts detected.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'], + 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'], + 'conflicts' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'domain' => ['type' => 'string', 'example' => 'example.com'], + 'resource_name' => ['type' => 'string', 'example' => 'My Application'], + 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'], + 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'], + 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''], + ] + ), + ], + ] + ) + ), + ] + ), new OA\Response( response: 422, ref: '#/components/responses/422', @@ -225,7 +273,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']; + $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -253,6 +301,7 @@ public function create_service(Request $request) 'urls.*' => 'array:name,url', 'urls.*.name' => 'string|required', 'urls.*.url' => 'string|nullable', + 'force_domain_override' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -378,12 +427,22 @@ public function create_service(Request $request) applyServiceApplicationPrerequisites($service); if ($request->has('urls') && is_array($request->urls)) { - $urlResult = $this->applyServiceUrls($service, $request->urls, $teamId); + $urlResult = $this->applyServiceUrls($service, $request->urls, $teamId, $request->boolean('force_domain_override')); if ($urlResult !== null) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => $urlResult['errors'], - ], 422); + $service->delete(); + if (isset($urlResult['errors'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $urlResult['errors'], + ], 422); + } + if (isset($urlResult['conflicts'])) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $urlResult['conflicts'], + 'warning' => $urlResult['warning'], + ], 409); + } } } @@ -399,7 +458,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']; + $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']; $validationRules = [ 'project_uuid' => 'string|required', @@ -416,6 +475,7 @@ public function create_service(Request $request) 'urls.*' => 'array:name,url', 'urls.*.name' => 'string|required', 'urls.*.url' => 'string|nullable', + 'force_domain_override' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -516,12 +576,22 @@ public function create_service(Request $request) $service->parse(isNew: true); if ($request->has('urls') && is_array($request->urls)) { - $urlResult = $this->applyServiceUrls($service, $request->urls, $teamId); + $urlResult = $this->applyServiceUrls($service, $request->urls, $teamId, $request->boolean('force_domain_override')); if ($urlResult !== null) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => $urlResult['errors'], - ], 422); + $service->delete(); + if (isset($urlResult['errors'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $urlResult['errors'], + ], 422); + } + if (isset($urlResult['conflicts'])) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $urlResult['conflicts'], + 'warning' => $urlResult['warning'], + ], 409); + } } } @@ -726,6 +796,7 @@ public function delete_by_uuid(Request $request) ], ), ], + 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], ], ) ), @@ -760,6 +831,35 @@ public function delete_by_uuid(Request $request) response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 409, + description: 'Domain conflicts detected.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'], + 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'], + 'conflicts' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'domain' => ['type' => 'string', 'example' => 'example.com'], + 'resource_name' => ['type' => 'string', 'example' => 'My Application'], + 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'], + 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'], + 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''], + ] + ), + ], + ] + ) + ), + ] + ), new OA\Response( response: 422, ref: '#/components/responses/422', @@ -785,7 +885,7 @@ public function update_by_uuid(Request $request) $this->authorize('update', $service); - $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls']; + $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override']; $validationRules = [ 'name' => 'string|max:255', @@ -797,6 +897,7 @@ public function update_by_uuid(Request $request) 'urls.*' => 'array:name,url', 'urls.*.name' => 'string|required', 'urls.*.url' => 'string|nullable', + 'force_domain_override' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -867,12 +968,21 @@ public function update_by_uuid(Request $request) $service->parse(); if ($request->has('urls') && is_array($request->urls)) { - $urlResult = $this->applyServiceUrls($service, $request->urls, $teamId); + $urlResult = $this->applyServiceUrls($service, $request->urls, $teamId, $request->boolean('force_domain_override')); if ($urlResult !== null) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => $urlResult['errors'], - ], 422); + if (isset($urlResult['errors'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $urlResult['errors'], + ], 422); + } + if (isset($urlResult['conflicts'])) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $urlResult['conflicts'], + 'warning' => $urlResult['warning'], + ], 409); + } } } diff --git a/openapi.json b/openapi.json index e9207e5b9..46a5b4bc3 100644 --- a/openapi.json +++ b/openapi.json @@ -8904,6 +8904,11 @@ }, "type": "object" } + }, + "force_domain_override": { + "type": "boolean", + "default": false, + "description": "Force domain override even if conflicts are detected." } }, "type": "object" @@ -8941,6 +8946,60 @@ "400": { "$ref": "#\/components\/responses\/400" }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, "422": { "$ref": "#\/components\/responses\/422" } @@ -9171,6 +9230,11 @@ }, "type": "object" } + }, + "force_domain_override": { + "type": "boolean", + "default": false, + "description": "Force domain override even if conflicts are detected." } }, "type": "object" @@ -9211,6 +9275,60 @@ "404": { "$ref": "#\/components\/responses\/404" }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, "422": { "$ref": "#\/components\/responses\/422" } diff --git a/openapi.yaml b/openapi.yaml index 735ebad21..ae2999610 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5610,6 +5610,10 @@ paths: 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 } + force_domain_override: + type: boolean + default: false + description: 'Force domain override even if conflicts are detected.' type: object responses: '201': @@ -5625,6 +5629,16 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '409': + description: 'Domain conflicts detected.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' } + warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' } + conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } } + type: object '422': $ref: '#/components/responses/422' security: @@ -5781,6 +5795,10 @@ paths: 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 } + force_domain_override: + type: boolean + default: false + description: 'Force domain override even if conflicts are detected.' type: object responses: '200': @@ -5798,6 +5816,16 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '409': + description: 'Domain conflicts detected.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' } + warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' } + conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } } + type: object '422': $ref: '#/components/responses/422' security: From c5196e12d209838a742b20ce630b15ee35801107 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:04:44 +0100 Subject: [PATCH 3/5] fix(api): show an error if the same 2 urls are provided --- .../Controllers/Api/ServicesController.php | 81 +++++++++++-------- 1 file changed, 48 insertions(+), 33 deletions(-) diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 09547eb1e..ddd63d60c 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -38,70 +38,85 @@ private function removeSensitiveData($service) return serializeApiResponse($service); } - private function applyServiceUrls(Service $service, array $urls, string $teamId, bool $forceDomainOverride = false): ?array + private function applyServiceUrls(Service $service, array $urlsArray, string $teamId, bool $forceDomainOverride = false): ?array { $errors = []; $conflicts = []; - foreach ($urls as $url) { - $name = data_get($url, 'name'); - $urls = data_get($url, 'url'); + $urls = collect($urlsArray)->flatMap(function ($item) { + $urlValue = data_get($item, 'url'); + if (blank($urlValue)) { + return []; + } + + return str($urlValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter(); + }); + + $urls = $urls->map(function ($url) use (&$errors) { + if (! filter_var($url, FILTER_VALIDATE_URL)) { + $errors[] = "Invalid URL: {$url}"; + + return $url; + } + $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 $url; + }); + + $duplicates = $urls->duplicates()->unique()->values(); + if ($duplicates->isNotEmpty() && ! $forceDomainOverride) { + $errors[] = 'The current request contains conflicting URLs across containers: '.implode(', ', $duplicates->toArray()); + } + + if (count($errors) > 0) { + return ['errors' => $errors]; + } + + collect($urlsArray)->each(function ($item) use ($service, $teamId, $forceDomainOverride, &$errors, &$conflicts) { + $name = data_get($item, 'name'); + $containerUrls = data_get($item, 'url'); if (blank($name)) { $errors[] = 'Service container name is required to apply URLs.'; - continue; + return; } $application = $service->applications()->where('name', $name)->first(); if (! $application) { $errors[] = "Service container with '{$name}' not found."; - continue; + return; } - 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; + if (filled($containerUrls)) { + $containerUrls = str($containerUrls)->replaceStart(',', '')->replaceEnd(',', '')->trim(); + $containerUrls = str($containerUrls)->explode(',')->map(fn ($url) => str(trim($url))->lower()); - return $url; - } - $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(); - }); - - if (count($errors) > 0) { - continue; - } - - $result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $application->uuid); + $result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $application->uuid); if (isset($result['error'])) { $errors[] = $result['error']; - continue; + return; } if ($result['hasConflicts'] && ! $forceDomainOverride) { $conflicts = array_merge($conflicts, $result['conflicts']); - continue; + return; } - $urls = $urls->filter(fn ($u) => filled($u))->unique()->implode(','); + $containerUrls = $containerUrls->filter(fn ($u) => filled($u))->unique()->implode(','); } else { - $urls = null; + $containerUrls = null; } - $application->fqdn = $urls; + $application->fqdn = $containerUrls; $application->save(); - } + }); if (! empty($errors)) { return ['errors' => $errors]; From 33d3f196cc87618e5545d0f55059edf9d17c0ef3 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:42:35 +0100 Subject: [PATCH 4/5] chore(api): improve current request error message --- app/Http/Controllers/Api/ServicesController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index ddd63d60c..cfe96edbe 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -68,7 +68,7 @@ private function applyServiceUrls(Service $service, array $urlsArray, string $te $duplicates = $urls->duplicates()->unique()->values(); if ($duplicates->isNotEmpty() && ! $forceDomainOverride) { - $errors[] = 'The current request contains conflicting URLs across containers: '.implode(', ', $duplicates->toArray()); + $errors[] = 'The current request contains conflicting URLs across containers: '.implode(', ', $duplicates->toArray()).'. Use force_domain_override=true to proceed.'; } if (count($errors) > 0) { From ae9d0ec817ba354a862fd92a56a20bdf8f0b2967 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:50:48 +0100 Subject: [PATCH 5/5] docs(api): change domains to urls --- app/Http/Controllers/Api/ServicesController.php | 4 ++-- openapi.json | 4 ++-- openapi.yaml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index cfe96edbe..a93a4c6d3 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -217,7 +217,7 @@ public function services(Request $request) 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").'], + 'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").'], ], ), ], @@ -807,7 +807,7 @@ public function delete_by_uuid(Request $request) 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").'], + 'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").'], ], ), ], diff --git a/openapi.json b/openapi.json index 46a5b4bc3..a94ef79b1 100644 --- a/openapi.json +++ b/openapi.json @@ -8899,7 +8899,7 @@ }, "url": { "type": "string", - "description": "Comma-separated list of domains (e.g. \"http:\/\/app.coolify.io,https:\/\/app2.coolify.io\")." + "description": "Comma-separated list of URLs (e.g. \"http:\/\/app.coolify.io,https:\/\/app2.coolify.io\")." } }, "type": "object" @@ -9225,7 +9225,7 @@ }, "url": { "type": "string", - "description": "Comma-separated list of domains (e.g. \"http:\/\/app.coolify.io,https:\/\/app2.coolify.io\")." + "description": "Comma-separated list of URLs (e.g. \"http:\/\/app.coolify.io,https:\/\/app2.coolify.io\")." } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index ae2999610..75ccb69fe 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5609,7 +5609,7 @@ paths: 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 } + items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, url: { type: string, description: 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").' } }, type: object } force_domain_override: type: boolean default: false @@ -5794,7 +5794,7 @@ paths: 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 } + items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, url: { type: string, description: 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").' } }, type: object } force_domain_override: type: boolean default: false