feat: initial alpha release

This commit represents the complete foundational codebase for Nimbus Alpha, a Laravel package that provides an integrated, in-browser API client with automatic schema discovery from validation rules.

IMPORTANT: This is a squashed commit representing the culmination of extensive development, refactoring, and architectural iterations. All previous commit history has been intentionally removed to provide a clean foundation for the public alpha release.

The development of Nimbus involved:
- Multiple architectural refactorings
- Significant structural changes
- Experimental approaches that were later abandoned
- Learning iterations on the core concept
- Migration between different design patterns

This messy history would:
- Make git blame confusing and unhelpful
- Obscure the actual intent behind current implementation
- Create noise when reviewing changes
- Reference deleted or refactored code

If git blame brought you to this commit, it means you're looking at code that was part of the initial alpha release. Here's what to do:

1. Check Current Documentation
   - See `/wiki/contribution-guide/README.md` for architecture details
   - Review the specific module's README if available
   - Look for inline comments explaining the reasoning

2. Look for Related Code
   - Check other files in the same module
   - Look for tests that demonstrate intended behavior
   - Review interfaces and contracts

3. Context Matters
   - This code may have been updated since alpha
   - Check git log for subsequent changes to this file
   - Look for related issues or PRs on GitHub

---

This commit marks the beginning of Nimbus's public journey. All future
commits will build upon this foundation with clear, traceable history.

Thank you for using or contributing to Nimbus!
This commit is contained in:
Mazen Touati
2025-10-20 00:35:07 +02:00
commit c2aa6895d6
570 changed files with 54298 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
<?php
namespace Sunchayn\Nimbus\Commands\Intellisense;
use Carbon\CarbonImmutable;
use Illuminate\Support\Str;
use RuntimeException;
use Sunchayn\Nimbus\IntellisenseProviders;
use Sunchayn\Nimbus\IntellisenseProviders\Contracts\IntellisenseContract;
use Symfony\Component\Console\Command\Command as SymfonyCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
/**
* Generates TypeScript intellisense files for the BE centric features.
*/
class GenerateIntellisenseCommand extends SymfonyCommand
{
/** @var array<int, class-string<IntellisenseContract>> */
protected array $intellisenseProviders = [
IntellisenseProviders\AuthorizationTypeIntellisense::class,
IntellisenseProviders\RandomValueGeneratorIntellisense::class,
];
const TARGET_BASE_PATH = '/resources/js/interfaces/generated/';
/**
* Configures the command with its name, description, and options.
*/
protected function configure(): void
{
$this
->setName('generate-intellisense')
->setDescription('Generate TypeScript intellisense files from Laravel enums and configurations to resources/js/interfaces/generated/');
}
/**
* Executes the intellisense generation process.
*
* Processes each registered intellisense generator and creates
* the corresponding TypeScript files in the target directory.
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->printHeader($output);
$output->writeln(' >> <info>Generating TypeScript Intellisense...</info>');
foreach ($this->intellisenseProviders as $intellisenseProvider) {
/** @var IntellisenseContract $instance */
$instance = new $intellisenseProvider;
$output->writeln('
> Generating '.$instance->getTargetFileName());
try {
$content = $this->getFileHeader().$instance->generate();
$targetPath = $this->getTargetPath($instance->getTargetFileName());
$this->createDirectoryIfItDoesntExist(dirname($targetPath));
file_put_contents($targetPath, $content);
$output->writeln('<info>✓ Generated.</info>');
} catch (Throwable $throwable) {
$output->writeln('');
$output->writeln('<error>Failed to generate:</error>.');
$output->writeln($throwable->getMessage());
$output->writeln('');
return self::FAILURE;
}
}
$output->writeln("\n<info>✓ All Intellisense generated successfully!</info>");
return self::SUCCESS;
}
/**
* Constructs the target path for the generated file.
*
* Files are generated in the resources/js/interfaces/generated/ directory
* to maintain organization and avoid conflicts with other generated files.
*/
private function getTargetPath(string $filename): string
{
return getcwd().self::TARGET_BASE_PATH.$filename;
}
/**
* Ensures the target directory exists before writing files.
*/
private function createDirectoryIfItDoesntExist(string $directory): void
{
if (is_dir($directory)) {
return;
}
mkdir($directory, 0755, true);
}
private function getFileHeader(): string
{
return Str::replace(
'{{ date }}',
CarbonImmutable::now()->toIso8601String(),
file_get_contents(__DIR__.'/stubs/intellisense-header.stub') ?: throw new RuntimeException('Cannot load header stub.'),
);
}
/*
* UI Helpers.
*/
private function printHeader(OutputInterface $output): void
{
$output->write(<<<HEAD
_ _ _ _ ___ _ _ _ _ ____
| \ | (_)_ __ ___ | |__ _ _ ___ |_ _|_ __ | |_ ___| | (_) ___| ___ _ __ ___ ___
| \| | | '_ ` _ \| '_ \| | | / __| | || '_ \| __/ _ \ | | \___ \ / _ \ '_ \/ __|/ _ \
| |\ | | | | | | | |_) | |_| \__ \ | || | | | || __/ | | |___) | __/ | | \__ \ __/
|_| \_|_|_| |_| |_|_.__/ \__,_|___/ |___|_| |_|\__\___|_|_|_|____/ \___|_| |_|___/\___|
HEAD);
$output->writeln("\n");
}
}

View File

@@ -0,0 +1,8 @@
/*
* This file is auto-generated.
* Don't update it manually, otherwise, your changes will be lost.
* To update the file run `php bin/intellisense`.
*
* Generated at: {{ date }}.
*/

View File

