feat(api): implement API response transformation middleware

- Added TransformApiV1Response middleware to handle API responses.
- Updated Employee and IncoSheets controllers to return ApiResponse.
- Refactored API routes to use new middleware for v1.
This commit is contained in:
Jérémy Munsch 2026-08-14 11:50:12 +02:00
parent 3c72e50adf
commit 7658483863
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
19 changed files with 131 additions and 69 deletions

View file

@ -3,17 +3,18 @@
declare(strict_types=1);
use Illuminate\Foundation\Configuration\Middleware;
use Presentation\Api\V1\Http\Middleware\TransformApiV1Response;
return function (Middleware $middleware): void {
$middleware->web(append: [
'throttle:web',
]);
$middleware->api(append: [
'throttle:api.v1',
]);
$middleware->api(append: []);
$middleware->alias([
$middleware->group('api.v1', [
'throttle:api.v1',
TransformApiV1Response::class,
]);
$middleware->preventRequestsDuringMaintenance(except: [

View file

@ -19,6 +19,9 @@ Route::get(
Route::get('/ping', fn () => response()->json('pong'))->name('api.ping');
// * V1 API
Route::prefix('v1')->name('api.v1.')->group(function () {
require __DIR__ . '/apiv1.php';
});
Route::prefix('v1')
->name('api.v1.')
->middleware('api.v1')
->group(function (): void {
require __DIR__ . '/apiv1.php';
});

View file

@ -11,7 +11,7 @@ use Shared\DDD\Usecase\Input;
use Shared\DDD\Usecase\Presenter;
use Shared\DDD\Usecase\Usecase;
/** @implements UsecaseExecutor<Input,\Shared\DDD\Usecase\Output> */
/** @implements UsecaseExecutor<Input, \Shared\DDD\Usecase\Output> */
final readonly class LaravelExecutor implements UsecaseExecutor
{
public function __construct(
@ -21,8 +21,11 @@ final readonly class LaravelExecutor implements UsecaseExecutor
}
/**
* @param Usecase<Input,\Shared\DDD\Usecase\Output> $usecase
* @param Presenter<\Shared\DDD\Usecase\Output,object> $presenter
* @template TView of object
*
* @param Usecase<Input, \Shared\DDD\Usecase\Output> $usecase
* @param Presenter<\Shared\DDD\Usecase\Output, TView> $presenter
* @return TView
*/
#[\Override]
public function execute(Usecase $usecase, Input $input, Presenter $presenter): object
@ -36,7 +39,7 @@ final readonly class LaravelExecutor implements UsecaseExecutor
$this->logger->debug('Usecase execution started', $context);
try {
/** @var object $result */
/** @var TView $result */
$result = $this->database->transaction(function () use ($usecase, $input, $presenter): object {
$output = $usecase->execute($input);

View file

@ -4,9 +4,10 @@ declare(strict_types=1);
namespace Presentation\Api\V1\Contracts;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\JsonResponse;
interface ApiResponse
interface ApiResponse extends Responsable
{
public function present(): JsonResponse;
}

View file

@ -6,7 +6,7 @@ namespace Presentation\Api\V1\Http\Controllers\Employees;
use Application\Usecase\Employees\Find\FindEmployee;
use Application\Usecase\Employees\Find\Input;
use Illuminate\Http\JsonResponse;
use Presentation\Api\V1\Contracts\ApiResponse;
use Presentation\Api\V1\Http\Requests\Employees\EmployeeShowRequest;
use Presentation\Api\V1\Http\Responses\Employees\EmployeeShowResponse;
use Presentation\Api\V1\Presenters\Employees\EmployeeShowPresenter;
@ -23,17 +23,15 @@ final readonly class EmployeeShowController
) {
}
public function __invoke(EmployeeShowRequest $request): JsonResponse
public function __invoke(EmployeeShowRequest $request): ApiResponse
{
/** @var \Presentation\Api\V1\Presenters\Employees\EmployeeShowView $response */
$response = UsecaseBuilder::using($this->executor)
->do($this->usecase)
->from($this->toInput($request))
->with($this->presenter)
->run();
return (new EmployeeShowResponse($response))->present();
return new EmployeeShowResponse($response);
}
private function toInput(EmployeeShowRequest $request): Input

View file

@ -6,7 +6,7 @@ namespace Presentation\Api\V1\Http\Controllers\Employees;
use Application\Usecase\Employees\FindAll\FindAllEmployees;
use Application\Usecase\Employees\FindAll\Input;
use Illuminate\Http\JsonResponse;
use Presentation\Api\V1\Contracts\ApiResponse;
use Presentation\Api\V1\Http\Requests\Employees\EmployeesIndexRequest;
use Presentation\Api\V1\Http\Responses\Employees\EmployeesIndexResponse;
use Presentation\Api\V1\Presenters\Employees\EmployeesIndexPresenter;
@ -23,21 +23,25 @@ final readonly class EmployeesIndexController
) {
}
public function __invoke(EmployeesIndexRequest $request): JsonResponse
public function __invoke(EmployeesIndexRequest $request): ApiResponse
{
$input = new Input(
pagination: $request->pagination(),
sorter: $request->sorter(),
filter: $request->filter(),
);
$input = $this->inputFrom($request);
/** @var \Presentation\Api\V1\Presenters\Employees\EmployeesIndexView $response */
$response = UsecaseBuilder::using($this->executor)
->do($this->usecase)
->from($input)
->with($this->presenter)
->run();
return (new EmployeesIndexResponse($response, $request->toLeagueUri()))->present();
return new EmployeesIndexResponse($response, $request->toLeagueUri());
}
private function inputFrom(EmployeesIndexRequest $request): Input
{
return new Input(
pagination: $request->pagination(),
sorter: $request->sorter(),
filter: $request->filter(),
);
}
}

View file

@ -6,7 +6,7 @@ namespace Presentation\Api\V1\Http\Controllers\IncoSheets;
use Application\Usecase\IncoSheets\FindAll\FindAllIncoSheets;
use Application\Usecase\IncoSheets\FindAll\Input;
use Illuminate\Http\JsonResponse;
use Presentation\Api\V1\Contracts\ApiResponse;
use Presentation\Api\V1\Http\Requests\IncoSheets\IncoSheetsIndexRequest;
use Presentation\Api\V1\Http\Responses\IncoSheets\IncoSheetsIndexResponse;
use Presentation\Api\V1\Presenters\IncoSheets\IncoSheetsIndexPresenter;
@ -23,7 +23,7 @@ final readonly class IncoSheetsIndexController
) {
}
public function __invoke(IncoSheetsIndexRequest $request): JsonResponse
public function __invoke(IncoSheetsIndexRequest $request): ApiResponse
{
$input = new Input(
pagination: $request->pagination(),
@ -31,13 +31,12 @@ final readonly class IncoSheetsIndexController
filter: $request->filter(),
);
/** @var \Presentation\Api\V1\Presenters\IncoSheets\IncoSheetsIndexView $response */
$response = UsecaseBuilder::using($this->executor)
->do($this->usecase)
->from($input)
->with($this->presenter)
->run();
return (new IncoSheetsIndexResponse($response, $request->toLeagueUri(), $request->isShort()))->present();
return new IncoSheetsIndexResponse($response, $request->toLeagueUri());
}
}

View file

@ -6,7 +6,7 @@ namespace Presentation\Api\V1\Http\Controllers\IncoSheets;
use Application\Usecase\IncoSheets\Find\FindIncoSheet;
use Application\Usecase\IncoSheets\Find\Input;
use Illuminate\Http\JsonResponse;
use Presentation\Api\V1\Contracts\ApiResponse;
use Presentation\Api\V1\Http\Requests\IncoSheets\IncoSheetsShowRequest;
use Presentation\Api\V1\Http\Responses\IncoSheets\IncoSheetsShowResponse;
use Presentation\Api\V1\Presenters\IncoSheets\IncoSheetsShowPresenter;
@ -23,19 +23,18 @@ final readonly class IncoSheetsShowController
) {
}
public function __invoke(IncoSheetsShowRequest $request): JsonResponse
public function __invoke(IncoSheetsShowRequest $request): ApiResponse
{
$input = new Input(
id: $request->incoId(),
);
/** @var \Presentation\Api\V1\Presenters\IncoSheets\IncoSheetsShowView $response */
$response = UsecaseBuilder::using($this->executor)
->do($this->usecase)
->from($input)
->with($this->presenter)
->run();
return (new IncoSheetsShowResponse($response))->present();
return new IncoSheetsShowResponse($response);
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Presentation\Api\V1\Contracts\ApiResponse;
use Symfony\Component\HttpFoundation\Response;
final class TransformApiV1Response
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($response instanceof ApiResponse) {
return $response->present();
}
/** @var Response $response */
return $response;
}
}

View file

@ -16,6 +16,12 @@ final readonly class EmployeeShowResponse implements ApiResponse
) {
}
#[\Override]
public function toResponse($request): JsonResponse
{
return $this->present();
}
#[\Override]
public function present(): JsonResponse
{

View file

@ -19,6 +19,12 @@ final readonly class EmployeesIndexResponse implements ApiResponse
) {
}
#[\Override]
public function toResponse($request): JsonResponse
{
return $this->present();
}
#[\Override]
public function present(): JsonResponse
{

View file

@ -18,10 +18,15 @@ final readonly class IncoSheetsIndexResponse implements ApiResponse
public function __construct(
private IncoSheetsIndexView $view,
private Http $baseUri,
private bool $isShort = false,
) {
}
#[\Override]
public function toResponse($request): JsonResponse
{
return $this->present();
}
#[\Override]
public function present(): JsonResponse
{
@ -36,31 +41,15 @@ final readonly class IncoSheetsIndexResponse implements ApiResponse
$prevUrl = $page > 1 ? (string) $this->baseUri->withQuery(http_build_query(['page' => $page - 1, 'pageSize' => $pageSize])) : null;
$nextUrl = $page < $totalPages ? (string) $this->baseUri->withQuery(http_build_query(['page' => $page + 1, 'pageSize' => $pageSize])) : null;
$isShort = $this->isShort;
$incoSheets = array_map(
static function (PaginatedIncoSheet | PaginatedShortIncoSheet $sheet) use ($isShort): array {
if ($isShort || $sheet instanceof PaginatedShortIncoSheet) {
$shortSheet = $sheet instanceof PaginatedShortIncoSheet
? $sheet
: new PaginatedShortIncoSheet(
id: $sheet->id,
name: $sheet->name,
label: $sheet->label,
createdAt: $sheet->createdAt,
updatedAt: $sheet->updatedAt,
);
return get_object_vars(IncoShortSheetDto::fromPaginatedShortIncoSheet($shortSheet));
}
return get_object_vars(IncoSheetDto::fromPaginatedIncoSheet($sheet));
},
$items = array_map(
fn (PaginatedIncoSheet|PaginatedShortIncoSheet $sheet): array => $sheet instanceof PaginatedShortIncoSheet
? get_object_vars(IncoShortSheetDto::fromPaginatedShortIncoSheet($sheet))
: get_object_vars(IncoSheetDto::fromPaginatedIncoSheet($sheet)),
$this->view->incoSheets
);
return response()->json([
'incoSheets' => $incoSheets,
'incoSheets' => $items,
'pagination' => [
'page' => $page,
'pageSize' => $pageSize,

View file

@ -16,6 +16,12 @@ final readonly class IncoSheetsShowResponse implements ApiResponse
) {
}
#[\Override]
public function toResponse($request): JsonResponse
{
return $this->present();
}
#[\Override]
public function present(): JsonResponse
{

View file

@ -4,7 +4,9 @@ declare(strict_types=1);
namespace Shared\DDD\Usecase\Builder;
/** @template-covariant TView of object */
interface BuilderStepBuild
{
/** @return TView */
public function run(): object;
}

View file

@ -9,6 +9,11 @@ use Shared\DDD\Usecase\Presenter;
/** @template TOutput of \Shared\DDD\Usecase\Output */
interface BuilderStepPresenter
{
/** @param Presenter<TOutput,object> $presenter */
/**
* @template TView of object
*
* @param Presenter<TOutput, TView> $presenter
* @return BuilderStepBuild<TView>
*/
public function with(Presenter $presenter): BuilderStepBuild;
}

View file

@ -6,11 +6,14 @@ namespace Shared\DDD\Usecase\Builder;
use Shared\DDD\Usecase\Usecase;
/** @template TOutput of \Shared\DDD\Usecase\Output */
/**
* @template TInput of \Shared\DDD\Usecase\Input
* @template TOutput of \Shared\DDD\Usecase\Output
*/
interface BuilderStepUsecase
{
/**
* @param Usecase<\Shared\DDD\Usecase\Input,TOutput> $usecase
* @param Usecase<TInput, TOutput> $usecase
* @return BuilderStepInput<TOutput>
*/
public function do(Usecase $usecase): BuilderStepInput;

View file

@ -10,8 +10,8 @@ final class UsecaseBuilder
* @template TInput of \Shared\DDD\Usecase\Input
* @template TOutput of \Shared\DDD\Usecase\Output
*
* @param UsecaseExecutor<TInput,TOutput> $executor
* @return BuilderStepUsecase<TOutput>
* @param UsecaseExecutor<TInput, TOutput> $executor
* @return BuilderStepUsecase<TInput, TOutput>
*/
public static function using(UsecaseExecutor $executor): BuilderStepUsecase
{

View file

@ -12,28 +12,30 @@ use Shared\DDD\Usecase\Usecase;
/**
* @template TInput of \Shared\DDD\Usecase\Input
* @template TOutput of \Shared\DDD\Usecase\Output
* @template TView of object
*
* @implements BuilderStepInput<TOutput>
* @implements BuilderStepPresenter<TOutput>
* @implements BuilderStepUsecase<TOutput>
* @implements BuilderStepUsecase<TInput, TOutput>
* @implements BuilderStepBuild<TView>
*/
final class UsecaseExecutionBuilder implements BuilderStepBuild, BuilderStepInput, BuilderStepPresenter, BuilderStepUsecase
{
/** @param UsecaseExecutor<TInput,TOutput> $executor */
/** @param UsecaseExecutor<TInput, TOutput> $executor */
public function __construct(
private readonly UsecaseExecutor $executor,
) {
}
/** @var Usecase<TInput,TOutput>|null */
/** @var Usecase<TInput, TOutput>|null */
private ?Usecase $usecase = null;
private ?Input $input = null;
/** @var Presenter<TOutput,object>|null */
/** @var Presenter<TOutput, object>|null */
private ?Presenter $presenter = null;
/** @param Usecase<TInput,TOutput> $usecase */
/** @param Usecase<TInput, TOutput> $usecase */
#[\Override]
public function do(Usecase $usecase): BuilderStepInput
{
@ -54,7 +56,12 @@ final class UsecaseExecutionBuilder implements BuilderStepBuild, BuilderStepInpu
return $this;
}
/** @param Presenter<TOutput,object> $presenter */
/**
* @template TNextView of object
*
* @param Presenter<TOutput, TNextView> $presenter
* @return BuilderStepBuild<TNextView>
*/
#[\Override]
public function with(Presenter $presenter): BuilderStepBuild
{
@ -69,6 +76,7 @@ final class UsecaseExecutionBuilder implements BuilderStepBuild, BuilderStepInpu
return $this;
}
/** @return TView */
#[\Override]
public function run(): object
{
@ -76,6 +84,7 @@ final class UsecaseExecutionBuilder implements BuilderStepBuild, BuilderStepInpu
throw new LogicException('Usecase, input and presenter are all required.');
}
/** @var TView */
return $this->executor->execute(
usecase: $this->usecase,
input: $this->input,

View file

@ -15,8 +15,11 @@ use Shared\DDD\Usecase\Usecase;
interface UsecaseExecutor
{
/**
* @param Usecase<TInput,TOutput> $usecase
* @param Presenter<TOutput,object> $presenter
* @template TView of object
*
* @param Usecase<TInput, TOutput> $usecase
* @param Presenter<TOutput, TView> $presenter
* @return TView
*/
public function execute(Usecase $usecase, Input $input, Presenter $presenter): object;
}