feat(api): add REST endpoints for destinations

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.
This commit is contained in:
Poul 2026-05-25 11:15:08 +00:00
parent 49656aa1ed
commit fe855cf8d0
2 changed files with 113 additions and 0 deletions

View file

@ -0,0 +1,105 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class DestinationsController extends Controller
{
private function transform($d): array
{
return [
'id' => $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.']);
}
}

View file

@ -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']);