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] 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: