eat(api): add service-applications API to manage service applications

This commit is contained in:
Bakr committed 2026-03-29 06:03:47 +03:00
1 parent 72118d61f9
commit 8ad65a0ef8
9 files changed
+1298 -2

No files matched your search

@@ -0,0 +1,58 @@
<?php
namespace App\Actions\Service;
use App\Models\ServiceApplication;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Contracts\Activity;
class DeployServiceApplication
{
use AsAction;
public string $jobQueue = 'high';
public function handle(ServiceApplication $serviceApplication, bool $pullLatestImages = false, bool $forceRebuild = false): Activity
{
$service = $serviceApplication->service;
$composeServiceName = $serviceApplication->name;
$service->parse();
$service->saveComposeConfigs();
$service->isConfigurationChanged(save: true);
$workdir = $service->workdir();
$commands = collect([
"echo 'Saved configuration files to {$workdir}.'",
"touch {$workdir}/.env",
]);
if ($pullLatestImages) {
$commands->push('echo Pulling image for service.');
$commands->push("docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} pull {$composeServiceName}");
}
if ($service->networks()->count() > 0) {
$commands->push('echo Creating Docker network.');
$commands->push("docker network inspect {$service->uuid} >/dev/null 2>&1 || docker network create --attachable {$service->uuid}");
}
$upCommand = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} up -d --no-deps";
if ($forceRebuild) {
$upCommand .= ' --build';
}
$upCommand .= " {$composeServiceName}";
$commands->push('echo Starting service container.');
$commands->push($upCommand);
$commands->push("docker network connect {$service->uuid} coolify-proxy >/dev/null 2>&1 || true");
if (data_get($service, 'connect_to_docker_network')) {
$compose = data_get($service, 'docker_compose', []);
$network = $service->destination->network;
$commands->push("docker network connect --alias {$composeServiceName}-{$service->uuid} {$network} {$composeServiceName}-{$service->uuid} >/dev/null 2>&1 || true");
}
return remote_process($commands->toArray(), $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Actions\Service;
use App\Models\ServiceApplication;
use Lorisleiva\Actions\Concerns\AsAction;
class RestartServiceApplication
{
use AsAction;
public string $jobQueue = 'high';
public function handle(ServiceApplication $serviceApplication): void
{
$service = $serviceApplication->service;
$server = $service->destination->server;
$containerName = $serviceApplication->name.'-'.$service->uuid;
instant_remote_process([
"docker restart {$containerName}",
], $server);
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Actions\Service;
use App\Models\ServiceApplication;
use Lorisleiva\Actions\Concerns\AsAction;
class StopServiceApplication
{
use AsAction;
public string $jobQueue = 'high';
public function handle(ServiceApplication $serviceApplication): void
{
$service = $serviceApplication->service;
$server = $service->destination->server;
$containerName = $serviceApplication->name.'-'.$service->uuid;
instant_remote_process([
"docker stop {$containerName}",
], $server);
}
}
@@ -0,0 +1,107 @@
<?php
namespace App\Actions\Service;
use App\Models\ServiceApplication;
use App\Support\ServiceComposeUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UpdateServiceApplicationFromApi
{
public function execute(ServiceApplication $serviceApplication, Request $request, string $teamId): ?JsonResponse
{
$forceDomainOverride = $request->boolean('force_domain_override');
if ($request->has('url')) {
$urlRaw = $request->input('url');
if ($urlRaw !== null && ! is_string($urlRaw)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['url' => 'The url must be a string.'],
], 422);
}
$parsed = ServiceComposeUrl::validateUrlString(
is_string($urlRaw) ? $urlRaw : null,
$forceDomainOverride
);
if (count($parsed['errors']) > 0) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $parsed['errors'],
], 422);
}
if ($parsed['normalized'] !== null) {
$containerUrls = str($parsed['normalized'])
->explode(',')
->map(fn ($url) => str(trim((string) $url))->lower());
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $serviceApplication->uuid);
if (isset($result['error'])) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [$result['error']],
], 422);
}
if ($result['hasConflicts'] && ! $forceDomainOverride) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => $result['conflicts'],
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
], 409);
}
}
$serviceApplication->fqdn = $parsed['normalized'];
}
if ($request->has('human_name')) {
$serviceApplication->human_name = $request->input('human_name');
}
if ($request->has('description')) {
$serviceApplication->description = $request->input('description');
}
if ($request->has('image')) {
$serviceApplication->image = $request->input('image');
}
if ($request->has('exclude_from_status')) {
$serviceApplication->exclude_from_status = $request->boolean('exclude_from_status');
}
if ($request->has('is_gzip_enabled')) {
$serviceApplication->is_gzip_enabled = $request->boolean('is_gzip_enabled');
}
if ($request->has('is_stripprefix_enabled')) {
$serviceApplication->is_stripprefix_enabled = $request->boolean('is_stripprefix_enabled');
}
if ($request->has('is_log_drain_enabled')) {
$enabled = $request->boolean('is_log_drain_enabled');
$server = $serviceApplication->service->destination->server;
if ($enabled && ! $server->isLogDrainEnabled()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'is_log_drain_enabled' => 'Log drain is not enabled on the server for this service.',
],
], 422);
}
$serviceApplication->is_log_drain_enabled = $enabled;
}
$serviceApplication->save();
$serviceApplication->refresh();
updateCompose($serviceApplication);
return null;
}
}
@@ -0,0 +1,754 @@
<?php
namespace App\Http\Controllers\Api;
use App\Actions\Service\DeployServiceApplication;
use App\Actions\Service\RestartServiceApplication;
use App\Actions\Service\StopServiceApplication;
use App\Actions\Service\UpdateServiceApplicationFromApi;
use App\Http\Controllers\Controller;
use App\Models\Service;
use App\Models\ServiceApplication;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
class ServiceApplicationsController extends Controller
{
private function removeSensitiveData(ServiceApplication $serviceApplication): array
{
$serviceApplication->makeHidden([
'id',
'resourceable',
'resourceable_id',
'resourceable_type',
]);
return serializeApiResponse($serviceApplication);
}
private function resolveService(Request $request, int $teamId): ?Service
{
$uuid = $request->route('uuid');
if (! $uuid) {
return null;
}
return Service::whereRelation('environment.project.team', 'id', $teamId)
->whereUuid($uuid)
->first();
}
private function resolveServiceApplicationForService(Request $request, Service $service): ?ServiceApplication
{
$appUuid = $request->route('app_uuid');
if (! $appUuid) {
return null;
}
return $service->applications()
->where('uuid', $appUuid)
->with(['service.destination.server'])
->first();
}
private function swarmNotSupportedResponse(): JsonResponse
{
return response()->json([
'message' => 'This operation is not supported for Swarm servers yet.',
], 501);
}
#[OA\Get(
summary: 'List service applications',
description: 'List compose service applications (containers) for a single service.',
path: '/services/{uuid}/applications',
operationId: 'list-service-applications-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Service applications for this service.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(type: 'object')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function index(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('view', $service);
$items = $service->applications()
->get()
->map(fn (ServiceApplication $sa) => $this->removeSensitiveData($sa));
return response()->json($items);
}
#[OA\Get(
summary: 'Get service application',
description: 'Get a single compose service application by service UUID and application UUID.',
path: '/services/{uuid}/applications/{app_uuid}',
operationId: 'get-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Service application.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(type: 'object')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function show(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('view', $serviceApplication);
return response()->json($this->removeSensitiveData($serviceApplication));
}
#[OA\Patch(
summary: 'Update service application',
description: 'Update fields for a compose service application. Use `url` for comma-separated public URLs (same rules as `urls[].url` on PATCH /services/{uuid}).',
path: '/services/{uuid}/applications/{app_uuid}',
operationId: 'patch-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'force_domain_override',
in: 'query',
description: 'When true, allow duplicate URLs in the request and proceed despite domain conflicts (same as service PATCH).',
required: false,
schema: new OA\Schema(type: 'boolean', default: false)
),
],
requestBody: new OA\RequestBody(
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'url' => new OA\Property(
property: 'url',
type: 'string',
nullable: true,
description: 'Comma-separated list of URLs (e.g. "http://app.example.com:8080,https://app2.example.com"). Stored as fqdn.'
),
'human_name' => new OA\Property(property: 'human_name', type: 'string', nullable: true),
'description' => new OA\Property(property: 'description', type: 'string', nullable: true),
'image' => new OA\Property(property: 'image', type: 'string', nullable: true),
'exclude_from_status' => new OA\Property(property: 'exclude_from_status', type: 'boolean', nullable: true),
'is_log_drain_enabled' => new OA\Property(property: 'is_log_drain_enabled', type: 'boolean', nullable: true),
'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true),
'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true),
]
)
)
),
responses: [
new OA\Response(
response: 200,
description: 'Updated service application.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(type: 'object')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 409,
description: 'Domain conflicts (unless force_domain_override).',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update(Request $request, UpdateServiceApplicationFromApi $updateServiceApplicationFromApi): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('update', $serviceApplication);
$allowedFields = [
'url',
'human_name',
'description',
'image',
'exclude_from_status',
'is_log_drain_enabled',
'is_gzip_enabled',
'is_stripprefix_enabled',
];
$validationRules = [
'url' => 'nullable|string',
'human_name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'image' => 'nullable|string',
'exclude_from_status' => 'sometimes|boolean',
'is_log_drain_enabled' => 'sometimes|boolean',
'is_gzip_enabled' => 'sometimes|boolean',
'is_stripprefix_enabled' => 'sometimes|boolean',
];
$validator = Validator::make($request->all(), $validationRules);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$response = $updateServiceApplicationFromApi->execute($serviceApplication, $request, $teamId);
if ($response instanceof JsonResponse) {
return $response;
}
$serviceApplication->refresh();
return response()->json($this->removeSensitiveData($serviceApplication));
}
#[OA\Get(
summary: 'Get service application logs',
description: 'Get Docker logs for a single compose service container.',
path: '/services/{uuid}/applications/{app_uuid}/logs',
operationId: 'get-service-application-logs-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'lines',
in: 'query',
description: 'Number of lines to show from the end of the logs.',
required: false,
schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Logs.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'logs' => new OA\Property(property: 'logs', type: 'string'),
]
)
),
]
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 501,
description: 'Swarm not supported.',
),
]
)]
public function logs_by_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('view', $serviceApplication);
$server = $serviceApplication->service->destination->server;
if ($server->isSwarm()) {
return $this->swarmNotSupportedResponse();
}
if (! $server->isFunctional()) {
return response()->json([
'message' => 'Server is not functional.',
], 400);
}
$containerName = $serviceApplication->name.'-'.$serviceApplication->service->uuid;
$status = getContainerStatus($server, $containerName);
if ($status !== 'running') {
return response()->json([
'message' => 'Service application container is not running.',
], 400);
}
$lines = (int) ($request->query('lines', 100) ?: 100);
$logs = getContainerLogs($server, $containerName, $lines);
return response()->json([
'logs' => $logs,
]);
}
#[OA\Get(
summary: 'Start or redeploy service application container',
description: 'Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.',
path: '/services/{uuid}/applications/{app_uuid}/start',
operationId: 'start-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'force',
in: 'query',
description: 'When true, passes --build to docker compose up.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false)
),
new OA\Parameter(
name: 'latest',
in: 'query',
description: 'When true, pulls the image for this compose service before up.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Deploy request queued.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => new OA\Property(property: 'message', type: 'string'),
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 501,
description: 'Swarm not supported.',
),
]
)]
public function action_start(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('deploy', $serviceApplication);
$server = $serviceApplication->service->destination->server;
if ($server->isSwarm()) {
return $this->swarmNotSupportedResponse();
}
if (! $server->isFunctional()) {
return response()->json([
'message' => 'Server is not functional.',
], 400);
}
$pullLatest = $request->boolean('latest', false);
$forceRebuild = $request->boolean('force', false);
DeployServiceApplication::dispatch($serviceApplication, $pullLatest, $forceRebuild);
return response()->json([
'message' => 'Service application deploy request queued.',
], 200);
}
#[OA\Get(
summary: 'Restart service application container',
description: 'Restarts a single compose service container (docker restart).',
path: '/services/{uuid}/applications/{app_uuid}/restart',
operationId: 'restart-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Restart queued.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => new OA\Property(property: 'message', type: 'string'),
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 501,
description: 'Swarm not supported.',
),
]
)]
public function action_restart(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('deploy', $serviceApplication);
$server = $serviceApplication->service->destination->server;
if ($server->isSwarm()) {
return $this->swarmNotSupportedResponse();
}
if (! $server->isFunctional()) {
return response()->json([
'message' => 'Server is not functional.',
], 400);
}
RestartServiceApplication::dispatch($serviceApplication);
return response()->json([
'message' => 'Service application restart request queued.',
], 200);
}
#[OA\Get(
summary: 'Stop service application container',
description: 'Stops a single compose service container (docker stop).',
path: '/services/{uuid}/applications/{app_uuid}/stop',
operationId: 'stop-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Stop queued.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => new OA\Property(property: 'message', type: 'string'),
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 501,
description: 'Swarm not supported.',
),
]
)]
public function action_stop(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('deploy', $serviceApplication);
$server = $serviceApplication->service->destination->server;
if ($server->isSwarm()) {
return $this->swarmNotSupportedResponse();
}
if (! $server->isFunctional()) {
return response()->json([
'message' => 'Server is not functional.',
], 400);
}
StopServiceApplication::dispatch($serviceApplication);
return response()->json([
'message' => 'Service application stop request queued.',
], 200);
}
}
+9 -2
View File
@@ -30,8 +30,15 @@ class ServiceApplicationPolicy
*/
public function update(User $user, ServiceApplication $serviceApplication): bool
{
// return Gate::allows('update', $serviceApplication->service);
return true;
return Gate::allows('update', $serviceApplication->service);
}
/**
* Determine whether the user can deploy or run lifecycle actions on the parent service stack.
*/
public function deploy(User $user, ServiceApplication $serviceApplication): bool
{
return Gate::allows('deploy', $serviceApplication->service);
}
/**
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Support;
class ServiceComposeUrl
{
/**
* Validate a comma-separated URL string like `urls[].url` on PATCH /services/{uuid}.
*
* @return array{errors: array<int, string>, normalized: ?string}
*/
public static function validateUrlString(?string $urlValue, bool $forceDomainOverride = false): array
{
$errors = [];
if ($urlValue === null || $urlValue === '') {
return ['errors' => [], 'normalized' => null];
}
$urls = str($urlValue)
->replaceStart(',', '')
->replaceEnd(',', '')
->trim()
->explode(',')
->map(fn ($url) => trim((string) $url))
->filter();
foreach ($urls as $url) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = "Invalid URL: {$url}";
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'], true)) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
}
$duplicates = $urls->duplicates()->unique()->values();
if ($duplicates->isNotEmpty() && ! $forceDomainOverride) {
$errors[] = 'The current request contains duplicate URLs: '.implode(', ', $duplicates->toArray()).'. Use force_domain_override=true to proceed.';
}
if (count($errors) > 0) {
return ['errors' => $errors, 'normalized' => null];
}
$normalized = $urls
->map(fn ($u) => str($u)->lower()->value())
->unique()
->filter(fn ($u) => filled($u))
->implode(',');
return ['errors' => [], 'normalized' => $normalized !== '' ? $normalized : null];
}
}