refactor: adopt native PHP 8.5 \Uri\Rfc3986\Uri for request URIs and pagination links

This commit is contained in:
Jérémy Munsch 2026-08-17 09:30:21 +02:00
parent 7c0edb9507
commit ac552a4530
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
14 changed files with 76 additions and 29 deletions

View file

@ -7,6 +7,7 @@ namespace Infra\Http\Helpers;
use Illuminate\Http\Request as HttpRequest;
use League\Uri\Http;
use Shared\Pagination;
use Uri\Rfc3986\Uri;
final class RequestHelper
{
@ -35,6 +36,16 @@ final class RequestHelper
);
}
public static function toUri(HttpRequest $request): Uri
{
return new Uri($request->url());
}
public static function toFullUri(HttpRequest $request): Uri
{
return new Uri($request->fullUrl());
}
public static function toLeagueUri(HttpRequest $request): Http
{
return Http::new($request->url());

View file

@ -16,6 +16,7 @@ use Illuminate\Support\ServiceProvider;
use Infra\Http\Helpers\RequestHelper;
use League\Uri\Http;
use Shared\Pagination;
use Uri\Rfc3986\Uri;
class AppServiceProvider extends ServiceProvider
{
@ -56,6 +57,16 @@ class AppServiceProvider extends ServiceProvider
return RequestHelper::pagination($this, $defaultPageSize);
});
Request::macro('toUri', function (): Uri {
/** @var \Illuminate\Http\Request $this */
return RequestHelper::toUri($this);
});
Request::macro('toFullUri', function (): Uri {
/** @var \Illuminate\Http\Request $this */
return RequestHelper::toFullUri($this);
});
Request::macro('toLeagueUri', function (): Http {
/** @var \Illuminate\Http\Request $this */
return RequestHelper::toLeagueUri($this);

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Presentation\Api\V1\Dto;
use League\Uri\Http;
use Presentation\Api\V1\Contracts\ApiResponseDto;
use Uri\Rfc3986\Uri;
final readonly class PaginationDto implements ApiResponseDto
{
@ -21,15 +21,15 @@ final readonly class PaginationDto implements ApiResponseDto
) {
}
public static function create(int $page, int $pageSize, int $totalItems, Http $baseUri): self
public static function create(int $page, int $pageSize, int $totalItems, Uri $baseUri): self
{
$totalPages = $pageSize > 0 ? (int) ceil($totalItems / $pageSize) : 1;
$firstUrl = (string) $baseUri->withQuery(http_build_query(['page' => 1, 'pageSize' => $pageSize]));
$lastUrl = (string) $baseUri->withQuery(http_build_query(['page' => $totalPages, 'pageSize' => $pageSize]));
$selfUrl = (string) $baseUri->withQuery(http_build_query(['page' => $page, 'pageSize' => $pageSize]));
$prevUrl = $page > 1 ? (string) $baseUri->withQuery(http_build_query(['page' => $page - 1, 'pageSize' => $pageSize])) : null;
$nextUrl = $page < $totalPages ? (string) $baseUri->withQuery(http_build_query(['page' => $page + 1, 'pageSize' => $pageSize])) : null;
$firstUrl = $baseUri->withQuery(http_build_query(['page' => 1, 'pageSize' => $pageSize]))->toString();
$lastUrl = $baseUri->withQuery(http_build_query(['page' => $totalPages, 'pageSize' => $pageSize]))->toString();
$selfUrl = $baseUri->withQuery(http_build_query(['page' => $page, 'pageSize' => $pageSize]))->toString();
$prevUrl = $page > 1 ? $baseUri->withQuery(http_build_query(['page' => $page - 1, 'pageSize' => $pageSize]))->toString() : null;
$nextUrl = $page < $totalPages ? $baseUri->withQuery(http_build_query(['page' => $page + 1, 'pageSize' => $pageSize]))->toString() : null;
return new self(
page: $page,

View file

@ -10,10 +10,10 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Validation\ValidationException;
use League\Uri\Http;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Throwable;
use Uri\Rfc3986\Uri;
final readonly class ApiExceptionRenderer
{
@ -21,11 +21,11 @@ final readonly class ApiExceptionRenderer
public function render(Throwable $e, Request $request): JsonResponse
{
$baseUri = $request->toLeagueUri();
$baseUri = $request->toUri();
return match (true) {
$e instanceof NotFoundException => response()->json([
'type' => (string) $baseUri->withPath('/errors/404'),
'type' => $baseUri->withPath('/errors/404')->toString(),
'title' => 'Resource Not Found',
'status' => 404,
'detail' => $e->getMessage(),
@ -33,7 +33,7 @@ final readonly class ApiExceptionRenderer
], 404, ['Content-Type' => self::CONTENT_TYPE]),
$e instanceof ValidationException => response()->json([
'type' => (string) $baseUri->withPath('/errors/422'),
'type' => $baseUri->withPath('/errors/422')->toString(),
'title' => 'Unprocessable Entity',
'status' => 422,
'detail' => $e->getMessage(),
@ -59,7 +59,7 @@ final readonly class ApiExceptionRenderer
], 422, ['Content-Type' => self::CONTENT_TYPE]),
$e instanceof \DomainException => response()->json([
'type' => (string) $baseUri->withPath('/errors/422'),
'type' => $baseUri->withPath('/errors/422')->toString(),
'title' => 'Unprocessable Entity',
'status' => 422,
'detail' => $e->getMessage(),
@ -73,7 +73,7 @@ final readonly class ApiExceptionRenderer
], 422, ['Content-Type' => self::CONTENT_TYPE]),
$e instanceof ThrottleRequestsException => response()->json([
'type' => (string) $baseUri->withPath('/errors/429'),
'type' => $baseUri->withPath('/errors/429')->toString(),
'title' => 'Too Many Requests',
'status' => 429,
'detail' => $e->getMessage(),
@ -84,7 +84,7 @@ final readonly class ApiExceptionRenderer
};
}
private function renderDefault(Throwable $e, Request $request, Http $baseUri): JsonResponse
private function renderDefault(Throwable $e, Request $request, Uri $baseUri): JsonResponse
{
$status = $e instanceof HttpExceptionInterface ? $e->getStatusCode() : 500;
$title = Response::$statusTexts[$status] ?? 'Internal Server Error';
@ -100,7 +100,7 @@ final readonly class ApiExceptionRenderer
$headers['Content-Type'] = self::CONTENT_TYPE;
return response()->json([
'type' => (string) $baseUri->withPath('/errors/' . $status),
'type' => $baseUri->withPath('/errors/' . $status)->toString(),
'title' => $title,
'status' => $status,
'detail' => $detail,

View file

@ -33,7 +33,7 @@ final readonly class EmployeesIndexController
->with($this->presenter)
->run();
return new EmployeesIndexResponse($response, $request->toLeagueUri());
return new EmployeesIndexResponse($response, $request->toUri());
}
private function inputFrom(EmployeesIndexRequest $request): Input

View file

@ -33,7 +33,7 @@ final readonly class IncoSheetsIndexController
->with($this->presenter)
->run();
return new IncoSheetsIndexResponse($response, $request->toLeagueUri());
return new IncoSheetsIndexResponse($response, $request->toUri());
}
private function inputFrom(IncoSheetsIndexRequest $request): Input

View file

@ -34,7 +34,7 @@ final readonly class IncoSheetsSearchController
return new IncoSheetsSearchResponse(
view: $view,
baseUri: $request->toLeagueUri(),
baseUri: $request->toUri(),
);
}

View file

@ -14,7 +14,7 @@ use Shared\Pagination;
/**
* @method \Shared\Pagination pagination(int $defaultPageSize = 25)
* @method \League\Uri\Http toLeagueUri()
* @method \Uri\Rfc3986\Uri toUri()
*/
final class IncoSheetsIndexRequest extends FormRequest
{

View file

@ -9,7 +9,7 @@ use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* @method \League\Uri\Http toLeagueUri()
* @method \Uri\Rfc3986\Uri toUri()
*/
final class IncoSheetsSearchRequest extends FormRequest
{

View file

@ -6,17 +6,17 @@ namespace Presentation\Api\V1\Http\Responses\Employees;
use Application\Usecase\Employees\FindAll\PaginatedEmployee;
use Illuminate\Http\JsonResponse;
use League\Uri\Http;
use Presentation\Api\V1\Contracts\ApiResponse;
use Presentation\Api\V1\Dto\Employees\PaginatedEmployee as PaginatedEmployeeDto;
use Presentation\Api\V1\Dto\PaginationDto;
use Presentation\Api\V1\Presenters\Employees\EmployeesIndexView;
use Uri\Rfc3986\Uri;
final readonly class EmployeesIndexResponse implements ApiResponse
{
public function __construct(
private EmployeesIndexView $view,
private Http $baseUri,
private Uri $baseUri,
) {
}

View file

@ -9,7 +9,6 @@ use Application\Usecase\IncoSheets\FindAll\PaginatedShortIncoSheet;
use Domain\Models\ValueObjects\Inco\Additive;
use Domain\Models\ValueObjects\Inco\Allergen;
use Illuminate\Http\JsonResponse;
use League\Uri\Http;
use Presentation\Api\V1\Contracts\ApiResponse;
use Presentation\Api\V1\Dto\IncoSheets\IncoSheetDto;
use Presentation\Api\V1\Dto\IncoSheets\IncoShortSheetDto;
@ -23,12 +22,13 @@ use Presentation\Api\V1\Dto\IncoSheets\Parts\StorageConditionDto;
use Presentation\Api\V1\Dto\IncoSheets\Parts\WeightDto;
use Presentation\Api\V1\Dto\PaginationDto;
use Presentation\Api\V1\Presenters\IncoSheets\IncoSheetsIndexView;
use Uri\Rfc3986\Uri;
final readonly class IncoSheetsIndexResponse implements ApiResponse
{
public function __construct(
private IncoSheetsIndexView $view,
private Http $baseUri,
private Uri $baseUri,
) {
}

View file

@ -9,7 +9,6 @@ use Application\Usecase\IncoSheets\Search\PaginatedShortIncoSheet;
use Domain\Models\ValueObjects\Inco\Additive;
use Domain\Models\ValueObjects\Inco\Allergen;
use Illuminate\Http\JsonResponse;
use League\Uri\Http;
use Presentation\Api\V1\Contracts\ApiResponse;
use Presentation\Api\V1\Dto\IncoSheets\IncoSheetDto;
use Presentation\Api\V1\Dto\IncoSheets\IncoShortSheetDto;
@ -23,12 +22,13 @@ use Presentation\Api\V1\Dto\IncoSheets\Parts\StorageConditionDto;
use Presentation\Api\V1\Dto\IncoSheets\Parts\WeightDto;
use Presentation\Api\V1\Dto\PaginationDto;
use Presentation\Api\V1\Presenters\IncoSheets\IncoSheetsSearchView;
use Uri\Rfc3986\Uri;
final readonly class IncoSheetsSearchResponse implements ApiResponse
{
public function __construct(
private IncoSheetsSearchView $view,
private Http $baseUri,
private Uri $baseUri,
) {
}

View file

@ -8,6 +8,7 @@ use Illuminate\Http\Request as HttpRequest;
use Infra\Http\Helpers\RequestHelper;
use League\Uri\Http;
use Shared\Pagination;
use Uri\Rfc3986\Uri;
covers(RequestHelper::class);
@ -90,6 +91,30 @@ describe('RequestHelper Unit Tests', function (): void {
->and($pagination->to())->toBe(24);
});
it('converts request URL to native RFC 3986 Uri instance', function (): void {
// GIVEN
$request = HttpRequest::create('http://localhost/api/v1/employees?foo=bar', 'GET');
// WHEN
$uri = RequestHelper::toUri($request);
// THEN
expect($uri)->toBeInstanceOf(Uri::class)
->and($uri->toString())->toBe('http://localhost/api/v1/employees');
});
it('converts full request URL with query parameters to native RFC 3986 Uri instance', function (): void {
// GIVEN
$request = HttpRequest::create('http://localhost/api/v1/employees?page=2&pageSize=10', 'GET');
// WHEN
$uri = RequestHelper::toFullUri($request);
// THEN
expect($uri)->toBeInstanceOf(Uri::class)
->and($uri->toString())->toBe('http://localhost/api/v1/employees?page=2&pageSize=10');
});
it('converts request URL to League Uri instance', function (): void {
// GIVEN
$request = HttpRequest::create('http://localhost/api/v1/employees?foo=bar', 'GET');

View file

@ -4,15 +4,15 @@ declare(strict_types=1);
namespace Tests\Unit\Presentation\Api\V1\Dto;
use League\Uri\Http;
use Presentation\Api\V1\Dto\PaginationDto;
use Uri\Rfc3986\Uri;
covers(PaginationDto::class);
describe('PaginationDto Unit Tests', function (): void {
it('creates pagination metadata and links correctly', function (): void {
// GIVEN
$baseUri = Http::new('http://localhost/api/v1/employees');
$baseUri = new Uri('http://localhost/api/v1/employees');
// WHEN
$dto = PaginationDto::create(
@ -36,7 +36,7 @@ describe('PaginationDto Unit Tests', function (): void {
it('handles first page correctly with null prev link', function (): void {
// GIVEN
$baseUri = Http::new('http://localhost/api/v1/employees');
$baseUri = new Uri('http://localhost/api/v1/employees');
// WHEN
$dto = PaginationDto::create(