@@ -0,0 +1,28 @@
<?php
namespace Sunchayn\Nimbus\Http\Api\Relay;
use Illuminate\Http\Resources\Json\JsonResource;
use Sunchayn\Nimbus\Modules\Relay\Actions\RequestRelayAction;
use Sunchayn\Nimbus\Modules\Relay\DataTransferObjects\RequestRelayData;
/**
* Relays the Execution of HTTP requests with authorization and returns structured response data.
*
* A relay endpoint is needed to access HTTP Only cookies,
* and deal with Laravel specific details for response/request life-cycle.
*/
class NimbusRelayController
{
public function __invoke(
NimbusRelayRequest $nimbusRelayRequest,
RequestRelayAction $requestRelayAction,
): JsonResource {
$relayedRequestResponseData = $requestRelayAction
->execute(
RequestRelayData::fromRelayApiRequest($nimbusRelayRequest),
);
return RelayResponseResource::make($relayedRequestResponseData);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Sunchayn\Nimbus\Http\Api\Relay;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Sunchayn\Nimbus\Modules\Relay\Authorization\AuthorizationTypeEnum;
class NimbusRelayRequest extends FormRequest
{
/**
* @return array<string, string|object|array<array-key, string|object>>
*/
public function rules(): array
{
return [
'method' => 'required',
'endpoint' => 'required',
'authorization' => 'sometimes|array',
'authorization.type' => ['required_with:authorization', Rule::in(AuthorizationTypeEnum::cases())],
'authorization.value' => 'sometimes',
'body' => 'sometimes',
'headers' => 'sometimes',
'headers.*.key' => 'required|string',
'headers.*.value' => 'required',
];
}
/**
* @return array<string, mixed>
*/
public function getBody(): array
{
$body = $this->validated('body') && filled($this->validated('body'))
? $this->validated('body')
: [];
return is_string($body)
? json_decode($body, true)
: $body;
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Sunchayn\Nimbus\Http\Api\Relay;
use Illuminate\Http\Resources\Json\JsonResource;
use Sunchayn\Nimbus\Modules\Relay\DataTransferObjects\RelayedRequestResponseData;
/**
* @property RelayedRequestResponseData $resource
*
* @mixin RelayedRequestResponseData
*/
class RelayResponseResource extends JsonResource
{
public static $wrap;
/**
* @return array<string, mixed>
*/
public function toArray($request): array
{
return [
'statusCode' => $this->resource->statusCode,
'statusText' => $this->resource->statusText,
'body' => $this->resource->body->toPrettyJSON(),
'headers' => $this->processHeaders($this->headers),
'cookies' => collect($this->resource->cookies)->map->toArray(),
'duration' => $this->resource->durationMs,
'timestamp' => $this->resource->timestamp,
];
}
/**
* Converts headers from backend format to frontend array format.
*
* @param string[][] $headers
* @return array<array{key: string, value: string}>
*/
private function processHeaders(array $headers): array
{
return collect($headers)
->flatMap(
// Convert each header value to a separate key-value pair
fn (array $values, string $key) => collect($values)
->map(fn (string $value): array => [
'key' => $key,
'value' => $value,
]),
)
->values()
->all();
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Sunchayn\Nimbus\Http\Web\Controllers;
use Illuminate\Contracts\Support\Renderable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Route as RouteFacade;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\Str;
use Sunchayn\Nimbus\Modules\Routes\Actions;
use Sunchayn\Nimbus\Modules\Routes\Exceptions\RouteExtractionException;
class NimbusIndexController
{
private const VIEW_NAME = 'nimbus::app';
public function __invoke(
Actions\ExtractRoutesAction $extractRoutesAction,
Actions\IgnoreRouteErrorAction $ignoreRouteErrorAction,
Actions\BuildGlobalHeadersAction $buildGlobalHeadersAction,
Actions\BuildCurrentUserAction $buildCurrentUserAction,
Actions\DisableThirdPartyUiAction $disableThirdPartyUiAction,
): Renderable|RedirectResponse {
$disableThirdPartyUiAction->execute();
Vite::useBuildDirectory('/vendor/nimbus');
Vite::useHotFile(base_path('/vendor/sunchayn/nimbus/resources/dist/hot'));
if (request()->has('ignore')) {
$ignoreRouteErrorAction->execute(
ignoreData: request()->get('ignore'),
);
return redirect()->to(request()->url());
}
try {
$routes = $extractRoutesAction
->execute(
routes: RouteFacade::getRoutes()->getRoutes(),
);
} catch (RouteExtractionException $routeExtractionException) {
return view(self::VIEW_NAME, [ // @phpstan-ignore-line it cannot find the view.
'routeExtractorException' => $this->renderExtractorException($routeExtractionException),
]);
}
return view(self::VIEW_NAME, [ // @phpstan-ignore-line it cannot find the view.
'routes' => $routes->toFrontendArray(),
'headers' => $buildGlobalHeadersAction->execute(),
'currentUser' => $buildCurrentUserAction->execute(),
]);
}
/**
* @return array<string, array<string, array<int|string>|string|null>|string|null>
*/
private function renderExtractorException(RouteExtractionException $routeExtractionException): array
{
return [
'exception' => [
'message' => $routeExtractionException->getMessage(),
'previous' => $routeExtractionException->getPrevious() instanceof \Throwable ? [
'message' => $routeExtractionException->getPrevious()->getMessage(),
'file' => $routeExtractionException->getPrevious()->getFile(),
'line' => $routeExtractionException->getPrevious()->getLine(),
'trace' => Str::replace("\n", '<br/>', $routeExtractionException->getPrevious()->getTraceAsString()),
] : null,
],
'routeContext' => $routeExtractionException->getRouteContext(),
'suggestedSolution' => $routeExtractionException->getSuggestedSolution(),
'ignoreData' => $routeExtractionException->getIgnoreData(),
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Sunchayn\Nimbus\IntellisenseProviders;
use Illuminate\Support\Str;
use RuntimeException;
use Sunchayn\Nimbus\IntellisenseProviders\Contracts\IntellisenseContract;
use Sunchayn\Nimbus\Modules\Relay\Authorization\AuthorizationTypeEnum;
/**
* Generates TypeScript types for authorization types from the backend enum.
*/
class AuthorizationTypeIntellisense implements IntellisenseContract
{
public const STUB = 'authorization-types.ts.stub';
public function getTargetFileName(): string
{
return Str::remove('.stub', self::STUB);
}
public function generate(): string
{
$enumCases = [];
foreach (AuthorizationTypeEnum::cases() as $case) {
$enumCases[] = sprintf(" %s = '%s',", $case->name, $case->value);
}
$enumContent = implode("\n", $enumCases);
return $this->replaceStubContent($enumContent);
}
private function replaceStubContent(string $enumList): string
{
$stubFile = file_get_contents(__DIR__.'/stubs/'.self::STUB) ?: throw new RuntimeException('Cannot read stub file.');
return str_replace('{{ content }}', rtrim($enumList), $stubFile);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Sunchayn\Nimbus\IntellisenseProviders\Contracts;
/**
* Contract for intellisense generators that create TypeScript types.
*
* Each intellisense generator is responsible for converting backend data structures
* into frontend TypeScript definitions for better type safety and developer experience.
*/
interface IntellisenseContract
{
/**
* Returns the target filename for the generated intellisense file.
*
* The filename should be descriptive and follow TypeScript naming conventions.
*/
public function getTargetFileName(): string;
/**
* Generates the intellisense content as TypeScript code.
*
* The generated content should be valid TypeScript that can be imported
* and used in frontend applications for type safety and autocompletion.
*/
public function generate(): string;
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Sunchayn\Nimbus\IntellisenseProviders;
use Illuminate\Support\Str;
use RuntimeException;
use Sunchayn\Nimbus\IntellisenseProviders\Contracts\IntellisenseContract;
use Sunchayn\Nimbus\Modules\Config\GlobalHeaderGeneratorTypeEnum;
/**
* Generates TypeScript types for random value generator types from the backend enum.
*/
class RandomValueGeneratorIntellisense implements IntellisenseContract
{
public const STUB = 'global-request-types.ts.stub';
public function getTargetFileName(): string
{
return Str::remove('.stub', self::STUB);
}
public function generate(): string
{
$enumCases = [];
foreach (GlobalHeaderGeneratorTypeEnum::cases() as $case) {
$enumCases[] = sprintf(" %s = '%s',", $case->name, $case->value);
}
$enumContent = implode("\n", $enumCases);
return $this->replaceStubContent($enumContent);
}
private function replaceStubContent(string $enumList): string
{
$stubFile = file_get_contents(__DIR__.'/stubs/'.self::STUB) ?: throw new RuntimeException('Cannot read stub file.');
return str_replace('{{ content }}', rtrim($enumList), $stubFile);
}
}

View File

@@ -0,0 +1,11 @@
export enum AuthorizationType {
{{ content }}
}
/**
* Individual authorization type item
*/
export type AuthorizationTypeItem = {
readonly id: AuthorizationType;
readonly label: string;
};

View File

@@ -0,0 +1,12 @@
export enum GeneratorType {
{{ content }}
}
/**
* Source global headers type for request configuration
*/
export type SourceGlobalHeaders = {
header: string;
type: 'raw' | 'generator';
value: GeneratorType | string | number;
};

View File

@@ -0,0 +1,42 @@
<?php
namespace Sunchayn\Nimbus\Modules\Config\Exceptions;
use Exception;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Contracts\SpecialAuthenticationInjectorContract;
class MisconfiguredValueException extends Exception
{
public const SPECIAL_AUTHENTICATION_INJECTOR = 1;
public const MISSING_DEPENDENCIES = 2;
public const INVALID_GUARD_INJECTOR_COMBINATION = 3;
public static function becauseSpecialAuthenticationInjectorIsInvalid(): self
{
return new self(
message: 'The config value for `nimbus.auth.special.injector` MUST be a class string of type <'.SpecialAuthenticationInjectorContract::class.'>',
code: self::SPECIAL_AUTHENTICATION_INJECTOR,
);
}
public static function becauseOfMissingDependency(string $dependency): self
{
return new self(
message: sprintf('The config value for `nimbus.auth.special.injector` is an injector that requires the following dependency <%s>', $dependency),
code: self::MISSING_DEPENDENCIES,
);
}
/**
* @param non-empty-string $suggestion
*/
public static function becauseOfInvalidGuardInjectorCombination(string $suggestion): self
{
return new self(
message: "The config value for `nimbus.auth.guard` doesn't work with the selected injector. ".$suggestion,
code: self::INVALID_GUARD_INJECTOR_COMBINATION,
);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Sunchayn\Nimbus\Modules\Config;
/**
* Defines available random value generation strategies for global headers..
*/
enum GlobalHeaderGeneratorTypeEnum: string
{
case Uuid = 'UUID';
case Email = 'Email';
case String = 'String';
}

View File

@@ -0,0 +1,146 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Actions;
use Carbon\CarbonImmutable;
use GuzzleHttp\Cookie\SetCookie;
use Illuminate\Container\Container;
use Illuminate\Cookie\CookieValuePrefix;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Response;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\AuthorizationHandlerFactory;
use Sunchayn\Nimbus\Modules\Relay\DataTransferObjects\RelayedRequestResponseData;
use Sunchayn\Nimbus\Modules\Relay\DataTransferObjects\RequestRelayData;
use Sunchayn\Nimbus\Modules\Relay\ValueObjects\PrintableResponseBody;
use Sunchayn\Nimbus\Modules\Relay\ValueObjects\ResponseCookieValueObject;
class RequestRelayAction
{
public const DEFAULT_CONTENT_TYPE = 'application/json';
public const MICROSECONDS_TO_MILLISECONDS = 1_000_000;
public const NON_STANDARD_STATUS_CODES = [
419 => 'Method Not Allowed',
];
public function __construct(
private readonly Container $container,
private readonly AuthorizationHandlerFactory $authorizationHandlerFactory,
) {}
public function execute(RequestRelayData $requestRelayData): RelayedRequestResponseData
{
$pendingRequest = $this->prepareRequest($requestRelayData);
$authorizationHandler = $this
->authorizationHandlerFactory
->create($requestRelayData->authorization);
$pendingRequest = $authorizationHandler->authorize($pendingRequest);
$start = hrtime(true);
$response = $pendingRequest->send(
method: $requestRelayData->method,
url: $requestRelayData->endpoint,
);
$durationInMs = $this->calculateDuration($start);
return new RelayedRequestResponseData(
statusCode: $response->getStatusCode(),
statusText: $this->getStatusTextFromCode($response->getStatusCode()),
body: PrintableResponseBody::fromResponse($response),
headers: $response->getHeaders(),
durationMs: $durationInMs,
timestamp: CarbonImmutable::now()->getTimestamp(),
cookies: $this->processCookies($response->getHeader('Set-Cookie')),
);
}
private function prepareRequest(RequestRelayData $requestRelayData): PendingRequest
{
$contentType = $requestRelayData->headers['content-type'] ?? self::DEFAULT_CONTENT_TYPE;
$headers = Arr::except(
$requestRelayData->headers,
[
// The Laravel HTTP client automatically sets a Content-Type header when sending requests.
// To prevent duplicate Content-Type headers, we explicitly remove any user-supplied value.
'content-type',
],
);
// SSL verification is disabled to support development environments with self-signed certificates.
return Http::withoutVerifying()
->withHeaders($headers)
->when(
in_array($requestRelayData->method, ['get', 'head']),
callback: fn (PendingRequest $pendingRequest) => $pendingRequest->withQueryParameters($requestRelayData->body),
default: fn (PendingRequest $pendingRequest) => $pendingRequest->withBody(
json_encode($requestRelayData->body) ?: throw new RuntimeException('Cannot parse body.'),
contentType: $contentType,
),
);
}
/**
* Calculates request duration in milliseconds with precision formatting.
*
* Uses high-resolution time for accurate microsecond measurements,
* formatted to 2 decimal places for readability.
*/
private function calculateDuration(int $startTime): float
{
return (float) number_format(
(hrtime(true) - $startTime) / self::MICROSECONDS_TO_MILLISECONDS,
2,
thousands_separator: ''
);
}
/**
* Processes Set-Cookie headers into structured cookie objects.
*
* Cookies are URL-decoded and prefixed with Laravel's encryption key
* to match the application's cookie handling behavior.
*
* @param string[] $setCookieHeaders
* @return ResponseCookieValueObject[]
*/
private function processCookies(array $setCookieHeaders): array
{
return Arr::map(
$setCookieHeaders,
function (string $cookieString): ResponseCookieValueObject {
$setCookie = SetCookie::fromString($cookieString);
return new ResponseCookieValueObject(
key: $setCookie->getName(),
rawValue: urldecode($setCookie->getValue() ?? ''),
prefix: CookieValuePrefix::create(
$setCookie->getName(),
$this->container->get('encrypter')->getKey()
),
);
},
);
}
/**
* Maps HTTP status codes to human-readable status text.
*
* Includes Laravel-specific status codes like 419 (Method Not Allowed)
* that aren't part of the standard HTTP specification.
*/
private function getStatusTextFromCode(int $statusCode): string
{
$statusCodeToTextMapping = Response::$statusTexts + self::NON_STANDARD_STATUS_CODES;
return $statusCodeToTextMapping[$statusCode] ?? 'Non-standard Status Code.';
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Authorization;
readonly class AuthorizationCredentials
{
/**
* @param string|array{username: string, password: string}|null $value
*/
public function __construct(
public AuthorizationTypeEnum $type,
public string|array|null $value,
) {}
public static function none(): self
{
return new self(
type: AuthorizationTypeEnum::None,
value: null,
);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Authorization;
/**
* Authorization types supported by the relay system.
*/
enum AuthorizationTypeEnum: string
{
case None = 'none';
case CurrentUser = 'current-user';
case Bearer = 'bearer';
case Basic = 'basic';
case Impersonate = 'impersonate';
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Authorization\Concerns;
use Illuminate\Container\Container;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Contracts\Container\BindingResolutionException;
use Sunchayn\Nimbus\Modules\Config\Exceptions\MisconfiguredValueException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Contracts\SpecialAuthenticationInjectorContract;
trait UsesSpecialAuthenticationInjector
{
/**
* @throws BindingResolutionException
* @throws MisconfiguredValueException
*/
public function getInjector(Container $container, Repository $configRepository): SpecialAuthenticationInjectorContract
{
/** @var ?class-string $injectorClass */
$injectorClass = $configRepository->get('nimbus.auth.special.injector');
if ($injectorClass === null) {
throw MisconfiguredValueException::becauseSpecialAuthenticationInjectorIsInvalid();
}
$injector = $container->make($injectorClass);
if (! $injector instanceof SpecialAuthenticationInjectorContract) {
throw MisconfiguredValueException::becauseSpecialAuthenticationInjectorIsInvalid();
}
return $injector;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Authorization\Contracts;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Client\PendingRequest;
/**
* Contract for injecting authentication context into relayed HTTP requests.
*
* Implementations of this interface define how an authenticated user should be
* attached to a relayed request. For example, this may involve setting a bearer token,
* attaching a session cookie, or adding custom authentication headers.
*/
interface SpecialAuthenticationInjectorContract
{
public function attach(
PendingRequest $pendingRequest,
Authenticatable $authenticatable
): PendingRequest;
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Authorization\Exceptions;
use RuntimeException;
class InvalidAuthorizationValueException extends RuntimeException
{
public const BEARER_TOKEN_IS_NOT_STRING = 1;
public const BASIC_AUTH_SHAPE_IS_INVALID = 2;
public const USER_IS_NOT_FOUND = 3;
public static function becauseBearerTokenValueIsNotString(): self
{
return new self(
message: 'Bearer token value is not a string.',
code: self::BEARER_TOKEN_IS_NOT_STRING,
);
}
public static function becauseBasicAuthCredentialsAreInvalid(): self
{
return new self(
message: 'Basic Auth credentials are invalid. Expects array{username: string, password: string}.',
code: self::BASIC_AUTH_SHAPE_IS_INVALID,
);
}
public static function becauseUserIsNotFound(): self
{
return new self(
message: "User ID didn't resolve to a user to impersonate.",
code: self::USER_IS_NOT_FOUND,
);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers;
use Illuminate\Http\Client\PendingRequest;
/**
* Contract for authorization handlers that manage their own authorization logic.
*/
interface AuthorizationHandler
{
/**
* Apply authorization to the pending request.
* Each handler manages its own authorization logic.
*/
public function authorize(PendingRequest $pendingRequest): PendingRequest;
}

View File

@@ -0,0 +1,81 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers;
use Sunchayn\Nimbus\Modules\Relay\Authorization\AuthorizationCredentials;
use Sunchayn\Nimbus\Modules\Relay\Authorization\AuthorizationTypeEnum;
/**
* Creates authorization handlers based on credential types.
*
* Uses factory pattern to instantiate appropriate handlers for different
* authorization methods (Bearer, Basic, Current User, etc.) with proper validation.
*
* @example
* Input: AuthorizationCredentials(type: 'bearer', value: 'token123')
* Output: BearerAuthorizationHandler instance
*/
class AuthorizationHandlerFactory
{
public function create(AuthorizationCredentials $authorizationCredentials): AuthorizationHandler
{
return match ($authorizationCredentials->type) {
AuthorizationTypeEnum::CurrentUser => $this->buildCurrentUserHandler(),
AuthorizationTypeEnum::Impersonate => $this->buildImpersonateHandler($authorizationCredentials->value),
AuthorizationTypeEnum::Bearer => $this->buildBearerHandler($authorizationCredentials->value),
AuthorizationTypeEnum::Basic => $this->buildBasicHandler($authorizationCredentials->value),
AuthorizationTypeEnum::None => $this->buildNoAuthorizationHandler(),
};
}
protected function buildCurrentUserHandler(): CurrentUserAuthorizationHandler
{
// Current user authorization requires access to the application's, so we use the IoT for that.
return resolve(CurrentUserAuthorizationHandler::class);
}
/**
* @param string|array{username: string, password: string}|null $rawValue
*/
protected function buildImpersonateHandler(string|array|null $rawValue): ImpersonateUserAuthorizationHandler
{
if ($rawValue === null) {
throw new \InvalidArgumentException('Impersonate user ID cannot be null.');
}
if (! ctype_digit($rawValue)) {
throw new \InvalidArgumentException('Impersonate user ID must be an integer.');
}
return resolve(ImpersonateUserAuthorizationHandler::class, ['userId' => (int) $rawValue]);
}
/**
* @param string|array{username: string, password: string}|null $rawValue
*/
protected function buildBearerHandler(string|array|null $rawValue): BearerAuthorizationHandler
{
if (! is_string($rawValue)) {
throw new \InvalidArgumentException('Bearer token must be a string');
}
return resolve(BearerAuthorizationHandler::class, ['token' => $rawValue]);
}
/**
* @param string|array{username: string, password: string}|null $rawValue
*/
protected function buildBasicHandler(string|array|null $rawValue): BasicAuthAuthorizationHandler
{
if (! is_array($rawValue)) {
throw new \InvalidArgumentException('Basic auth credentials must be an array');
}
return BasicAuthAuthorizationHandler::fromArray($rawValue);
}
protected function buildNoAuthorizationHandler(): NoAuthorizationHandler
{
return resolve(NoAuthorizationHandler::class);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers;
use Illuminate\Http\Client\PendingRequest;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Exceptions\InvalidAuthorizationValueException;
class BasicAuthAuthorizationHandler implements AuthorizationHandler
{
public function __construct(
public readonly string $username,
public readonly string $password,
) {
if (in_array(trim($username), ['', '0'], true) || in_array(trim($password), ['', '0'], true)) {
throw InvalidAuthorizationValueException::becauseBasicAuthCredentialsAreInvalid();
}
}
/**
* @param array{username?: string, password?: string} $credentials
*/
public static function fromArray(array $credentials): self
{
if (! array_key_exists('username', $credentials) || ! array_key_exists('password', $credentials)) {
throw InvalidAuthorizationValueException::becauseBasicAuthCredentialsAreInvalid();
}
return new self($credentials['username'], $credentials['password']);
}
public function authorize(PendingRequest $pendingRequest): PendingRequest
{
return $pendingRequest->withBasicAuth($this->username, $this->password);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers;
use Illuminate\Http\Client\PendingRequest;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Exceptions\InvalidAuthorizationValueException;
class BearerAuthorizationHandler implements AuthorizationHandler
{
public function __construct(
public readonly string $token,
) {
if (in_array(trim($token), ['', '0'], true)) {
throw InvalidAuthorizationValueException::becauseBearerTokenValueIsNotString();
}
}
public function authorize(PendingRequest $pendingRequest): PendingRequest
{
return $pendingRequest->withToken($this->token);
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers;
use Illuminate\Config\Repository as ConfigRepository;
use Illuminate\Container\Container;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Request;
use Illuminate\Session\SessionManager;
use Illuminate\Session\Store;
use Illuminate\Support\Arr;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Concerns\UsesSpecialAuthenticationInjector;
/**
* Authorization handler that forwards the current user's session cookies.
*
* This handler ensures the outbound request is authenticated as the current
* Laravel user. It supports two modes:
* - Directly from the current authenticated user on the relay request.
* - Fallback to session cookie resolution when no user is bound to the request.
*/
class CurrentUserAuthorizationHandler implements AuthorizationHandler
{
use UsesSpecialAuthenticationInjector;
private readonly UserProvider $userProvider;
public function __construct(
private readonly Request $relayRequest,
private readonly Container $container,
private readonly ConfigRepository $configRepository,
) {
$this->userProvider = $this->resolveUserProvider();
}
public function authorize(PendingRequest $pendingRequest): PendingRequest
{
$user = $this->getAuthenticatedUser();
if (! $user instanceof \Illuminate\Contracts\Auth\Authenticatable) {
return $pendingRequest;
}
return $this
->getInjector($this->container, $this->configRepository)
->attach($pendingRequest, $user);
}
/**
* Resolve the active user provider for the configured authentication guard.
*/
private function resolveUserProvider(): UserProvider
{
$authManager = $this->container->get('auth');
$guardName = $this->configRepository->get('nimbus.auth.guard');
return $authManager->guard($guardName)->getProvider();
}
/**
* Determine the currently authenticated user either from the relay request
* or, if missing, by resolving from a valid Laravel session cookie.
*/
private function getAuthenticatedUser(): ?Authenticatable
{
return $this->relayRequest->user() ?? $this->getUserFromSession();
}
/**
* Attempt to retrieve the authenticated user from the Laravel session cookie.
*/
private function getUserFromSession(): ?Authenticatable
{
$sessionCookieName = $this->configRepository->get('session.cookie');
$sessionCookie = $this->relayRequest->cookies->get($sessionCookieName);
if (! is_string($sessionCookie)) {
return null;
}
/** @var Store $session */
$session = $this->container->make(SessionManager::class)->driver();
$session->setId(id: $this->extractSessionIdFromCookie($sessionCookie));
$session->start();
$tokenPrefix = 'login_'.$this->configRepository->get('nimbus.auth.guard');
$userId = Arr::first(
$session->all(),
fn (mixed $value, string $key): bool => str_starts_with($key, $tokenPrefix),
);
return $this->userProvider->retrieveById($userId);
}
/**
* Decrypt the Laravel session cookie and extract the session ID.
*
* Laravels session cookie is formatted as "payload|signature", where the
* payload contains the encrypted session identifier.
*/
private function extractSessionIdFromCookie(string $cookieValue): string
{
$encrypter = $this->container->make('encrypter');
$decrypted = $encrypter->decrypt($cookieValue, unserialize: false);
[, $sessionId] = explode('|', $decrypted, 2);
return $sessionId;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers;
use Illuminate\Config\Repository as ConfigRepository;
use Illuminate\Container\Container;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Client\PendingRequest;
use Sunchayn\Nimbus\Modules\Config\Exceptions\MisconfiguredValueException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Concerns\UsesSpecialAuthenticationInjector;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Exceptions\InvalidAuthorizationValueException;
class ImpersonateUserAuthorizationHandler implements AuthorizationHandler
{
use UsesSpecialAuthenticationInjector;
private UserProvider $userProvider;
public function __construct(
public readonly int $userId,
private readonly Container $container,
private readonly ConfigRepository $configRepository,
) {
if ($userId <= 0) {
throw InvalidAuthorizationValueException::becauseUserIsNotFound();
}
$this->userProvider = $this
->container->get('auth')
->guard(name: config('nimbus.auth.guard'))
->getProvider();
}
/**
* @throws BindingResolutionException
* @throws MisconfiguredValueException
*/
public function authorize(PendingRequest $pendingRequest): PendingRequest
{
$user = $this->userProvider->retrieveById($this->userId);
if ($user === null) {
throw InvalidAuthorizationValueException::becauseUserIsNotFound();
}
return $this
->getInjector($this->container, $this->configRepository)
->attach($pendingRequest, $user);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers;
use Illuminate\Http\Client\PendingRequest;
/**
* Authorization handler that performs no authorization.
*
* Used when requests should be sent without any authentication headers
* or session forwarding, allowing unauthenticated API testing.
*/
class NoAuthorizationHandler implements AuthorizationHandler
{
/**
* Applies no authorization to the request.
*
* This handler intentionally does nothing, allowing requests to be
* sent without any authentication mechanisms.
*/
public function authorize(PendingRequest $pendingRequest): PendingRequest
{
return $pendingRequest;
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Authorization\Injectors;
use Illuminate\Config\Repository as ConfigRepository;
use Illuminate\Container\Container;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Cookie\CookieValuePrefix;
use Illuminate\Encryption\Encrypter;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Sunchayn\Nimbus\Modules\Config\Exceptions\MisconfiguredValueException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Contracts\SpecialAuthenticationInjectorContract;
class RememberMeCookieInjector implements SpecialAuthenticationInjectorContract
{
private UserProvider $userProvider;
private Guard $authGuard;
private Encrypter $encrypter;
/**
* @throws NotFoundExceptionInterface
* @throws ContainerExceptionInterface
* @throws MisconfiguredValueException
*/
public function __construct(
private readonly Request $relayRequest,
private readonly Container $container,
ConfigRepository $configRepository,
) {
$this->encrypter = $this->container->get('encrypter');
$this->authGuard = $this->container->get('auth')->guard(
$configRepository->get('nimbus.auth.guard'),
);
if (! $this->authGuard instanceof StatefulGuard) {
throw MisconfiguredValueException::becauseOfInvalidGuardInjectorCombination('Please use a stateful guard.');
}
if (! method_exists($this->authGuard, 'getProvider')) {
throw MisconfiguredValueException::becauseOfInvalidGuardInjectorCombination('Please use a guard that exposes a provider.');
}
$this->userProvider = $this->authGuard->getProvider();
}
public function attach(
PendingRequest $pendingRequest,
Authenticatable $authenticatable,
): PendingRequest {
if ($this->forwardRememberMeCookie($pendingRequest, $authenticatable)) {
return $pendingRequest;
}
// Generate authentication artifacts for the user.
$recallerToken = $this->generateRecallerTokenFor($authenticatable);
return $pendingRequest->withCookies(
[
$this->authGuard->getRecallerName() => $recallerToken,
],
// Note: This implementation assumes same-domain requests. Cross-domain
// impersonation would require additional configuration for cookie domains.
$this->relayRequest->getHost(),
);
}
private function forwardRememberMeCookie(PendingRequest $pendingRequest, Authenticatable $authenticatable): bool
{
if ($authenticatable->getAuthIdentifier() !== $this->relayRequest->user()?->getAuthIdentifier()) {
return false;
}
$recallerCookieKey = $this->authGuard->getRecallerName();
$recallerCookieToForward = $this->relayRequest->cookies->get($recallerCookieKey);
if (! $recallerCookieToForward) {
return false;
}
$pendingRequest->withCookies(
[
$recallerCookieKey => $recallerCookieToForward,
],
domain: $this->relayRequest->getHost(),
);
return true;
}
/**
* Generate authentication artifacts for the user.
*
* Creates a recaller cookie that will authenticate the target user
* when the request reaches the target application. No session cookie
* is needed since we're not forwarding the original session.
*/
private function generateRecallerTokenFor(Authenticatable $authenticatable): string
{
// Generate recaller token for "remember me" functionality
$recallerToken = $authenticatable->getAuthIdentifier().'|'.$this->getRememberMeTokenFor($authenticatable).'|'.$authenticatable->getAuthPasswordName();
return $this->encrypter->encrypt(
CookieValuePrefix::create($this->authGuard->getRecallerName(), $this->encrypter->getKey()).$recallerToken,
false,
);
}
private function getRememberMeTokenFor(Authenticatable $authenticatable): string
{
$existentToken = $authenticatable->getRememberToken();
if (filled($existentToken)) {
return $existentToken;
}
$newToken = hash('sha256', Str::random(60));
$this->userProvider->updateRememberToken($authenticatable, $newToken);
return $newToken;
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\Authorization\Injectors;
use Illuminate\Config\Repository as ConfigRepository;
use Illuminate\Container\Container;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Http\Client\PendingRequest;
use Sunchayn\Nimbus\Modules\Config\Exceptions\MisconfiguredValueException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Contracts\SpecialAuthenticationInjectorContract;
class TymonJwtTokenInjector implements SpecialAuthenticationInjectorContract
{
private Guard $guard;
/**
* @throws MisconfiguredValueException
*/
public function __construct(
private readonly ConfigRepository $configRepository,
private readonly Container $container,
) {
if (! class_exists(\Tymon\JWTAuth\JWTGuard::class)) {
throw MisconfiguredValueException::becauseOfMissingDependency(dependency: 'tymon/jwt-auth');
}
$this->guard = $this
->container->make('auth')
->guard(name: $this->configRepository->get('nimbus.auth.guard'));
if (! $this->guard instanceof \Tymon\JWTAuth\JWTGuard) {
throw MisconfiguredValueException::becauseOfInvalidGuardInjectorCombination("Please use a `\Tymon\JWTAuth\JWTGuard` guard.");
}
}
protected function getGuard(): Guard
{
return $this->guard;
}
public function attach(
PendingRequest $pendingRequest,
Authenticatable $authenticatable,
): PendingRequest {
/** @var string $bearerToken */
// @phpstan-ignore-next-line The `JWTGuard` will indeed return the token here even though the contract annotates it as void.
$bearerToken = $this->getGuard()->login($authenticatable);
$pendingRequest->withToken(
token: $bearerToken,
);
return $pendingRequest;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\DataTransferObjects;
use Sunchayn\Nimbus\Modules\Relay\ValueObjects\PrintableResponseBody;
use Sunchayn\Nimbus\Modules\Relay\ValueObjects\ResponseCookieValueObject;
readonly class RelayedRequestResponseData
{
/**
* @param string[][] $headers
* @param ResponseCookieValueObject[] $cookies
*/
public function __construct(
public int $statusCode,
public string $statusText,
public PrintableResponseBody $body,
public array $headers,
public float $durationMs,
public int $timestamp,
public array $cookies,
) {}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\DataTransferObjects;
use Illuminate\Support\Collection;
use Sunchayn\Nimbus\Http\Api\Relay\NimbusRelayRequest;
use Sunchayn\Nimbus\Modules\Relay\Authorization\AuthorizationCredentials;
use Sunchayn\Nimbus\Modules\Relay\Authorization\AuthorizationTypeEnum;
use Symfony\Component\HttpFoundation\ParameterBag;
readonly class RequestRelayData
{
/**
* @param array<string, string> $headers
* @param array<string, mixed> $body
*/
public function __construct(
public string $method,
public string $endpoint,
public AuthorizationCredentials $authorization,
public array $headers,
public array $body,
public ParameterBag $cookies,
) {}
public static function fromRelayApiRequest(NimbusRelayRequest $nimbusRelayRequest): self
{
/**
* @var array{
* headers?: array<array-key, array{key: string, value: string}>,
* method: string,
* endpoint: string,
* authorization?: array{type: string, value?: string|array{username: string, password: string}|null},
* body: mixed,
* } $data
**/
$data = $nimbusRelayRequest->validated();
/** @var Collection<string, string> $headers */
$headers = collect($data['headers'] ?? [])
->pluck('value', 'key');
$headers->when(
! $headers->has('Accept'),
fn () => $headers->put('Accept', 'application/json'),
);
$headers->when(
$nimbusRelayRequest->userAgent() !== null,
fn () => $headers->put('User-Agent', (string) $nimbusRelayRequest->userAgent()),
);
return new self(
method: strtolower($data['method']),
endpoint: $data['endpoint'],
authorization: array_key_exists('authorization', $data)
? new AuthorizationCredentials(
type: AuthorizationTypeEnum::from($data['authorization']['type']),
value: $data['authorization']['value'] ?? null,
)
: AuthorizationCredentials::none(),
headers: $headers->mapWithKeys(fn (mixed $value, string $key): array => [strtolower($key) => $value])->all(),
body: $nimbusRelayRequest->getBody(),
cookies: $nimbusRelayRequest->cookies,
);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\ValueObjects;
use Illuminate\Http\Client\Response;
readonly class PrintableResponseBody
{
/**
* @param string|array<array-key, mixed> $body
*/
public function __construct(
public array|string $body,
) {}
public static function fromResponse(Response $response): self
{
return new self(
body: $response->json() ?? $response->body(),
);
}
public function toPrettyJSON(): string
{
if (is_array($this->body)) {
return json_encode($this->body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: '';
}
return $this->body;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Sunchayn\Nimbus\Modules\Relay\ValueObjects;
use Illuminate\Support\Str;
class ResponseCookieValueObject
{
protected ?string $decryptedValue;
public function __construct(
public string $key,
protected string $rawValue,
protected string $prefix,
) {
$this->decryptedValue = $this->computeDecryptedValue();
}
/**
* @return array{
* key: string,
* value: array{
* raw: string,
* decrypted: string|null,
* },
* }
*/
public function toArray(): array
{
return [
'key' => $this->key,
'value' => [
'raw' => $this->rawValue,
'decrypted' => $this->decryptedValue,
],
];
}
private function computeDecryptedValue(): ?string
{
return rescue(
fn () => Str::replaceStart($this->prefix, '', decrypt($this->rawValue, false)),
report: false,
);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Actions;
use Illuminate\Contracts\Auth\Factory;
class BuildCurrentUserAction
{
public function __construct(
private readonly Factory $factory,
) {}
/**
* @return array{id: int}|null
*/
public function execute(): ?array
{
if ($this->factory->guard()->user() === null) {
return null;
}
return ['id' => $this->factory->guard()->user()->getAuthIdentifier()];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Actions;
use Illuminate\Config\Repository as ConfigRepository;
use Illuminate\Support\Arr;
use Sunchayn\Nimbus\Modules\Config\GlobalHeaderGeneratorTypeEnum;
class BuildGlobalHeadersAction
{
public function __construct(
private readonly ConfigRepository $configRepository,
) {}
/**
* @return array<array-key, scalar|null>
*/
public function execute(): array
{
/** @var array<array-key, mixed> $headers */
$headers = $this->configRepository->get('nimbus.headers');
return array_values(
Arr::map(
$headers,
fn (mixed $value, string $header): array => [
'header' => $header,
'type' => $value instanceof GlobalHeaderGeneratorTypeEnum ? 'generator' : 'raw',
'value' => match (true) {
$value instanceof GlobalHeaderGeneratorTypeEnum => $value->value,
is_scalar($value) => $value,
default => null,
},
],
),
);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Actions;
/**
* Disables third-party UI addons that interfere with Nimbus interface.
*/
class DisableThirdPartyUiAction
{
public function execute(): void
{
// The Debugbar will interfere with the UI,
// The page is a third party so most likely no need to have the debug toolbar here.
if (class_exists(\Barryvdh\Debugbar\Facades\Debugbar::class)) {
\Barryvdh\Debugbar\Facades\Debugbar::disable();
}
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Actions;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Routing\Route;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Psr\Log\LoggerInterface;
use Sunchayn\Nimbus\Modules\Routes\Collections\ExtractedRoutesCollection;
use Sunchayn\Nimbus\Modules\Routes\DataTransferObjects\ExtractedRoute;
use Sunchayn\Nimbus\Modules\Routes\Exceptions\RouteExtractionException;
use Sunchayn\Nimbus\Modules\Routes\Exceptions\RouteExtractionInternalException;
use Sunchayn\Nimbus\Modules\Routes\Extractor\SchemaExtractor;
use Sunchayn\Nimbus\Modules\Routes\Factories\ExtractableRouteFactory;
use Sunchayn\Nimbus\Modules\Routes\Services\IgnoredRoutesService;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\Endpoint;
use Throwable;
/**
* Orchestrates the extraction of validation schemas from Laravel routes.
*
* Filters routes by prefix, excludes ignored routes, and transforms each
* route into a structured configuration with extracted validation schemas.
*/
class ExtractRoutesAction
{
public function __construct(
protected SchemaExtractor $schemaExtractor,
protected ExtractableRouteFactory $routeFactory,
protected IgnoredRoutesService $ignoredRoutesService,
protected Repository $config,
protected LoggerInterface $logger,
) {}
/**
* Processes application routes and extracts schemas.
*
* @param array<int, \Illuminate\Routing\Route> $routes
*/
public function execute(array $routes): ExtractedRoutesCollection
{
$prefix = $this->config->get('nimbus.routes.prefix');
$configs = collect($routes)
->filter(function (Route $route) use ($prefix): bool {
$uri = $route->uri();
return str_starts_with($uri, $prefix) || str_starts_with($uri, '/'.$prefix);
})
->when(
$this->ignoredRoutesService->hasIgnoredRoutes(),
fn (Collection $routes) => $routes->reject(fn (Route $route): bool => $this->ignoredRoutesService->isIgnored($route))
)
->values()
->map(fn (Route $route): \Sunchayn\Nimbus\Modules\Routes\DataTransferObjects\ExtractedRoute => $this->transformRoute($route));
return ExtractedRoutesCollection::make($configs);
}
/**
* Transforms a Laravel route into a RouteConfig with extracted schema.
*
* @throws RouteExtractionException
*/
protected function transformRoute(Route $route): ExtractedRoute
{
try {
$extractableRoute = $this->routeFactory->fromLaravelRoute($route);
$schema = $this->schemaExtractor->extract($extractableRoute);
} catch (Throwable $throwable) {
$this->logger->error(
$throwable->getMessage(),
context: [
'trace' => $throwable->getTraceAsString(),
],
);
throw RouteExtractionInternalException::forRoute(
throwable: $throwable,
routeUri: $route->uri(),
routeMethods: $route->methods(),
controllerClass: $extractableRoute->controllerClass ?? null,
controllerMethod: $extractableRoute->controllerMethod ?? null,
);
}
$methods = Arr::where(
$route->methods(),
// We exclude the `HEAD` methods as they don't carry request bodies.
fn (string $method): bool => $method !== 'HEAD',
);
return new ExtractedRoute(
uri: Endpoint::fromRaw(
$route->uri(),
routesPrefix: $this->config->get('nimbus.routes.prefix'),
isVersioned: $this->config->get('nimbus.routes.versioned'),
),
methods: $methods,
schema: $schema,
);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Actions;
use Sunchayn\Nimbus\Modules\Routes\Services\IgnoredRoutesService;
/**
* Adda the specified route to the ignore list.
*
* @example
* Input: "api/users|GET,POST"
* Output: Redirects to clean URL after adding route to ignored list
*/
class IgnoreRouteErrorAction
{
public function __construct(
private readonly IgnoredRoutesService $ignoredRoutesService,
) {}
public function execute(string $ignoreData): void
{
// Parse the ignore data (format: "uri|methods")
$parts = explode('|', $ignoreData);
if (count($parts) !== 2) {
return;
}
if (empty($parts[0])) {
return;
}
$uri = $parts[0];
$methods = json_decode($parts[1], true) ?: [];
$this->ignoredRoutesService->add($uri, $methods);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Collections;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Sunchayn\Nimbus\Modules\Routes\DataTransferObjects\ExtractedRoute;
/**
* @phpstan-import-type SchemaShape from \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema
*
* @phpstan-type RouteDefinitionShape array{
* uri: string,
* shortUri: string,
* methods: string[],
* schema: SchemaShape,
* extractionError: string,
* }
*
* @extends Collection<array-key, ExtractedRoute>
*/
class ExtractedRoutesCollection extends Collection
{
/**
* @return array<string, array<string, RouteDefinitionShape[]>>
*/
public function toFrontendArray(): array
{
/** @var Collection<string, self> $groupedByVersion */
$groupedByVersion = $this->groupBy(static fn (ExtractedRoute $extractedRoute): string => $extractedRoute->uri->version);
return $groupedByVersion
->map(
static function (self $group): Collection {
/** @var Collection<string, self> $groupedByResource */
$groupedByResource = $group
->groupBy(static fn (ExtractedRoute $extractedRoute): string => $extractedRoute->uri->resource);
return $groupedByResource
->map(
static fn (self $group): Collection => $group->map(
static fn (ExtractedRoute $extractedRoute): array => [
'uri' => $extractedRoute->uri->value,
'shortUri' => Str::replaceStart('/', '', $extractedRoute->uri->getShortUri()),
'methods' => $extractedRoute->methods,
'schema' => $extractedRoute->schema->toJsonSchema(),
'extractionError' => $extractedRoute->schema->extractionError?->toHtml(),
],
),
);
},
)
->toArray();
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\DataTransferObjects;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\Endpoint;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema;
/**
* @codeCoverageIgnore a DTO with no behavior.
*/
class ExtractedRoute
{
/**
* @param string[] $methods
*/
public function __construct(
public readonly Endpoint $uri,
public readonly array $methods,
public readonly Schema $schema,
) {}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\DataTransferObjects;
/**
* @codeCoverageIgnore a DTO with no behavior.
*/
readonly class IgnoreRouteErrorData
{
/**
* @param string[] $methods
*/
public function __construct(
public string $uri,
public array $methods,
) {}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Exceptions;
class InvalidRouteDefinitionException extends RouteExtractionException
{
/**
* @param string[] $routeMethods
*/
public static function forRoute(
string $routeUri,
array $routeMethods,
string $controllerClass,
string $controllerMethod,
): self {
$properlyFormattedUsesStatement = filled($controllerClass) && filled($controllerMethod);
$message = $properlyFormattedUsesStatement
? sprintf("Controller method '%s' not found in class '%s' for route '%s'.", $controllerMethod, $controllerClass, $routeUri)
: sprintf("Malformed `uses` statement for route '%s'.", $routeUri);
$suggestedSolution = $properlyFormattedUsesStatement
? sprintf("Check that the method '%s' exists in the '%s' class. This usually indicates an incorrect route definition in your routes file.", $controllerMethod, $controllerClass)
: 'Make sure the `uses` statement is properly formatted `{controllerClass}@{controllerMethod}`. If it is an invokable controller then it must not have the `@` suffix.';
return new self(
message: $message,
routeUri: $routeUri,
routeMethods: $routeMethods,
controllerClass: $controllerClass,
controllerMethod: $controllerMethod,
suggestedSolution: $suggestedSolution,
);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Exceptions;
use RuntimeException;
use Throwable;
abstract class RouteExtractionException extends RuntimeException
{
private readonly string $controllerClass;
private readonly string $controllerMethod;
/**
* @param string[] $routeMethods
*/
public function __construct(
string $message,
private readonly ?string $routeUri = null,
private readonly ?array $routeMethods = null,
?string $controllerClass = null,
?string $controllerMethod = null,
private readonly ?string $suggestedSolution = null,
int $code = 0,
?Throwable $previous = null,
) {
$this->controllerClass = filled($controllerClass) ? $controllerClass : '[unspecified]';
$this->controllerMethod = filled($controllerMethod) ? $controllerMethod : '[unspecified]';
parent::__construct($message, $code, $previous);
}
public function getSuggestedSolution(): ?string
{
return $this->suggestedSolution;
}
public function getIgnoreData(): ?string
{
if ($this->routeUri === null || $this->routeMethods === null) {
return null;
}
return implode('|', [
$this->routeUri,
json_encode($this->routeMethods),
]);
}
/**
* @return array<string, array<string>|string|null>
*/
public function getRouteContext(): array
{
return [
'uri' => $this->routeUri,
'methods' => $this->routeMethods,
'controllerClass' => $this->controllerClass,
'controllerMethod' => $this->controllerMethod,
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Exceptions;
use Throwable;
class RouteExtractionInternalException extends RouteExtractionException
{
/**
* @param string[] $routeMethods
*/
public static function forRoute(
Throwable $throwable,
string $routeUri,
array $routeMethods,
?string $controllerClass = null,
?string $controllerMethod = null,
): self {
return new self(
message: sprintf("Failed to extract route information for '%s' due to an unexpected error: %s", $routeUri, $throwable->getMessage()),
routeUri: $routeUri,
routeMethods: $routeMethods,
controllerClass: $controllerClass,
controllerMethod: $controllerMethod,
suggestedSolution: 'Check the application logs for more details and ensure all dependencies are properly installed.'
.'<br />In case of internal errors, please open an issue: <a class="hover:underline" href="https://github.com/sunchayn/nimbus/issues/new/choose">https://github.com/sunchayn/nimbus/issues/new/choose</a>',
code: $throwable->getCode(),
previous: $throwable,
);
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Extractor\Ast;
use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PhpParser\Node\VariadicPlaceholder;
/**
* Converts AST nodes into their concrete PHP values.
*
* @example Node\Scalar\LNumber(2) -> 2
* @example Node\Expr\Array_([2, 'example']) -> [2, 'example']
* @example Node\Expr\New_(SomeClass) -> new SomeClass()
*/
class ConvertNodeToConcreteValue
{
/**
* @param array<string, mixed> $variablesContext
*/
public static function process(Node $node, array $variablesContext = []): mixed
{
return match (true) {
$node instanceof Node\Scalar\String_ => $node->value,
$node instanceof Node\Scalar\LNumber => $node->value,
$node instanceof Node\Scalar\DNumber => $node->value,
$node instanceof Node\Expr\Array_ => self::resolveArray($node, $variablesContext),
$node instanceof Node\Expr\Variable => self::resolveVariable($node, $variablesContext),
$node instanceof Node\Expr\StaticCall => self::resolveStaticCall($node, $variablesContext),
$node instanceof Expr\New_ => self::resolveNewInstance($node, $variablesContext),
$node instanceof Node\Expr\ConstFetch => self::resolveConstant($node),
$node instanceof Node\Expr\BinaryOp\Concat => self::resolveConcatenation($node, $variablesContext),
$node instanceof Node\Scalar\InterpolatedString => self::resolveInterpolatedString($node, $variablesContext),
$node instanceof Node\InterpolatedStringPart => $node->value,
$node instanceof Expr\ClassConstFetch && $node->class instanceof Name => $node->class->name,
default => null,
};
}
/**
* @param array<string, mixed> $variablesContext
* @return array<array-key, mixed>
*/
private static function resolveArray(Node\Expr\Array_ $array, array $variablesContext): array
{
$result = [];
foreach ($array->items as $item) {
$value = self::process($item->value, $variablesContext);
if ($item->key === null) {
$result[] = $value;
continue;
}
$key = self::process($item->key, $variablesContext);
$result[$key] = $value;
}
return $result;
}
/**
* @param array<string, mixed> $variablesContext
*/
private static function resolveVariable(Node\Expr\Variable $variable, array $variablesContext): mixed
{
$varName = $variable->name;
if (! is_string($varName)) {
return null;
}
return $variablesContext[$varName] ?? null;
}
/**
* @param array<string, mixed> $variablesContext
*/
private static function resolveStaticCall(Node\Expr\StaticCall $staticCall, array $variablesContext): mixed
{
$arguments = array_filter( // <- TODO [Test] make sure to assert this filter if not already.
array_map(
function (Arg|VariadicPlaceholder $argNode) use ($variablesContext): mixed {
if ($argNode instanceof VariadicPlaceholder) {
return null;
}
return self::process($argNode->value, $variablesContext);
},
$staticCall->args,
),
);
// If we failed to resolve all arguments, return null to avoid incomplete values
if (count($arguments) !== count($staticCall->args)) {
return null;
}
if (! ($staticCall->name instanceof Identifier)) {
return null;
}
if (! ($staticCall->class instanceof Name)) {
return null;
}
return $staticCall->class->name::{$staticCall->name->name}(...$arguments);
}
/**
* @param array<string, mixed> $variablesContext
*/
private static function resolveNewInstance(Expr\New_ $new, array $variablesContext): ?object
{
$arguments = array_filter( // <- TODO [Test] make sure to assert this filter if not already.
array_map(
function (Arg|VariadicPlaceholder $argNode) use ($variablesContext): mixed {
if ($argNode instanceof VariadicPlaceholder) {
return null;
}
return self::process($argNode->value, $variablesContext);
},
$new->args,
),
);
// If we failed to resolve all arguments, return null to avoid incomplete values
if (count($arguments) !== count($new->args)) {
return null;
}
if (! ($new->class instanceof Name)) {
return null;
}
return new $new->class->name(...$arguments);
}
private static function resolveConstant(Node\Expr\ConstFetch $constFetch): mixed
{
return constant($constFetch->name->toString());
}
/**
* @param array<string, mixed> $variablesContext
*/
private static function resolveConcatenation(Node\Expr\BinaryOp\Concat $concat, array $variablesContext): string
{
return self::process($concat->left, $variablesContext).self::process($concat->right, $variablesContext);
}
/**
* @param array<string, mixed> $variablesContext
*/
private static function resolveInterpolatedString(Node\Scalar\InterpolatedString $interpolatedString, array $variablesContext): string
{
return array_reduce(
$interpolatedString->parts,
fn ($carry, $current): string => $carry.self::process($current, $variablesContext),
initial: '',
);
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Extractor\Ast;
use PhpParser\Node;
use PhpParser\NodeVisitor;
use PhpParser\NodeVisitorAbstract;
use Sunchayn\Nimbus\Modules\Routes\Extractor\Ast\Shared\QualifiesTypehint;
use Sunchayn\Nimbus\Modules\Schemas\Collections\Ruleset;
/**
* Extracts validation rules from FormRequest's `rules()` method.
*
* Walks through a class AST to find the `rules()` method and extracts
* its return statement to determine the validation rules array.
*/
class RulesMethodVisitor extends NodeVisitorAbstract
{
use QualifiesTypehint;
private ?Ruleset $rules = null;
/** @var array<string, mixed> Variables defined within the rules() method */
private array $variablesContext = [];
public function beforeTraverse(array $nodes): array
{
return $this->qualifyClassTypeHinting($nodes);
}
public function getRules(): Ruleset
{
return $this->rules ?? Ruleset::fromLaravelRules([]);
}
public function enterNode(Node $node): null|int|Node|array
{
if (! $this->isRulesMethod($node)) {
return null;
}
/** @var Node\Stmt\ClassMethod $node */
$this->extractRulesFromMethod($node);
return NodeVisitor::STOP_TRAVERSAL;
}
private function isRulesMethod(Node $node): bool
{
return $node instanceof Node\Stmt\ClassMethod
&& $node->name->toString() === 'rules';
}
private function extractRulesFromMethod(Node\Stmt\ClassMethod $classMethod): void
{
if ($classMethod->stmts === null) {
return;
}
foreach ($classMethod->stmts as $stmt) {
if ($stmt instanceof Node\Stmt\Return_) {
$this->processReturnStatement($stmt);
continue;
}
if ($stmt instanceof Node\Stmt\Expression && $stmt->expr instanceof Node\Expr\Assign) {
$this->addVariableValueToContext($stmt->expr);
}
}
}
private function processReturnStatement(Node\Stmt\Return_ $return): void
{
if (! $return->expr instanceof \PhpParser\Node\Expr) {
return;
}
$rules = ConvertNodeToConcreteValue::process($return->expr, $this->variablesContext);
if (is_array($rules)) {
$this->rules = Ruleset::fromLaravelRules($rules);
}
}
private function addVariableValueToContext(Node\Expr\Assign $assign): void
{
if (! ($assign->var instanceof Node\Expr\Variable)) {
return;
}
if (! is_string($assign->var->name)) {
return;
}
$this->variablesContext[$assign->var->name] = ConvertNodeToConcreteValue::process($assign->expr, $this->variablesContext);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Extractor\Ast\Shared;
use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
/**
* @codeCoverageIgnore covered in used classes.
*/
trait QualifiesTypehint
{
/**
* Normalizes how classes are referenced in type hinting so that we can have more conclusive equality checks later on.
*
* @param Node[] $nodes
* @return Node[] $nodes
*/
protected function qualifyClassTypeHinting(array $nodes): array
{
$nodeTraverser = new NodeTraverser;
$nodeTraverser->addVisitor(new NameResolver);
return $nodeTraverser->traverse($nodes);
}
}

View File

@@ -0,0 +1,301 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Extractor\Ast;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use PhpParser\Node;
use PhpParser\Node\Expr\Assign;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Identifier;
use PhpParser\Node\Stmt\Expression;
use PhpParser\NodeVisitor;
use PhpParser\NodeVisitorAbstract;
use Sunchayn\Nimbus\Modules\Routes\Extractor\Ast\Shared\QualifiesTypehint;
use Sunchayn\Nimbus\Modules\Schemas\Collections\Ruleset;
/**
* Extracts validation rules from controller methods using AST analysis.
*
* Finds request validation calls (validate, validateWithBag) within controller
* action methods and extracts their validation rule arguments.
*
* @example Controller: $request->validate(['name' => 'required|string'])
* @example Output: ['name' => 'required|string']
*/
class ValidateCallVisitor extends NodeVisitorAbstract
{
use QualifiesTypehint;
private const SUPPORTED_VALIDATION_METHODS = ['validate', 'validateWithBag'];
private string $targetControllerMethod;
/** @var array<string, Node\Stmt\ClassMethod> */
private array $classMethodNodes = [];
private ?Ruleset $rules = null;
/** @var array<string, mixed> Variables defined within the method */
private array $context = [];
public function __construct(string $routeActionMethod)
{
$this->targetControllerMethod = $routeActionMethod;
}
public function beforeTraverse(array $nodes): array
{
$this->gatherClassMethods($nodes);
return $this->qualifyClassTypeHinting($nodes);
}
public function getRules(): Ruleset
{
return $this->rules ?? Ruleset::fromLaravelRules([]);
}
public function enterNode(Node $node): null|int|Node|array
{
if (! $this->isTargetControllerMethod($node)) {
return null;
}
/** @var Node\Stmt\ClassMethod $node */
$this->extractValidationRules($node);
return NodeVisitor::STOP_TRAVERSAL;
}
private function isTargetControllerMethod(Node $node): bool
{
return $node instanceof Node\Stmt\ClassMethod
&& $node->name->toString() === $this->targetControllerMethod;
}
private function extractValidationRules(Node\Stmt\ClassMethod $classMethod): void
{
// TODO [Enhancement] Support validation rules from wrapped method calls (e.g., $this->validateFormData(..)).
if ($classMethod->stmts === null) {
return;
}
foreach ($classMethod->stmts as $statement) {
if (! ($statement instanceof Expression)) {
continue;
}
if ($statement->expr instanceof Assign) {
$this->storeVariableAssignment($statement->expr);
}
$expression = $this->extractExpressionFromNode($statement);
if ($this->isEligibleMethodCall($expression, $classMethod)) {
/** @var MethodCall $expression */
$this->processValidationCall($expression);
}
}
}
private function extractExpressionFromNode(Expression $expression): Node\Expr
{
return match (true) {
$expression->expr instanceof Assign => $expression->expr->expr,
default => $expression->expr,
};
}
private function isEligibleMethodCall(
Node\Expr $expr,
Node\Stmt\ClassMethod $classMethod
): bool {
if (! ($expr instanceof MethodCall)) {
return false;
}
if (! ($expr->name instanceof Identifier)) {
return false;
}
if (! in_array($expr->name->toString(), self::SUPPORTED_VALIDATION_METHODS)) {
return false;
}
return $this->isCalledOnRequestInstance($expr, $classMethod);
}
private function isCalledOnRequestInstance(
MethodCall $methodCall,
Node\Stmt\ClassMethod $classMethod
): bool {
if (! ($methodCall->var instanceof Variable) || ! is_string($methodCall->var->name)) {
return true; // <- Assume it's valid if we can't determine the variable.
}
$varName = $methodCall->var->name;
$matchingParameter = Arr::first(
$classMethod->params,
fn (Node\Param $param): bool => $param->var instanceof Variable && $param->var->name === $varName,
);
if ($matchingParameter === null) {
return false;
}
$type = $matchingParameter->type->name ?? null;
return $type === Request::class || is_subclass_of($type, Request::class);
}
private function processValidationCall(MethodCall $methodCall): void
{
$methodName = $methodCall->name instanceof Node\Identifier
? $methodCall->name->toString()
: null;
$argNode = $this->extractValidationRulesArgument($methodCall, $methodName);
if (! $argNode instanceof \PhpParser\Node) {
return;
}
if ($this->isNestedMethodCall($argNode)) {
/** @var Identifier $argIdentifier */
$argIdentifier = $argNode->name;
$this->processNestedMethodCall(methodName: $argIdentifier->name);
return;
}
$this->rules = Ruleset::fromLaravelRules(
ConvertNodeToConcreteValue::process($argNode, $this->context)
);
}
private function extractValidationRulesArgument(MethodCall $methodCall, ?string $methodName): ?Node
{
return match ($methodName) {
'validate' => $methodCall->args[0]->value ?? null,
'validateWithBag' => $methodCall->args[1]->value ?? null,
default => null,
};
}
/**
* @phpstan-assert-if-true MethodCall $argNode
*/
private function isNestedMethodCall(Node $argNode): bool
{
if (! ($argNode instanceof MethodCall)) {
return false;
}
if (! ($argNode->name instanceof Identifier)) {
return false;
}
return array_key_exists($argNode->name->name, $this->classMethodNodes);
}
private function processNestedMethodCall(string $methodName): void
{
$nestedMethod = $this->classMethodNodes[$methodName];
$this->extractRulesFromReturnStatement($nestedMethod);
}
private function extractRulesFromReturnStatement(Node\Stmt\ClassMethod $classMethod): void
{
/** @var Node\Stmt\Return_|null $returnStatement */
$returnStatement = Arr::first(
$classMethod->stmts ?? [],
fn ($stmt): bool => $stmt instanceof Node\Stmt\Return_,
);
if ($returnStatement === null || ! $this->isArrayReturn($returnStatement)) {
return;
}
// TODO [Enhancement] Account for nested method calls in return statements
$this->addMethodVariablesToContext($classMethod);
if (! ($returnStatement->expr instanceof Node)) {
return;
}
$this->rules = Ruleset::fromLaravelRules(
ConvertNodeToConcreteValue::process($returnStatement->expr, $this->context)
);
}
private function isArrayReturn(Node\Stmt\Return_ $return): bool
{
return $return->expr instanceof Node\Expr\Array_;
}
private function addMethodVariablesToContext(Node\Stmt\ClassMethod $classMethod): void
{
if ($classMethod->stmts === null) {
return;
}
foreach ($classMethod->stmts as $statement) {
if (! property_exists($statement, 'expr')) {
continue;
}
if (! ($statement->expr instanceof Assign)) {
continue;
}
$this->storeVariableAssignment($statement->expr);
}
}
private function storeVariableAssignment(Assign $assign): void
{
if ($assign->expr instanceof MethodCall) {
return;
}
if (! ($assign->var instanceof Variable) || ! is_string($assign->var->name)) {
return;
}
$this->context[$assign->var->name] = ConvertNodeToConcreteValue::process(
$assign->expr,
$this->context
);
}
/**
* Find all nodes that are a class method node.
*
* @param Node[] $nodes
*/
private function gatherClassMethods(array $nodes): void
{
/** @var Node\Stmt\Class_|null $classNode */
$classNode = Arr::first(
$nodes,
fn (Node $node): bool => $node instanceof Node\Stmt\Class_
);
if ($classNode === null) {
return;
}
foreach ($classNode->stmts as $stmt) {
if ($stmt instanceof Node\Stmt\ClassMethod) {
$this->classMethodNodes[$stmt->name->name] = $stmt;
}
}
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Extractor;
use Illuminate\Container\Container;
use Sunchayn\Nimbus\Modules\Routes\Extractor\Strategies\ExtractorStrategyContract;
use Sunchayn\Nimbus\Modules\Routes\Extractor\Strategies\FormRequestExtractorStrategy;
use Sunchayn\Nimbus\Modules\Routes\Extractor\Strategies\InlineRequestValidatorExtractorStrategy;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\ExtractableRoute;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema;
class SchemaExtractor
{
/** @var ExtractorStrategyContract[] */
protected array $strategies = [];
public function __construct(
Container $container,
) {
$this->strategies = [
// Define in the extraction stragies in their execution order.
// Only one strategy will run, and that will be the first matching strategy.
$container->make(FormRequestExtractorStrategy::class),
$container->make(InlineRequestValidatorExtractorStrategy::class),
];
}
public function extract(ExtractableRoute $extractableRoute): Schema
{
foreach ($this->strategies as $strategy) {
if ($strategy->matches($extractableRoute)) {
/** @var ExtractorStrategyContract $strategy */
return $strategy->extract($extractableRoute);
}
}
return Schema::empty();
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Extractor\Strategies;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\ExtractableRoute;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema;
interface ExtractorStrategyContract
{
public function matches(ExtractableRoute $extractableRoute): bool;
public function extract(ExtractableRoute $extractableRoute): Schema;
}

View File

@@ -0,0 +1,163 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Extractor\Strategies;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\ParserFactory;
use ReflectionClass;
use ReflectionException;
use ReflectionNamedType;
use ReflectionParameter;
use Sunchayn\Nimbus\Modules\Routes\Extractor\Ast\RulesMethodVisitor;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\ExtractableRoute;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\RulesExtractionError;
use Sunchayn\Nimbus\Modules\Schemas\Builders\SchemaBuilder;
use Sunchayn\Nimbus\Modules\Schemas\Collections\Ruleset;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema;
use Throwable;
/**
* Extracts validation rules from Laravel Form Request classes.
*
* @example
* Controller: public function store(StoreUserRequest $request)
* FormRequest: rules() returns ['name' => 'required|string']
* Output: Schema with name field as required string
*/
class FormRequestExtractorStrategy implements ExtractorStrategyContract
{
public function __construct(
private readonly SchemaBuilder $schemaBuilder,
) {}
public function matches(ExtractableRoute $extractableRoute): bool
{
foreach ($extractableRoute->parameters as $parameter) {
if (! $parameter->hasType()) {
continue;
}
$type = $parameter->getType();
if (! $type instanceof ReflectionNamedType) {
continue;
}
$parameterType = $type->getName();
// If Laravel Request is used,
// we cannot figure out the schema from the request.
if ($parameterType === Request::class) {
return false;
}
// If it is not a form request instance, we continue.
if (! is_subclass_of($parameterType, Request::class)) {
continue;
}
return true;
}
return false; // <- didn't find a form request.
}
public function extract(ExtractableRoute $extractableRoute): Schema
{
$requestParameter = Arr::first(
$extractableRoute->parameters,
function (ReflectionParameter $reflectionParameter): bool {
$type = $reflectionParameter->getType();
if (! $type instanceof ReflectionNamedType) {
return false;
}
return is_subclass_of($type->getName(), Request::class);
},
);
if (! $requestParameter) {
return Schema::empty();
}
$type = $requestParameter->getType();
if (! $type instanceof ReflectionNamedType) {
return Schema::empty();
}
/** @var class-string $requestClassName */
$requestClassName = $type->getName();
$instance = new $requestClassName;
if (! method_exists($instance, 'rules')) {
return Schema::empty();
}
try {
$rules = Ruleset::fromLaravelRules($instance->rules());
} catch (Throwable $throwable) {
// We will give it one extra attempt to figure out the shape statically as much as possible.
$rules = $this->attemptGettingRulesShape($requestClassName);
$throwable = new RulesExtractionError(
throwable: $throwable,
);
}
return $this->schemaBuilder->buildSchemaFromRuleset($rules, rulesExtractionError: $throwable ?? null);
}
/**
* In some situations, the rules method might break due to dependency on request context,
* or any other information that is not available when calling it statically.
*
* @param class-string $requestClassName
*/
private function attemptGettingRulesShape(string $requestClassName): Ruleset
{
if (! method_exists($requestClassName, 'rules')) {
return Ruleset::empty();
}
try {
$fileName = (new ReflectionClass($requestClassName))->getFileName();
} catch (ReflectionException) {
return Ruleset::empty();
}
if (! $fileName || ! file_exists($fileName)) {
return Ruleset::empty();
}
$parser = (new ParserFactory)->createForNewestSupportedVersion();
$ast = $parser->parse(file_get_contents($fileName) ?: '');
if ($ast === null) {
return Ruleset::empty();
}
return $this->getRulesFromRequestAst($ast);
}
/**
* @param Node[] $ast
*/
private function getRulesFromRequestAst(array $ast): Ruleset
{
$rulesMethodVisitor = new RulesMethodVisitor;
$nodeTraverser = new NodeTraverser;
$nodeTraverser->addVisitor($rulesMethodVisitor);
$nodeTraverser->traverse($ast);
return $rulesMethodVisitor->getRules();
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Extractor\Strategies;
use PhpParser\NodeTraverser;
use Sunchayn\Nimbus\Modules\Routes\Extractor\Ast\ValidateCallVisitor;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\ExtractableRoute;
use Sunchayn\Nimbus\Modules\Schemas\Builders\SchemaBuilder;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema;
class InlineRequestValidatorExtractorStrategy implements ExtractorStrategyContract
{
public function __construct(
private readonly SchemaBuilder $schemaBuilder,
) {}
public function matches(ExtractableRoute $extractableRoute): bool
{
// This strategy doesn't support anonymous routes for now.
return $extractableRoute->methodName !== null;
}
public function extract(ExtractableRoute $extractableRoute): Schema
{
if (! $this->matches($extractableRoute)) {
return Schema::empty();
}
if ($extractableRoute->methodName === null) {
return Schema::empty();
}
$ast = ($extractableRoute->codeParser)();
if ($ast === null) {
return Schema::empty();
}
$validateCallVisitor = new ValidateCallVisitor($extractableRoute->methodName);
$nodeTraverser = new NodeTraverser;
$nodeTraverser->addVisitor($validateCallVisitor);
$nodeTraverser->traverse($ast);
return $this->schemaBuilder->buildSchemaFromRuleset($validateCallVisitor->getRules());
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Factories;
use Closure;
use Illuminate\Routing\Route;
use PhpParser\Node;
use PhpParser\Parser;
use PhpParser\ParserFactory;
use ReflectionClass;
use ReflectionException;
use ReflectionParameter;
use Sunchayn\Nimbus\Modules\Routes\Exceptions\InvalidRouteDefinitionException;
use Sunchayn\Nimbus\Modules\Routes\Exceptions\RouteExtractionException;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\ExtractableRoute;
/**
* Creates ExtractableRoute objects from Laravel Route instances.
*
* Handles the complexity of parsing `controller@method` syntax, validating
* controller existence, and preparing code analysis tools for validation extraction.
*/
class ExtractableRouteFactory
{
private static ?ParserFactory $parserFactory = null;
/**
* Converts a Laravel route into an ExtractableRoute for validation analysis.
*
* @throws RouteExtractionException
*/
public function fromLaravelRoute(Route $route): ExtractableRoute
{
$uses = $route->getAction('uses');
// Currently only supports controller@method routes. Closure-based routes
// would require different extraction strategies and are not yet supported
if (! is_string($uses) || $this->isSerializedRoute($uses)) {
return ExtractableRoute::empty();
}
$parts = explode('@', $uses);
$controllerClassName = $parts[0];
$controllerMethod = $parts[1] ?? '';
if ($controllerClassName === '' || $controllerClassName === '0' || ($controllerMethod === '' || $controllerMethod === '0')) {
throw InvalidRouteDefinitionException::forRoute(
routeUri: $route->uri(),
routeMethods: $route->methods(),
controllerClass: $controllerClassName,
controllerMethod: $controllerMethod,
);
}
/** @var class-string $controllerClassName */
$methodInfo = $this->getMethodInfo($controllerClassName, $controllerMethod);
if ($methodInfo === null) {
throw InvalidRouteDefinitionException::forRoute(
routeUri: $route->uri(),
routeMethods: $route->methods(),
controllerClass: $controllerClassName,
controllerMethod: $controllerMethod,
);
}
return new ExtractableRoute(
parameters: $methodInfo['parameters'],
codeParser: $this->getCodeParserFor($methodInfo['fileName']),
methodName: $controllerMethod,
controllerClass: $controllerClassName,
controllerMethod: $controllerMethod,
);
}
/**
* Validates controller and method existence, returning metadata for extraction.
*
* Returns null if the controller class or method doesn't exist, allowing
* the factory to handle missing controllers gracefully with proper exceptions.
*
* @param class-string $class
* @param non-empty-string $method
* @return null|array{
* parameters: array<int, ReflectionParameter>,
* fileName: non-empty-string
* }
*/
private function getMethodInfo(string $class, string $method): ?array
{
if (! class_exists($class)) {
return null;
}
if (! method_exists($class, $method)) {
return null;
}
$fileName = (new ReflectionClass($class))->getFileName();
if ($fileName === false) {
return null;
}
$parameters = $this->getMethodParameters($class, $method);
return [
'parameters' => $parameters,
'fileName' => $fileName,
];
}
private function isSerializedRoute(string $value): bool
{
return str_starts_with($value, 'a:') || str_starts_with($value, 'O:');
}
/**
* Extracts method parameters for validation analysis.
*
* Method parameters are needed to identify FormRequest classes that
* contain validation rules for extraction.
*
* @param class-string $class
* @param non-empty-string $method
* @return array<int, ReflectionParameter>
*/
private function getMethodParameters(string $class, string $method): array
{
try {
$reflectionClass = new ReflectionClass($class);
$reflectionMethod = $reflectionClass->getMethod($method);
} catch (ReflectionException) {
return [];
}
return $reflectionMethod->getParameters();
}
/**
* Creates a parser closure for the controller file.
*
* Uses rescue() to prevent parsing errors from crashing the application
* when controller files contain syntax errors or are unreadable.
*
* @param non-empty-string $fileName
* @return Closure(): (Node[]|null)
*/
private function getCodeParserFor(string $fileName): Closure
{
return fn (): ?array => rescue(
fn (): ?array => $this->getParser()->parse(file_get_contents($fileName) ?: ''),
report: false,
);
}
/**
* Provides a singleton PHP parser instance.
*
* ParserFactory is expensive to create, so we reuse a single instance
* across all route extractions for better performance.
*/
private function getParser(): Parser
{
if (! self::$parserFactory instanceof \PhpParser\ParserFactory) {
self::$parserFactory = new ParserFactory;
}
return self::$parserFactory->createForNewestSupportedVersion();
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Services;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\File;
/**
* Manages routes that should be excluded from schema extraction.
*
* Persists ignored routes to vendor directory to survive package updates,
* allowing developers to maintain a blacklist of problematic routes.
*/
class IgnoredRoutesService
{
private const STORAGE_PATH = 'vendor/sunchayn/nimbus/storage/ignored_routes.json';
/** @var array<string, non-empty-array{methods: string[], reason: string, ignored_at: string}> */
private array $ignoredRoutes = [];
private bool $isDirty = false;
public function __construct()
{
$this->loadFromFile();
}
public function __destruct()
{
if (! $this->isDirty) {
return;
}
$this->writeToFile();
}
/**
* Adds a route to the ignored list with a reason for exclusion.
*
* Routes are ignored when extraction fails or when developers
* explicitly want to exclude them from schema generation.
*
* @param non-empty-string $uri
* @param array<int, string> $methods
* @param non-empty-string $reason
*/
public function add(string $uri, array $methods, string $reason = 'Route extraction failed'): void
{
$routeData = [
'methods' => $methods,
'reason' => $reason,
'ignored_at' => CarbonImmutable::now()->toISOString() ?? date('Y-m-d H:i:s'),
];
$this->ignoredRoutes[$uri] = $routeData;
$this->isDirty = true;
}
/**
* Removes specific HTTP methods from an ignored route.
*
* If all methods are removed, the entire route is removed from
* the ignored list, allowing it to be processed again.
*
* @param non-empty-string $uri
* @param array<int, string> $methods
*/
public function remove(string $uri, array $methods): void
{
if (! isset($this->ignoredRoutes[$uri])) {
return;
}
$this->ignoredRoutes[$uri]['methods'] = array_diff($this->ignoredRoutes[$uri]['methods'], $methods);
if (empty($this->ignoredRoutes[$uri]['methods'])) {
unset($this->ignoredRoutes[$uri]);
}
$this->isDirty = true;
}
/**
* Checks if a route should be ignored during schema extraction.
*
* A route is ignored if any of its HTTP methods are in the ignored list,
* allowing partial ignoring of routes with multiple methods.
*/
public function isIgnored(Route $route): bool
{
$ignoredRoute = $this->ignoredRoutes[$route->uri()] ?? null;
if ($ignoredRoute === null) {
return false;
}
foreach ($route->methods() as $method) {
if (in_array($method, $ignoredRoute['methods'])) {
return true;
}
}
return false;
}
/**
* Checks if there are any ignored routes without loading full data.
*
* Performs a lightweight check to avoid unnecessary file operations
* when no routes are ignored.
*/
public function hasIgnoredRoutes(): bool
{
if ($this->ignoredRoutes !== []) {
return true;
}
try {
$content = File::get(base_path(self::STORAGE_PATH));
} catch (FileNotFoundException) {
return false;
}
if ($content === '[]') {
return false;
}
json_decode($content);
return json_last_error() === JSON_ERROR_NONE;
}
/**
* Loads ignored routes from persistent storage.
*
* Gracefully handles missing or corrupted files by defaulting
* to an empty ignored routes list.
*/
private function loadFromFile(): void
{
$filePath = base_path(self::STORAGE_PATH);
if (! File::exists($filePath)) {
$this->ignoredRoutes = [];
return;
}
$content = File::get($filePath);
$this->ignoredRoutes = json_decode($content, true) ?: [];
}
/**
* Persists ignored routes to storage with automatic directory creation.
*
* Creates the storage directory if it doesn't exist and ensures
* the file is always valid JSON, even if encoding fails.
*/
private function writeToFile(): void
{
$filePath = base_path(self::STORAGE_PATH);
$directory = dirname($filePath);
if (! File::exists($directory)) {
File::makeDirectory($directory, 0755, true);
}
File::put(
$filePath,
json_encode($this->ignoredRoutes, JSON_PRETTY_PRINT) ?: '[]'
);
$this->isDirty = false;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Services\Uri;
class NonVersionedUri implements UriContract
{
/** @var string[] */
private array $parts;
public function __construct(
public string $value,
public string $routesPrefix,
) {
$this->parts = explode('/', $value);
}
public function getVersion(): string
{
return 'n/a';
}
public function getResource(): string
{
// Remove empty parts from the URI and reindex array
$cleanParts = array_values(array_filter($this->parts, fn (string $part): bool => $part !== ''));
$cleanParts = $this->removePrefixIfPresent($cleanParts);
// Extract the resource (first remaining part)
return array_shift($cleanParts) ?? '';
}
/**
* @param string[] $parts
* @return string[]
*/
private function removePrefixIfPresent(array $parts): array
{
if (! filled($this->routesPrefix)) {
return $parts;
}
if ($parts !== [] && $parts[0] === $this->routesPrefix) {
array_shift($parts);
}
return $parts;
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Services\Uri;
interface UriContract
{
public function getVersion(): string;
public function getResource(): string;
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\Services\Uri;
class VersionedUri implements UriContract
{
/** @var string[] */
private array $cleanParts;
public function __construct(
public string $value,
public string $routesPrefix,
) {
$this->cleanParts = $this->parseAndCleanUri();
}
public function getVersion(): string
{
if ($this->cleanParts !== [] && $this->isVersionPart($this->cleanParts[0])) {
return $this->cleanParts[0];
}
return 'v1';
}
public function getResource(): string
{
// If there's a version part, skip it to get the resource
if ($this->cleanParts !== [] && $this->isVersionPart($this->cleanParts[0])) {
return $this->cleanParts[1] ?? '';
}
// If there's no version part, the first part is the resource
return $this->cleanParts[0] ?? '';
}
/**
* @return string[]
*/
private function parseAndCleanUri(): array
{
$parts = explode('/', $this->value);
// Remove empty parts from the URI and reindex array
$cleanParts = array_values(array_filter($parts, fn ($part): bool => $part !== ''));
// Remove the routes prefix if present
if ($this->routesPrefix !== '' && $cleanParts !== [] && $cleanParts[0] === $this->routesPrefix) {
array_shift($cleanParts);
}
return $cleanParts;
}
private function isVersionPart(string $part): bool
{
// Check if the part looks like a version (e.g., v1, v2, 1.0, etc.)
return preg_match('/^v\d+$/', $part) || preg_match('/^\d+\.\d+$/', $part);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\ValueObjects;
use RuntimeException;
use Sunchayn\Nimbus\Modules\Routes\Services\Uri\NonVersionedUri;
use Sunchayn\Nimbus\Modules\Routes\Services\Uri\VersionedUri;
readonly class Endpoint
{
public function __construct(
public string $version,
public string $resource,
public string $value,
) {}
public static function fromRaw(string $uri, string $routesPrefix, bool $isVersioned): self
{
$uriObject = $isVersioned
? new VersionedUri($uri, routesPrefix: $routesPrefix)
: new NonVersionedUri($uri, routesPrefix: $routesPrefix);
return new self(
version: $uriObject->getVersion(),
resource: $uriObject->getResource(),
value: $uri,
);
}
public function getShortUri(): string
{
if ($this->resource === '') {
throw new RuntimeException('Invalid ValueObject. The resource cannot be empty.');
}
$resourcePos = strpos($this->value, '/'.$this->resource);
if ($resourcePos === false) {
throw new RuntimeException('Invalid ValueObject. The `resource` MUST exist in the URI.');
}
// To get the short URL, we remove everything before the resource.
// This will include things like the versioning and the API prefix when provided.
// @example /rest-api/v1/users/{user} -> /users/{user}
// @example users/{user} -> users/{user}
return substr($this->value, $resourcePos);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\ValueObjects;
use Closure;
use PhpParser\Node;
use ReflectionParameter;
class ExtractableRoute
{
/**
* @param ReflectionParameter[] $parameters
* @param Closure() : (Node[]|null) $codeParser
*/
public function __construct(
public readonly array $parameters,
public readonly Closure $codeParser,
public readonly ?string $methodName = null,
public readonly ?string $controllerClass = null,
public readonly ?string $controllerMethod = null,
) {}
public static function empty(): self
{
return new self(
parameters: [],
codeParser: fn (): array => [],
);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Sunchayn\Nimbus\Modules\Routes\ValueObjects;
use Illuminate\Support\Str;
use Throwable;
class RulesExtractionError
{
public function __construct(
private readonly Throwable $throwable,
) {}
public function toHtml(): string
{
$errorMessage = filled($this->throwable->getMessage())
? $this->throwable->getMessage()
: '[no error message]';
$trace = Str::replace("\n", '<br >', $this->throwable->getTraceAsString());
return <<<ERROR_HTML
<b>{$errorMessage}</b><br />
<small>{$this->throwable->getFile()}::{$this->throwable->getLine()}</small>
<p class="text-xs">{$trace}</p>
ERROR_HTML;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\Builders;
use Sunchayn\Nimbus\Modules\Schemas\RulesMapper\RuleToSchemaMapper;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty;
/**
* Converts Laravel validation rules into individual schema properties.
*
* Maps Laravel rule strings like "required|string|max:255" to JSON Schema properties
* with proper type, format, and validation constraints.
*
* @example
* Input: "email" field with "required|email" rules
* Output: SchemaProperty with type="string", format="email", required=true
*
* @phpstan-import-type NormalizedRulesShape from \Sunchayn\Nimbus\Modules\Schemas\Collections\Ruleset
* @phpstan-import-type SchemaPropertyFormatsShape from SchemaProperty
*/
class PropertyBuilder
{
public function __construct(
private readonly RuleToSchemaMapper $ruleToSchemaMapper,
) {}
/**
* @param NormalizedRulesShape $rules
*/
public function buildPropertyFromRules(string $field, array $rules): SchemaProperty
{
$schemaMetadata = $this->ruleToSchemaMapper->convertRulesToBaseSchemaPropertyMetadata($rules);
return new SchemaProperty(
name: $field,
type: $schemaMetadata['type'],
required: $schemaMetadata['required'],
format: $this->extractFormat($rules),
enum: $schemaMetadata['enum'] ?? null,
minimum: $schemaMetadata['minimum'],
maximum: $schemaMetadata['maximum'],
);
}
/**
* @param NormalizedRulesShape $rules
* @return SchemaPropertyFormatsShape|null
*/
private function extractFormat(array $rules): ?string
{
foreach ($rules as $rule) {
if (! is_string($rule)) {
continue;
}
$format = $this->detectFormatFromRule($rule);
if ($format !== null) {
return $format;
}
}
return null;
}
/**
* @return SchemaPropertyFormatsShape|null
*/
private function detectFormatFromRule(string $rule): ?string
{
return match ($rule) {
'email' => 'email',
'uuid' => 'uuid',
'date' => 'date-time',
default => null,
};
}
}

View File

@@ -0,0 +1,304 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\Builders;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\RulesExtractionError;
use Sunchayn\Nimbus\Modules\Schemas\Collections\Ruleset;
use Sunchayn\Nimbus\Modules\Schemas\Enums\RulesFieldType;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\FieldPath;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\PathSegment;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty;
/**
* Converts Laravel validation rules into JSON Schema structures.
*
* Handles three main patterns:
* 1. Root fields: "name" => "required|string"
* 2. Nested objects: "user.profile.name" => "required|string"
* 3. Arrays: "tags.*" => "string" or "users.*.email" => "email"
*
* The builder processes rules in a specific order to ensure parent structures
* exist before children are added.
*
* @phpstan-import-type NormalizedRulesShape from Ruleset
*/
class SchemaBuilder
{
public function __construct(
private readonly PropertyBuilder $propertyBuilder,
) {}
public function buildSchemaFromRuleset(
Ruleset $ruleset,
?RulesExtractionError $rulesExtractionError = null
): Schema {
$properties = $this->buildProperties($ruleset);
return new Schema(
properties: $properties,
extractionError: $rulesExtractionError,
);
}
/**
* @return array<string, SchemaProperty>
*/
private function buildProperties(Ruleset $ruleset): array
{
return $this
// Process in order: root fields → nested objects → arrays
// This ensures parent structures exist before we add children
->sortRulesByProcessingOrder($ruleset)
->reduce(
function (array $properties, array $rules, string $fieldName): array {
$fieldPath = FieldPath::fromString($fieldName);
if ($fieldPath->type === RulesFieldType::ARRAY_OF_PRIMITIVES) {
return $this->addSimpleArrayProperty($fieldPath, $rules, $properties);
}
if ($fieldPath->type === RulesFieldType::DOT_NOTATION) {
return $this->addDotNotationStructure($fieldPath, $rules, $properties);
}
// Builds a root-level property (e.g., "name", "email").
$properties[$fieldName] = $this->propertyBuilder->buildPropertyFromRules($fieldName, $rules);
return $properties;
},
initial: [],
);
}
/**
* Sorts rules by processing order: root → nested → wildcards.
*/
private function sortRulesByProcessingOrder(Ruleset $ruleset): Ruleset
{
return $ruleset
->whereRootField()
->merge(
$ruleset
->whereDotNotationField()
// Sort nested fields by parent fields first,
// this way we make sure we create the parent schema first to keep things simple (relatively).
->sortBy(fn (array $rules, string $field): int => strlen($field))
)
->merge(
$ruleset->whereArrayOfPrimitivesField(),
);
}
/**
* Adds a simple array property (e.g., "tags.*" => "string").
*
* @param array<string, SchemaProperty> $properties
* @param NormalizedRulesShape $rules
* @return array<string, SchemaProperty>
*/
private function addSimpleArrayProperty(FieldPath $fieldPath, array $rules, array $properties): array
{
$arrayName = Str::replaceLast('.*', '', $fieldPath->value);
// Get existing property to preserve 'required' status
$existingProperty = $properties[$arrayName] ?? null;
// Build the item schema (primitive type like string, integer, etc.)
$schemaProperty = $this->propertyBuilder->buildPropertyFromRules(
// Name the items after their array's name but in singular form.
// It is an array of items, e.g. Tags -> each item is a tag.
// Note: this also helps to make the payload generator more realistic in the FE.
field: Str::singular($arrayName),
rules: $rules,
);
$properties[$arrayName] = new SchemaProperty(
name: $arrayName,
type: 'array',
required: $existingProperty->required ?? false,
itemsSchema: $schemaProperty,
);
return $properties;
}
/**
* Adds a dot notation structure (objects, arrays, or both).
*
* Handles both simple dot notation and complex array patterns:
* - "user.profile.name" → nested objects
* - "users.*.email" → array of objects with email property
* - "company.teams.*.members.*.name" → deeply nested arrays
*
* @param array<string, SchemaProperty> $properties
* @param NormalizedRulesShape $rules
* @return array<string, SchemaProperty>
*/
private function addDotNotationStructure(FieldPath $fieldPath, array $rules, array $properties): array
{
$rootField = $fieldPath->getRootField();
$rootProperty = $properties[$rootField] ?? $this->createEmptyObject($rootField);
$properties[$rootField] = $this->buildNestedStructure(
$rootProperty,
segments: $this->parsePathSegments($fieldPath->value),
rules: $rules
);
return $properties;
}
/**
* Parses a field path into typed segments.
*
* Example: "company.teams.*.members.*.name"
* Returns:
* [
* new PathSegment(value: 'teams'),
* new PathSegment(value: '*'),
* new PathSegment(value: 'members'),
* new PathSegment(value: '*'),
* new PathSegment(value: 'name', isLeaf: true),
* ]
*
* @return array<array-key, PathSegment>
*/
private function parsePathSegments(string $path): array
{
$parts = explode('.', $path);
// Remove root field since we handle it separately.
array_shift($parts);
$leafIndex = count($parts) - 1;
return Arr::map(
$parts,
fn (string $part, $index): PathSegment => new PathSegment(value: $part, isLeaf: $index === $leafIndex)
);
}
/**
* Recursively builds nested array/object structures.
*
* @param NormalizedRulesShape $rules
* @param PathSegment[] $segments
*/
private function buildNestedStructure(
SchemaProperty $schemaProperty,
array $segments,
array $rules
): SchemaProperty {
if ($segments === []) {
return $schemaProperty;
}
$pathSegment = array_shift($segments);
if ($pathSegment->isArray()) {
return $this->convertPropertyToArray($schemaProperty, $segments, $rules);
}
return $this->addPropertyToStructure($schemaProperty, $pathSegment, $segments, $rules);
}
/**
* Converts a property to an array type and processes remaining segments as array items.
*
* @param NormalizedRulesShape $rules
* @param PathSegment[] $segments
*/
private function convertPropertyToArray(
SchemaProperty $schemaProperty,
array $segments,
array $rules
): SchemaProperty {
$itemObject = $schemaProperty->itemsSchema ?? $this->createEmptyObject(name: 'item');
// Build the item structure from remaining segments.
$itemSchema = $this->buildNestedStructure($itemObject, $segments, $rules);
return new SchemaProperty(
name: $schemaProperty->name,
type: 'array',
required: $schemaProperty->required,
itemsSchema: $itemSchema,
);
}
/**
* Adds a property to the current structure (object).
*
* @param NormalizedRulesShape $rules
* @param PathSegment[] $remainingSegments
*/
private function addPropertyToStructure(
SchemaProperty $schemaProperty,
PathSegment $pathSegment,
array $remainingSegments,
array $rules
): SchemaProperty {
$propertyName = $pathSegment->value;
$existingSchema = $schemaProperty->propertiesSchema ?? new Schema([]);
/** @var Collection<string, SchemaProperty> $properties */
$properties = Collection::make($existingSchema->properties)->keyBy('name');
// If this is a leaf, build the final property with rules.
if ($pathSegment->isLeaf) {
$newProperty = $this->propertyBuilder->buildPropertyFromRules($propertyName, $rules);
$properties->put($newProperty->name, $newProperty);
return $this->rebuildObjectPropertyWithNewSchema($schemaProperty, $properties->all());
}
// Otherwise, create/get intermediate object and recurse.
$existingProperty = $properties->get($propertyName);
$intermediateProperty = $existingProperty ?? $this->createEmptyObject($propertyName);
$updatedProperty = $this->buildNestedStructure($intermediateProperty, $remainingSegments, $rules);
$properties->put($updatedProperty->name, $updatedProperty);
return $this->rebuildObjectPropertyWithNewSchema($schemaProperty, $properties->all());
}
/**
* Rebuilds a property with updated child properties.
*
* @param SchemaProperty[] $properties
*/
private function rebuildObjectPropertyWithNewSchema(
SchemaProperty $schemaProperty,
array $properties
): SchemaProperty {
return new SchemaProperty(
name: $schemaProperty->name,
type: 'object',
required: $schemaProperty->required,
format: $schemaProperty->format,
enum: $schemaProperty->enum,
propertiesSchema: new Schema($properties),
);
}
/**
* Creates an empty object property.
*/
private function createEmptyObject(string $name): SchemaProperty
{
return new SchemaProperty(
name: $name,
type: 'object',
required: false,
propertiesSchema: new Schema([])
);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\Collections;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
use InvalidArgumentException;
use Sunchayn\Nimbus\Modules\Schemas\Enums\RulesFieldType;
use Sunchayn\Nimbus\Modules\Schemas\ValueObjects\FieldPath;
/**
* Represents a normalized set of Laravel validation rules.
*
* Handles conversion from various input formats (string, array) to a consistent
* array format for processing throughout the schema building pipeline.
*
* @example
* Input: "required|string|max:255"
* Output: ["required", "string", "max:255"]
*
* @phpstan-type NormalizedRulesShape array<array-key, string|Rule>
*
* @extends Collection<string, NormalizedRulesShape>
*/
class Ruleset extends Collection
{
public function __construct($items = [])
{
parent::__construct($items);
if (! $this->every(fn (mixed $item): true => is_array($item))) { // @phpstan-ignore-line this is a runtime check.
throw new InvalidArgumentException('Ruleset items must be an array');
}
}
/**
* Creates a Ruleset from various input formats.
*
* Handles both string format ("required|string|max:255") and array format
* to ensure consistent processing throughout the schema builder.
*
* @param array<string, mixed> $rules
*/
public static function fromLaravelRules(array $rules): self
{
$normalized = array_map(
function (mixed $fieldRules): array {
// Convert pipe-separated string to array
if (is_string($fieldRules)) {
return array_values(
array_filter(explode('|', $fieldRules)),
);
}
// Return array as-is
if (is_array($fieldRules)) {
return $fieldRules;
}
// Wrap objets in arrays, these can be Rules.
if (is_object($fieldRules)) {
return [$fieldRules];
}
// If unknown, return an empty array.
return [];
},
$rules,
);
return new self($normalized);
}
public function whereRootField(): self
{
return $this->where(fn (mixed $_, string $field): bool => FieldPath::fromString($field)->type === RulesFieldType::ROOT);
}
public function whereDotNotationField(): self
{
return $this->where(function (mixed $_, string $field): bool {
$fieldPath = FieldPath::fromString($field);
return $fieldPath->type === RulesFieldType::DOT_NOTATION;
});
}
public function whereArrayOfPrimitivesField(): self
{
return $this->where(fn (mixed $_, string $field): bool => FieldPath::fromString($field)->type === RulesFieldType::ARRAY_OF_PRIMITIVES);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\Enums;
enum RulesFieldType
{
/** @example email */
case ROOT;
/** @example person.email */
/** @example persons.*.email */
case DOT_NOTATION;
/** @example items.* */
case ARRAY_OF_PRIMITIVES;
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\RulesMapper\Processors;
use BackedEnum;
use Illuminate\Validation\Rules\Enum;
use UnitEnum;
/**
* Processes `Enum` validation rules to extract enum values for schema generation.
*
* @phpstan-import-type SchemaPropertyEnumShape from \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty
*/
class EnumRuleProcessor
{
/**
* @return array{type: 'string', enum: SchemaPropertyEnumShape|null}
*/
public static function process(Enum $rule): array
{
/** @var class-string<UnitEnum> $enumClass */
$enumClass = invade($rule)->type; // @phpstan-ignore-line
if (! enum_exists($enumClass)) {
return ['type' => 'string', 'enum' => null];
}
$values = array_map(
fn (UnitEnum|BackedEnum $enum): int|string => $enum->value ?? $enum->name,
$enumClass::cases()
);
if ($values === []) {
return ['type' => 'string', 'enum' => null];
}
return ['type' => 'string', 'enum' => $values];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\RulesMapper\Processors;
use BackedEnum;
use Illuminate\Validation\Rules\In;
use UnitEnum;
/**
* Processes `In` validation rules to extract allowed values for schema generation.
*
* @phpstan-import-type SchemaPropertyTypesShape from \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty
* @phpstan-import-type SchemaPropertyEnumShape from \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty
*/
class InRuleProcessor
{
/**
* @return array{type: 'string'|'integer', enum: SchemaPropertyEnumShape|null}
*/
public static function process(In $in): array
{
/** @var array<array-key, scalar|object> $rawValues */
$rawValues = invade($in)->values; // @phpstan-ignore-line
// Normalize the values into primitives.
$values = array_map(
fn (bool|float|int|object|string $value): float|bool|int|string|null => match (true) {
is_scalar($value) => $value,
$value instanceof BackedEnum => $value->value,
$value instanceof UnitEnum => $value->name,
default => null,
},
$rawValues,
);
/** @var array<array-key, scalar> $values */
$values = array_values(
array_filter($values), // <- Removes null values.
);
if (empty($values)) {
return ['type' => 'string', 'enum' => null];
}
$identityValue = $values[0];
$type = match (true) {
is_int($identityValue) => 'integer',
default => 'string',
};
return ['type' => $type, 'enum' => $values];
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\RulesMapper;
use Illuminate\Validation\Rules\Enum;
use Illuminate\Validation\Rules\In;
use Illuminate\Validation\ValidationRuleParser;
use Sunchayn\Nimbus\Modules\Schemas\RulesMapper\Processors\EnumRuleProcessor;
use Sunchayn\Nimbus\Modules\Schemas\RulesMapper\Processors\InRuleProcessor;
/**
* Converts Laravel validation rules into JSON Schema property definitions.
*
* @phpstan-import-type NormalizedRulesShape from \Sunchayn\Nimbus\Modules\Schemas\Collections\Ruleset
* @phpstan-import-type SchemaPropertyTypesShape from \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty
* @phpstan-import-type SchemaPropertyFormatsShape from \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty
* @phpstan-import-type SchemaPropertyEnumShape from \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty
*/
class RuleToSchemaMapper
{
/**
* Converts an array of Laravel validation rules into schema property data.
*
* Laravel's validation rules are processed sequentially, with later rules
* potentially overriding earlier ones (e.g., 'string' then 'email').
*
* @param NormalizedRulesShape $rules
* @return array{
* type: SchemaPropertyTypesShape,
* required: bool,
* format: SchemaPropertyFormatsShape|null,
* enum: SchemaPropertyEnumShape|null,
* minimum: ?int,
* maximum: ?int,
* }
*/
public function convertRulesToBaseSchemaPropertyMetadata(array $rules): array
{
$shape = [
'type' => 'string',
'required' => false,
'format' => null,
'enum' => null,
'minimum' => null,
'maximum' => null,
];
foreach ($rules as $rule) {
$ruleSpecificUpdates = $this->processRule($rule);
// Amend the original shape to add the changes specific to the rule in hand.
$shape = array_merge($shape, $ruleSpecificUpdates);
}
return $shape;
}
/**
* Processes individual validation rules and returns the changes to apply.
*
* @return array{}|array{
* type?: SchemaPropertyTypesShape,
* format?: SchemaPropertyFormatsShape,
* enum?: SchemaPropertyEnumShape|null,
* minimum?: int,
* maximum?: int,
* }
*/
private function processRule(mixed $rule): array
{
if (is_object($rule)) {
return $this->processObjectRule($rule);
}
if (! is_scalar($rule)) {
return [];
}
[$name, $params] = ValidationRuleParser::parse((string) $rule);
$ruleName = strtolower($name);
return match ($ruleName) {
'required' => ['required' => true],
'string' => ['type' => 'string'],
'integer' => ['type' => 'integer'],
'numeric' => ['type' => 'number'],
'boolean' => ['type' => 'boolean'],
'array' => ['type' => 'array'],
'email' => $this->setFormat('email'),
'uuid' => $this->setFormat('uuid'),
'date' => $this->setFormat('date-time'),
'in' => $this->setEnum($params),
'min' => ['minimum' => $params[0] ?? null],
'max' => ['maximum' => $params[0] ?? null],
'size' => ['minimum' => $params[0] ?? null, 'maximum' => $params[0] ?? null],
default => [],
};
}
/**
* Handles custom validation rule objects.
*
* Analyzes specific rule types like Enum and In to extract constraint
* information, falling back to string type for unknown rules.
*
* @return array{type: 'integer'|'string', enum?: SchemaPropertyEnumShape|null}
*/
private function processObjectRule(object $rule): array
{
return match (true) {
$rule instanceof Enum => EnumRuleProcessor::process($rule),
$rule instanceof In => InRuleProcessor::process($rule),
default => ['type' => 'string'],
};
}
/**
* Sets the format specification for the property.
*
* @param SchemaPropertyFormatsShape $format
* @return array{format: SchemaPropertyFormatsShape, type?: 'string'}
*/
private function setFormat(string $format): array
{
$result = ['format' => $format];
// Email, UUID, and date-time are all string-based formats in JSON Schema
if (in_array($format, ['email', 'uuid', 'date-time'], true)) {
$result['type'] = 'string';
}
return $result;
}
/**
* Sets enum constraints from validation rule parameters.
*
* The 'in' rule provides explicit allowed values that must be preserved
* in the schema for proper validation.
*
* @param array<int, mixed> $params
* @return array{enum: SchemaPropertyEnumShape}|array{}
*/
private function setEnum(array $params): array
{
if ($params === []) {
return [];
}
return ['enum' => $params];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\ValueObjects;
use Sunchayn\Nimbus\Modules\Schemas\Enums\RulesFieldType;
/**
* Represents a field path in Laravel validation rules.
*
* Handles parsing, validation, and type detection for field paths
* like "user.profile.age" or "tags.*".
*/
readonly class FieldPath
{
/** @var string[] */
public array $segments;
public function __construct(
public string $value,
public RulesFieldType $type,
) {
$this->segments = explode('.', $this->value);
}
public static function fromString(string $field): self
{
$type = match (true) {
str_ends_with($field, '.*') => RulesFieldType::ARRAY_OF_PRIMITIVES,
str_contains($field, '.') => RulesFieldType::DOT_NOTATION,
default => RulesFieldType::ROOT,
};
return new self(
value: $field,
type: $type,
);
}
public function getRootField(): string
{
return $this->segments[0];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\ValueObjects;
class PathSegment
{
public function __construct(
public readonly string $value,
public readonly bool $isLeaf = true,
) {}
public function isArray(): bool
{
return $this->value === '*';
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\ValueObjects;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Arr;
use Sunchayn\Nimbus\Modules\Routes\ValueObjects\RulesExtractionError;
/**
* @phpstan-import-type SchemaPropertyShape from SchemaProperty
*
* @phpstan-type SchemaShape array{
* '$schema': 'https://json-schema.org/draft/2020-12/schema',
* type: 'object',
* properties: array<string, SchemaPropertyShape>,
* required: string[],
* additionalProperties: false,
* }
*
* @implements Arrayable<string, SchemaPropertyShape>
*/
class Schema implements Arrayable
{
/** @var SchemaProperty[] */
public readonly array $properties;
/**
* @param SchemaProperty[] $properties
*/
public function __construct(
array $properties,
public readonly ?RulesExtractionError $extractionError = null,
) {
$this->properties = array_values($properties);
}
public static function empty(): self
{
return new self(
properties: [],
);
}
public function isEmpty(): bool
{
return $this->properties === [];
}
/**
* @return string[]
*/
public function getRequiredProperties(): array
{
return collect($this->properties)
->filter(fn (SchemaProperty $schemaProperty): bool => $schemaProperty->required)
->map(fn (SchemaProperty $schemaProperty): string => $schemaProperty->name)
->values()
->all();
}
public function toArray(): array
{
return Arr::mapWithKeys(
$this->properties,
fn (SchemaProperty $schemaProperty): array => [
$schemaProperty->name => $schemaProperty->toArray(),
]
);
}
/**
* Converts this schema to proper JSON Schema format.
*
* Generates a complete JSON Schema object with all necessary metadata
* that can be used directly by JSON Schema validators and editors.
*
* @return SchemaShape
*/
public function toJsonSchema(): array
{
return [
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'type' => 'object',
'properties' => $this->toArray(),
'required' => $this->getRequiredProperties(),
'additionalProperties' => false,
];
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace Sunchayn\Nimbus\Modules\Schemas\ValueObjects;
use Illuminate\Support\Arr;
// TODO [Refactor] Refactor this into specialized classes with proper support for JSONSchema.
// E.g. IntegerSchemaProperty, ObjectSchemaProperty, etc.
// Most likely its own package.
/**
* @phpstan-type SchemaPropertyTypesShape 'number'|'integer'|'array'|'object'|'string'|'boolean'
* @phpstan-type SchemaPropertyFormatsShape 'uuid'|'email'|'date-time'
* @phpstan-type SchemaPropertyEnumShape array<array-key, scalar>
* TODO [Documentation] Figure out how to annotate the `items` and `properties` recursively.`
* @phpstan-type SchemaPropertyShape array{
* type: SchemaPropertyTypesShape,
* x-name: string,
* x-required: bool,
* format?: SchemaPropertyFormatsShape,
* enum?: SchemaPropertyEnumShape,
* items?: array<array-key, mixed>,
* properties?: array<string, array<array-key, mixed>>,
* required?: bool,
* minLength?: int,
* minimum?: int,
* maxLength?: int,
* maximum?: int,
* }
*/
class SchemaProperty
{
/**
* @param SchemaPropertyTypesShape $type
* @param SchemaPropertyFormatsShape|null $format
* @param SchemaPropertyEnumShape|null $enum Allowed enum values.
*/
public function __construct(
public readonly string $name,
public readonly string $type = 'string', // <- Make this an enum.
public readonly bool $required = false,
public readonly ?string $format = null, // <- Make this an enum.
public readonly ?array $enum = null,
public readonly ?SchemaProperty $itemsSchema = null, // <- For arrays
public readonly ?Schema $propertiesSchema = null, // <- For objects
public readonly ?int $minimum = null,
public readonly ?int $maximum = null,
) {}
/**
* @return SchemaPropertyShape
*/
public function toArray(): array
{
$result = [
'type' => $this->type,
];
if ($this->format !== null) {
$result['format'] = $this->format;
}
if ($this->enum !== null && $this->enum !== []) {
$result['enum'] = $this->enum;
}
if ($this->itemsSchema instanceof \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\SchemaProperty) {
$result['items'] = $this->itemsSchema->toArray();
}
if ($this->propertiesSchema instanceof \Sunchayn\Nimbus\Modules\Schemas\ValueObjects\Schema) {
$result['properties'] = Arr::mapWithKeys(
$this->propertiesSchema->properties,
fn (SchemaProperty $schemaProperty): array => [$schemaProperty->name => $schemaProperty->toArray()]
);
$result['required'] = $this->propertiesSchema->getRequiredProperties();
}
if ($this->minimum !== null && in_array($this->type, ['string', 'integer', 'number'])) {
$minPropertyName = $this->type === 'string' ? 'minLength' : 'minimum';
$result[$minPropertyName] = $this->minimum;
}
if ($this->maximum !== null && in_array($this->type, ['string', 'integer', 'number'])) {
$minPropertyName = $this->type === 'string' ? 'maxLength' : 'maximum';
$result[$minPropertyName] = $this->maximum;
}
/**
* @var SchemaPropertyShape $final PHPStan couldn't infer the correct type when building the array incrementally.
*/
$final = array_merge(
$result,
// To make dealing with props easier, for instance, for payload generation.
// We also add a couple of custom properties to the schema.
$this->getCustomProperties(),
);
return $final;
}
/**
* @return array{
* x-name: string,
* x-required: bool,
* }
*/
private function getCustomProperties(): array
{
return [
// Keep in mind: the `required` property is reserved for objects to tell what properties are required in the object.
// this custom property, on the other hand, is to tell if the current property is required or not.
'x-required' => $this->required,
'x-name' => $this->name,
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Sunchayn\Nimbus;
use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider;
use Sunchayn\Nimbus\Modules\Routes\Services\IgnoredRoutesService;
class NimbusServiceProvider extends PackageServiceProvider
{
private bool $enabled;
public function __construct($app)
{
parent::__construct($app);
$this->enabled = $this->app->environment(config('nimbus.allowed_envs', ['testing', 'local', 'staging']));
}
/**
* @see https://github.com/spatie/laravel-package-tools
*/
public function configurePackage(Package $package): void
{
$package
->name('nimbus')
->hasConfigFile()
->hasViews('nimbus')
->hasAssets()
->hasRoutes(['api', 'web']);
}
public function register(): void
{
if (! $this->enabled) {
$this->registerPackageConfigs();
return;
}
parent::register();
$this->app->singleton(
IgnoredRoutesService::class,
fn (): \Sunchayn\Nimbus\Modules\Routes\Services\IgnoredRoutesService => new IgnoredRoutesService,
);
}
public function boot(): void
{
if (! $this->enabled) {
return;
}
parent::boot();
}
}