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\ODataRequest;
14: use LaravelUi5\OData\Http\ODataResponse;
15: use LaravelUi5\OData\Protocol\Execution\BatchHandler;
16: use LaravelUi5\OData\Protocol\Execution\Engine;
17: use LaravelUi5\OData\Protocol\Planning\QueryPlanner;
18: use LaravelUi5\OData\Service\Contracts\ODataServiceRegistryInterface;
19: use Throwable;
20:
21: /**
22: * OData HTTP controller — routes requests through the execution engine.
23: *
24: * @package LaravelUi5\OData\Controller
25: */
26: class OData extends Controller
27: {
28: /**
29: * Handle an OData request.
30: */
31: public function handle(Request $request, ODataServiceRegistryInterface $resolver): ODataResponse
32: {
33: $oDataService = $resolver->resolve($request->path());
34:
35: try {
36: $route = $oDataService->route();
37: $rawPath = '/' . ltrim($request->path(), '/');
38: $path = substr($rawPath, strlen('/' . ltrim($route, '/'))) ?: '/';
39:
40: // Read-only engine: only GET, HEAD (service root) and POST ($batch) are accepted.
41: $method = strtoupper($request->getMethod());
42:
43: // HEAD on service root: return CSRF token for UI5 security handshake.
44: if ($method === 'HEAD' && trim($path, '/') === '') {
45: return new ODataResponse(status: 200, headers: [
46: 'X-CSRF-Token' => csrf_token(),
47: ]);
48: }
49:
50: if ($method !== 'GET' && !($method === 'POST' && trim($path, '/') === '$batch')) {
51: throw new BadRequestException(
52: 'method_not_allowed',
53: sprintf('HTTP method %s is not supported on this read-only service.', $method)
54: );
55: }
56:
57: // Reject unsupported system query options.
58: $this->validateQueryOptions($request);
59:
60: // Batch — handled separately since it re-dispatches inner requests.
61: // Supports both JSON batch and multipart/mixed batch formats.
62: if (trim($path, '/') === '$batch') {
63: $schema = $oDataService->schema();
64: return (new BatchHandler($schema, $oDataService))
65: ->handle($request->getContent(), $request->header('Content-Type'));
66: }
67:
68: // Resolve page size: client Prefer header → server default → server max.
69: $maxPageSize = $this->resolveMaxPageSize($request);
70:
71: $planRequest = new ODataRequest(
72: path: $path,
73: filter: $request->query('$filter'),
74: select: $request->query('$select'),
75: orderBy: $request->query('$orderby'),
76: top: $request->query('$top') !== null ? (int) $request->query('$top') : null,
77: skip: $request->query('$skip') !== null ? (int) $request->query('$skip') : null,
78: expand: $request->query('$expand'),
79: search: $request->query('$search'),
80: compute: $request->query('$compute'),
81: count: $request->query('$count') === 'true',
82: maxPageSize: $maxPageSize,
83: );
84:
85: $schema = $oDataService->schema();
86: $plan = (new QueryPlanner)->plan($planRequest, $schema);
87:
88: return (new Engine($schema, $oDataService->endpoint()))->execute($plan);
89: } catch (ProtocolException $e) {
90: throw $e;
91: } catch (Throwable $e) {
92: throw new InternalServerErrorException('internal_error', $e->getMessage(), $e);
93: }
94: }
95:
96: /**
97: * Resolve the effective max page size from the client Prefer header
98: * and the server-side pagination config.
99: */
100: private function resolveMaxPageSize(Request $request): ?int
101: {
102: // 1. Parse client preference from Prefer header.
103: $maxPageSize = null;
104: $prefer = $request->header('Prefer', '');
105: if ($prefer !== '' && $prefer !== null) {
106: if (preg_match('/(?:odata\.)?maxpagesize\s*=\s*(\d+)/i', $prefer, $m)) {
107: $maxPageSize = (int) $m[1];
108: }
109: }
110:
111: // 2. Apply server-side default when client sends no preference.
112: $paginationDefault = config('odata.pagination.default');
113: if ($maxPageSize === null && $paginationDefault !== null) {
114: $maxPageSize = (int) $paginationDefault;
115: }
116:
117: // 3. Clamp to server-side maximum.
118: $paginationMax = config('odata.pagination.max');
119: if ($paginationMax !== null && ($maxPageSize === null || $maxPageSize > (int) $paginationMax)) {
120: $maxPageSize = (int) $paginationMax;
121: }
122:
123: return $maxPageSize;
124: }
125:
126: /**
127: * Reject unknown $-prefixed query options and unsupported features.
128: */
129: private function validateQueryOptions(Request $request): void
130: {
131: $supported = [
132: '$filter', '$select', '$orderby', '$top', '$skip', '$count',
133: '$expand', '$search', '$compute', '$format', '$skiptoken',
134: '$batch',
135: ];
136:
137: foreach ($request->query() as $key => $value) {
138: if (!is_string($key)) {
139: continue;
140: }
141: if (str_starts_with($key, '$') && !in_array(strtolower($key), $supported, true)) {
142: if (strtolower($key) === '$apply') {
143: throw new NotImplementedException(
144: 'not_implemented',
145: 'The $apply query option is not supported'
146: );
147: }
148: throw new BadRequestException(
149: 'invalid_query_option',
150: sprintf('Unknown system query option: %s', $key)
151: );
152: }
153: }
154: }
155:
156: /**
157: * @param string $method
158: * @param array $parameters
159: */
160: public function callAction($method, $parameters)
161: {
162: return parent::callAction($method, array_values($parameters));
163: }
164: }
165: