89 lines
3.7 KiB
PHP
89 lines
3.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller\Api;
|
|
|
|
use App\Model\Export;
|
|
use App\Model\ExportQuery;
|
|
use App\Service\ProductExportBuilder;
|
|
use App\Service\TravelExportBuilder;
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|
|
|
/**
|
|
* Bulk exports, for consumers that mirror our data rather than query it per row.
|
|
*
|
|
* Deliberately its own resource rather than a verb hung off the collection it exports.
|
|
* `/api/travels/{dateCode}` accepts an arbitrary string, so an `/api/travels/export` would
|
|
* share a namespace with every date code and could only be kept apart from one by a
|
|
* negative lookahead on the sibling route -- a constraint nothing in the URL announces and
|
|
* which silently mis-routes when it is wrong. Under `/api/exports` the collision does not
|
|
* exist: this prefix never takes a parameterised segment, exactly like `/api/contingents`.
|
|
*/
|
|
#[Route('/api/exports')]
|
|
#[IsGranted('ROLE_OAUTH2_API')]
|
|
class ExportController extends AbstractController
|
|
{
|
|
public function __construct(
|
|
private readonly TravelExportBuilder $travelExportBuilder,
|
|
private readonly ProductExportBuilder $productExportBuilder,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Every travel date x hotel pair with its pickups resolved, paginated.
|
|
*
|
|
* The counters in meta describe the whole export, not the page, so they repeat
|
|
* unchanged on every page and a consumer can abort after the first one.
|
|
*/
|
|
#[Route(path: '/travels', name: 'api_exports_travels', methods: ['GET'])]
|
|
public function travels(
|
|
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
|
ExportQuery $query = new ExportQuery(),
|
|
): JsonResponse {
|
|
// Deliberately not wrapped in a catch that answers 200 with an error body, the way
|
|
// TravelController::hotelAvailability() does: the consumer's retry logic keys on
|
|
// status codes, so a failure here has to surface as a 5xx.
|
|
return $this->paginate($this->travelExportBuilder->build(), $query);
|
|
}
|
|
|
|
/**
|
|
* Every travel date x hotel pair with its whole record, unfiltered, paginated.
|
|
*
|
|
* The website's product import. Same grain, envelope and failure behaviour as travels();
|
|
* the rows carry rooms, prices, services and selection groups instead of the
|
|
* disposition subset, and nothing is dropped for the consumer.
|
|
*/
|
|
#[Route(path: '/products', name: 'api_exports_products', methods: ['GET'])]
|
|
public function products(
|
|
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
|
ExportQuery $query = new ExportQuery(),
|
|
): JsonResponse {
|
|
return $this->paginate($this->productExportBuilder->build(), $query);
|
|
}
|
|
|
|
private function paginate(Export $export, ExportQuery $query): JsonResponse
|
|
{
|
|
$total = count($export->rows);
|
|
|
|
return $this->json([
|
|
'data' => array_slice($export->rows, ($query->page - 1) * $query->perPage, $query->perPage),
|
|
'meta' => [
|
|
'page' => $query->page,
|
|
'perPage' => $query->perPage,
|
|
'total' => $total,
|
|
'totalPages' => (int) ceil($total / $query->perPage),
|
|
'skipped' => $export->skipped,
|
|
'pickupsWithoutMasterData' => $export->pickupsWithoutMasterData,
|
|
'sourceGeneratedAt' => $export->sourceGeneratedAt?->format('c'),
|
|
'generatedAt' => $export->generatedAt->format('c'),
|
|
],
|
|
]);
|
|
}
|
|
}
|