1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace LaravelUi5\OData\Http\Controller;
6:
7: use Illuminate\Http\Request;
8: use Illuminate\Routing\Controller;
9: use LaravelUi5\OData\Exception\BadRequestException;
10: use LaravelUi5\OData\Exception\InternalServerErrorException;
11: use LaravelUi5\OData\Exception\NotImplementedException;
12: use LaravelUi5\OData\Exception\ProtocolException;
13: use LaravelUi5\OData\Http\CustomQueryOptions;
14: use LaravelUi5\OData\Http\ODataRequest;
15: use LaravelUi5\OData\Http\ODataResponse;
16: use LaravelUi5\OData\Http\ReadGate;
17: use LaravelUi5\OData\Protocol\Execution\BatchHandler;
18: use LaravelUi5\OData\Protocol\Planning\QueryPlanner;
19: use LaravelUi5\OData\Service\Contracts\ODataServiceInterface;
20: use LaravelUi5\OData\Service\Contracts\ODataServiceRegistryInterface;
21: use Throwable;
22:
23: /**
24: * OData HTTP controller — routes requests through the execution engine.
25: *
26: * @package LaravelUi5\OData\Controller
27: */
28: class OData extends Controller
29: {
30: public function __construct(private readonly ReadGate $gate)
31: {
32: }
33:
34: /**
35: * Handle an OData request, resolving the service from the registry.
36: *
37: * The registry-backed entry point: one route group, one middleware pipeline,
38: * services selected by path. Delegates to {@see self::forService()}.
39: */
40: public function handle(Request $request, ODataServiceRegistryInterface $resolver): ODataResponse
41: {
42: return $this->forService($request, $resolver->resolve($request->path()));
43: }
44:
45: /**
46: * Handle an OData request against an already-resolved service.
47: *
48: * The registry-independent seam: compose your own route, choose your own
49: * middleware pipeline, and bind a specific service — e.g. a curated, Basic-auth
50: * endpoint for Excel/Power BI beside the standard registry-resolved `/odata` space:
51: *
52: * Route::any('excel/{path?}', fn (Request $r) =>
53: * app(OData::class)->forService($r, app(ExcelService::class))
54: * )->where('path', '.*')->middleware('auth.basic');
55: *
56: * The service declares its own mount via route()/endpoint(). When mounting a
57: * service on a non-standard prefix, override BOTH so path-stripping (route()) AND
58: * the self-referential URLs — @odata.context, @odata.nextLink (endpoint()) — follow
59: * that prefix; otherwise paginated responses emit next-links into the default
60: * `/odata` namespace and downstream clients page into the wrong place.
61: */
62: public function forService(Request $request, ODataServiceInterface $service): ODataResponse
63: {
64: try {
65: $route = $service->route();
66: $rawPath = '/' . ltrim($request->path(), '/');
67: $path = substr($rawPath, strlen('/' . ltrim($route, '/'))) ?: '/';
68:
69: // Read-only engine: only GET, HEAD (service root) and POST ($batch) are accepted.
70: $method = strtoupper($request->getMethod());
71:
72: // HEAD on service root: return CSRF token for UI5 security handshake.
73: if ($method === 'HEAD' && trim($path, '/') === '') {
74: return new ODataResponse(status: 200, headers: [
75: 'X-CSRF-Token' => csrf_token(),
76: ]);
77: }
78:
79: if ($method !== 'GET' && !($method === 'POST' && trim($path, '/') === '$batch')) {
80: throw new BadRequestException(
81: 'method_not_allowed',
82: sprintf('HTTP method %s is not supported on this read-only service.', $method)
83: );
84: }
85:
86: // Reject unsupported system query options.
87: $this->validateQueryOptions($request);
88:
89: // Batch — handled separately since it re-dispatches inner requests.
90: // Supports both JSON batch and multipart/mixed batch formats.
91: if (trim($path, '/') === '$batch') {
92: $schema = $service->schema();
93: return (new BatchHandler($schema, $service, $this->gate, $request))
94: ->handle($request->getContent(), $request->header('Content-Type'));
95: }
96:
97: // Resolve page size: client Prefer header → server default → server max.
98: $maxPageSize = $this->resolveMaxPageSize($request);
99:
100: $planRequest = new ODataRequest(
101: path: $path,
102: filter: $request->query('$filter'),
103: select: $request->query('$select'),
104: orderBy: $request->query('$orderby'),
105: top: $request->query('$top') !== null ? (int) $request->query('$top') : null,
106: skip: $request->query('$skip') !== null ? (int) $request->query('$skip') : null,
107: expand: $request->query('$expand'),
108: search: $request->query('$search'),
109: compute: $request->query('$compute'),
110: count: $request->query('$count') === 'true',
111: maxPageSize: $maxPageSize,
112: customQueryOptions: CustomQueryOptions::fromQuery($request->query()),
113: );
114:
115: $schema = $service->schema();
116: $plan = (new QueryPlanner)->plan($planRequest, $schema);
117:
118: // Read-authorization gate: authorize the plan, then execute. A hard denial answers a
119: // 403; a gated $expand is pruned + reported in a sap-messages header; else served
120: // as-is. The same gate runs for each $batch inner request (see BatchHandler).
121: return $this->gate->execute($plan, $request, $schema, $service->endpoint());
122: } catch (ProtocolException $e) {
123: throw $e;
124: } catch (Throwable $e) {
125: throw new InternalServerErrorException('internal_error', $e->getMessage(), $e);
126: }
127: }
128:
129: /**
130: * Resolve the effective max page size from the client Prefer header
131: * and the server-side pagination config.
132: */
133: private function resolveMaxPageSize(Request $request): ?int
134: {
135: // 1. Parse client preference from Prefer header.
136: $maxPageSize = null;
137: $prefer = $request->header('Prefer', '');
138: if ($prefer !== '' && $prefer !== null) {
139: if (preg_match('/(?:odata\.)?maxpagesize\s*=\s*(\d+)/i', $prefer, $m)) {
140: $maxPageSize = (int) $m[1];
141: }
142: }
143:
144: // 2. Apply server-side default when client sends no preference.
145: $paginationDefault = config('odata.pagination.default');
146: if ($maxPageSize === null && $paginationDefault !== null) {
147: $maxPageSize = (int) $paginationDefault;
148: }
149:
150: // 3. Clamp to server-side maximum.
151: $paginationMax = config('odata.pagination.max');
152: if ($paginationMax !== null && ($maxPageSize === null || $maxPageSize > (int) $paginationMax)) {
153: $maxPageSize = (int) $paginationMax;
154: }
155:
156: return $maxPageSize;
157: }
158:
159: /**
160: * Reject unknown $-prefixed query options and unsupported features.
161: */
162: private function validateQueryOptions(Request $request): void
163: {
164: $supported = [
165: '$filter', '$select', '$orderby', '$top', '$skip', '$count',
166: '$expand', '$search', '$compute', '$format', '$skiptoken',
167: '$batch',
168: ];
169:
170: foreach ($request->query() as $key => $value) {
171: if (!is_string($key)) {
172: continue;
173: }
174: if (str_starts_with($key, '$') && !in_array(strtolower($key), $supported, true)) {
175: if (strtolower($key) === '$apply') {
176: throw new NotImplementedException(
177: 'not_implemented',
178: 'The $apply query option is not supported'
179: );
180: }
181: throw new BadRequestException(
182: 'invalid_query_option',
183: sprintf('Unknown system query option: %s', $key)
184: );
185: }
186: }
187: }
188:
189: /**
190: * @param string $method
191: * @param array $parameters
192: */
193: public function callAction($method, $parameters)
194: {
195: return parent::callAction($method, array_values($parameters));
196: }
197: }
198: