From fe855cf8d0930d2b0e76994b2ed72634ff8240c8 Mon Sep 17 00:00:00 2001 From: Poul Date: Mon, 25 May 2026 11:15:08 +0000 Subject: [PATCH] feat(api): add REST endpoints for destinations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Destinations section is exposed in the Coolify UI but not via the REST API. The destination_uuid field is required when creating applications via create-public-application, but no way to enumerate or create destinations programmatically existed — this blocks IaC tools (e.g. an Aspire publisher targeting Coolify). Adds, scoped to the existing v1 auth:sanctum + ApiAllowed + api.sensitive group: GET /api/v1/destinations GET /api/v1/destinations/{uuid} DELETE /api/v1/destinations/{uuid} GET /api/v1/servers/{server_uuid}/destinations POST /api/v1/servers/{server_uuid}/destinations The controller uses the existing inline-Validator convention (no Form Request classes per the API surface's house style), reuses StandaloneDocker::ownedByCurrentTeamAPI / SwarmDocker::ownedByCurrentTeamAPI for team scoping (matching ScheduledTasksController etc.), and respects the `attachedTo()` guard on delete. No migrations needed — both standalone_dockers and swarm_dockers tables already carry uuid/name/network/server_id/timestamps. OpenAPI @OA\ annotations omitted in this commit to keep the diff minimal; a follow-up can add them in the style of ServersController. --- .../Api/DestinationsController.php | 105 ++++++++++++++++++ routes/api.php | 8 ++ 2 files changed, 113 insertions(+) create mode 100644 app/Http/Controllers/Api/DestinationsController.php diff --git a/app/Http/Controllers/Api/DestinationsController.php b/app/Http/Controllers/Api/DestinationsController.php new file mode 100644 index 000000000..43fb0cba8 --- /dev/null +++ b/app/Http/Controllers/Api/DestinationsController.php @@ -0,0 +1,105 @@ + $d->id, + 'uuid' => $d->uuid, + 'name' => $d->name, + 'network' => $d->network, + 'type' => $d instanceof SwarmDocker ? 'swarm' : 'standalone', + 'server_uuid' => $d->server?->uuid, + 'created_at' => $d->created_at, + 'updated_at' => $d->updated_at, + ]; + } + + public function index(Request $request) + { + $teamId = auth()->user()->currentTeam()->id; + $standalone = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->get(); + $swarm = SwarmDocker::ownedByCurrentTeamAPI($teamId)->get(); + + return response()->json($standalone->concat($swarm)->map(fn ($d) => $this->transform($d))->values()); + } + + public function index_by_server(Request $request, string $server_uuid) + { + $teamId = auth()->user()->currentTeam()->id; + $server = Server::ownedByCurrentTeamAPI($teamId)->whereUuid($server_uuid)->firstOrFail(); + $list = $server->standaloneDockers->concat($server->swarmDockers); + + return response()->json($list->map(fn ($d) => $this->transform($d))->values()); + } + + public function show(Request $request, string $uuid) + { + $teamId = auth()->user()->currentTeam()->id; + $d = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->whereUuid($uuid)->first() + ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->whereUuid($uuid)->firstOrFail(); + + return response()->json($this->transform($d)); + } + + public function create(Request $request, string $server_uuid) + { + $teamId = auth()->user()->currentTeam()->id; + $server = Server::ownedByCurrentTeamAPI($teamId)->whereUuid($server_uuid)->firstOrFail(); + + $allowed = ['name', 'network', 'type']; + $extra = array_diff(array_keys($request->all()), $allowed); + if (! empty($extra)) { + return response()->json(['message' => 'Unknown fields', 'fields' => array_values($extra)], 422); + } + + $validator = Validator::make($request->all(), [ + 'name' => 'nullable|string|max:255', + 'network' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'], + 'type' => 'nullable|in:standalone,swarm', + ]); + if ($validator->fails()) { + return response()->json(['message' => 'Validation failed', 'errors' => $validator->errors()], 422); + } + + $type = $request->input('type', 'standalone'); + $name = $request->input('name') ?: ($server->name.'-'.$request->input('network')); + $class = $type === 'swarm' ? SwarmDocker::class : StandaloneDocker::class; + + $exists = $class::where('server_id', $server->id)->where('network', $request->input('network'))->exists(); + if ($exists) { + return response()->json(['message' => 'A destination with this network already exists on the server.'], 409); + } + + $d = $class::create([ + 'name' => $name, + 'network' => $request->input('network'), + 'server_id' => $server->id, + ]); + + return response()->json(['uuid' => $d->uuid], 201); + } + + public function delete(Request $request, string $uuid) + { + $teamId = auth()->user()->currentTeam()->id; + $d = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->whereUuid($uuid)->first() + ?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->whereUuid($uuid)->firstOrFail(); + if ($d->attachedTo()) { + return response()->json(['message' => 'Destination has attached resources, detach first.'], 409); + } + $d->delete(); + + return response()->json(['message' => 'Deleted.']); + } +} diff --git a/routes/api.php b/routes/api.php index cc380b2be..cd98df9ec 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Api\CloudProviderTokensController; use App\Http\Controllers\Api\DatabasesController; use App\Http\Controllers\Api\DeployController; +use App\Http\Controllers\Api\DestinationsController; use App\Http\Controllers\Api\GithubController; use App\Http\Controllers\Api\HetznerController; use App\Http\Controllers\Api\OtherController; @@ -87,6 +88,13 @@ Route::get('/servers/{uuid}/domains', [ServersController::class, 'domains_by_server'])->middleware(['api.ability:read']); Route::get('/servers/{uuid}/resources', [ServersController::class, 'resources_by_server'])->middleware(['api.ability:read']); + // Destinations — REST surface for the Coolify "Destinations" UI section (added). + Route::get('/destinations', [DestinationsController::class, 'index'])->middleware(['api.ability:read']); + Route::get('/destinations/{uuid}', [DestinationsController::class, 'show'])->middleware(['api.ability:read']); + Route::delete('/destinations/{uuid}', [DestinationsController::class, 'delete'])->middleware(['api.ability:write']); + Route::get('/servers/{server_uuid}/destinations', [DestinationsController::class, 'index_by_server'])->middleware(['api.ability:read']); + Route::post('/servers/{server_uuid}/destinations', [DestinationsController::class, 'create'])->middleware(['api.ability:write']); + Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:write']); Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']);