1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace LaravelUi5\OData\Protocol\Execution;
6:
7: use Illuminate\Http\Request;
8: use LaravelUi5\OData\Http\ODataResponse;
9: use LaravelUi5\OData\Exception\BadRequestException;
10: use LaravelUi5\OData\Exception\ProtocolException;
11: use LaravelUi5\OData\Http\CustomQueryOptions;
12: use LaravelUi5\OData\Http\ODataRequest;
13: use LaravelUi5\OData\Http\ReadGate;
14: use LaravelUi5\OData\Protocol\Planning\QueryPlanner;
15: use LaravelUi5\OData\Service\Contracts\ODataServiceInterface;
16: use LaravelUi5\OData\Service\Contracts\RuntimeSchemaInterface;
17: use Symfony\Component\HttpFoundation\Response;
18:
19: /**
20: * Handles OData batch requests ($batch) in both JSON and multipart/mixed format.
21: *
22: * Parses the request body, dispatches each inner request through
23: * the QueryPlanner + Engine pipeline, and streams the batch response.
24: *
25: * Only GET requests are supported (read-only engine). Inner requests
26: * that fail produce an error response entry rather than aborting the
27: * entire batch.
28: *
29: * @link https://docs.oasis-open.org/odata/odata-json-format/v4.01/odata-json-format-v4.01.html#sec_BatchRequest
30: * @link https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_MultipartBatchFormat
31: */
32: final readonly class BatchHandler
33: {
34: public function __construct(
35: private RuntimeSchemaInterface $schema,
36: private ODataServiceInterface $service,
37: private ReadGate $gate,
38: private Request $request,
39: ) {}
40:
41: /**
42: * Handle a batch request. Detects JSON vs multipart/mixed from Content-Type.
43: */
44: public function handle(string $requestBody, ?string $contentType = null): ODataResponse
45: {
46: if ($contentType !== null && str_starts_with($contentType, 'multipart/mixed')) {
47: return $this->handleMultipart($requestBody, $contentType);
48: }
49:
50: return $this->handleJson($requestBody);
51: }
52:
53: // ── JSON batch ───────────────────────────────────────────────────────────
54:
55: private function handleJson(string $requestBody): ODataResponse
56: {
57: $body = json_decode($requestBody, true);
58:
59: if (!is_array($body) || !array_key_exists('requests', $body) || !is_array($body['requests'])) {
60: throw new BadRequestException(
61: 'missing_requests',
62: 'The provided JSON document did not contain a valid requests property'
63: );
64: }
65:
66: $requests = $this->validateRequests($body['requests']);
67:
68: $response = new ODataResponse(null, 200, [
69: 'Content-Type' => 'application/json;odata.metadata=minimal;charset=utf-8',
70: 'OData-Version' => '4.0',
71: ]);
72:
73: $schema = $this->schema;
74: $service = $this->service;
75: $gate = $this->gate;
76: $request = $this->request;
77:
78: $response->setCallback(static function () use ($requests, $schema, $service, $gate, $request): void {
79: echo '{"responses":[';
80:
81: $first = true;
82: foreach ($requests as $requestData) {
83: if (!$first) {
84: echo ',';
85: }
86:
87: $innerResponse = self::dispatchInnerRequest($requestData, $schema, $service, $gate, $request);
88: echo json_encode($innerResponse, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
89:
90: $first = false;
91: }
92:
93: echo ']}';
94: });
95:
96: return $response;
97: }
98:
99: // ── Multipart/mixed batch ────────────────────────────────────────────────
100:
101: private function handleMultipart(string $requestBody, string $contentType): ODataResponse
102: {
103: $boundary = $this->extractBoundary($contentType);
104: if ($boundary === null) {
105: throw new BadRequestException(
106: 'missing_boundary',
107: 'The multipart/mixed Content-Type header must include a boundary parameter'
108: );
109: }
110:
111: $requests = $this->parseMultipartParts($requestBody, $boundary);
112: $this->validateMethodsAreGet($requests);
113:
114: $responseBoundary = 'batchresponse_' . bin2hex(random_bytes(16));
115: $schema = $this->schema;
116: $service = $this->service;
117: $gate = $this->gate;
118: $request = $this->request;
119:
120: $response = new ODataResponse(null, 200, [
121: 'Content-Type' => 'multipart/mixed; boundary=' . $responseBoundary,
122: 'OData-Version' => '4.0',
123: ]);
124:
125: $response->setCallback(static function () use ($requests, $schema, $service, $gate, $request, $responseBoundary): void {
126: foreach ($requests as $requestData) {
127: $innerResult = self::dispatchInnerRequest($requestData, $schema, $service, $gate, $request);
128:
129: $status = $innerResult['status'];
130: $statusText = self::httpStatusText($status);
131: $body = $innerResult['body'] ?? null;
132: $bodyJson = $body !== null
133: ? json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
134: : '';
135:
136: echo "--{$responseBoundary}\r\n";
137: echo "Content-Type: application/http\r\n";
138: echo "\r\n";
139: echo "HTTP/1.1 {$status} {$statusText}\r\n";
140:
141: if ($bodyJson !== '') {
142: echo "Content-Type: application/json;odata.metadata=minimal;charset=utf-8\r\n";
143: echo "OData-Version: 4.0\r\n";
144: echo "\r\n";
145: echo $bodyJson;
146: } else {
147: echo "\r\n";
148: }
149:
150: echo "\r\n";
151: }
152:
153: echo "--{$responseBoundary}--\r\n";
154: });
155:
156: return $response;
157: }
158:
159: private function extractBoundary(string $contentType): ?string
160: {
161: if (preg_match('/boundary\s*=\s*"?([^";,\s]+)"?/i', $contentType, $m)) {
162: return $m[1];
163: }
164:
165: return null;
166: }
167:
168: /**
169: * Parse multipart body into an array of request descriptors.
170: *
171: * @return list<array{id: string, method: string, url: string}>
172: */
173: private function parseMultipartParts(string $body, string $boundary): array
174: {
175: // Normalize to LF for consistent parsing; the spec says CRLF but
176: // real clients may send bare LF.
177: $body = str_replace("\r\n", "\n", $body);
178:
179: $parts = explode('--' . $boundary, $body);
180: $requests = [];
181: $id = 0;
182:
183: // First element is prologue (before first boundary) — skip it.
184: array_shift($parts);
185:
186: foreach ($parts as $part) {
187: $trimmed = ltrim($part, "\n");
188:
189: // Closing boundary marker: "--" after the boundary.
190: if ($trimmed === '--' || str_starts_with($trimmed, "--")) {
191: break;
192: }
193:
194: // Split part headers from HTTP message by double newline.
195: $sections = explode("\n\n", $trimmed, 2);
196: if (count($sections) < 2) {
197: continue;
198: }
199:
200: $httpMessage = trim($sections[1]);
201: if ($httpMessage === '') {
202: continue;
203: }
204:
205: // Parse the HTTP request line: "GET /path HTTP/1.1" or "GET /path".
206: $lines = explode("\n", $httpMessage);
207: $requestLine = trim($lines[0]);
208:
209: if (!preg_match('/^(GET|POST|PUT|PATCH|DELETE|HEAD)\s+(.+?)(?:\s+HTTP\/[\d.]+)?$/i', $requestLine, $m)) {
210: continue;
211: }
212:
213: $requests[] = [
214: 'id' => (string) $id,
215: 'method' => strtoupper($m[1]),
216: 'url' => trim($m[2]),
217: ];
218:
219: $id++;
220: }
221:
222: return $requests;
223: }
224:
225: // ── Shared validation / dispatch ─────────────────────────────────────────
226:
227: /**
228: * Validate JSON batch requests: check required keys and GET-only.
229: *
230: * @param list<array{id: string, method: string, url: string}> $requests
231: * @return list<array{id: string, method: string, url: string}>
232: */
233: private function validateRequests(array $requests): array
234: {
235: foreach ($requests as $request) {
236: if (!isset($request['id'], $request['method'], $request['url'])) {
237: throw new BadRequestException(
238: 'missing_request_properties',
239: 'All requests must contain the "id", "method" and "url" properties'
240: );
241: }
242: }
243:
244: $this->validateMethodsAreGet($requests);
245:
246: return $requests;
247: }
248:
249: /**
250: * Reject non-GET methods (read-only engine).
251: */
252: private function validateMethodsAreGet(array $requests): void
253: {
254: foreach ($requests as $request) {
255: if (strtoupper($request['method']) !== 'GET') {
256: throw new BadRequestException(
257: 'unsupported_method',
258: sprintf(
259: 'Request %s uses method "%s" — only GET is supported on this read-only service',
260: $request['id'],
261: $request['method']
262: )
263: );
264: }
265: }
266: }
267:
268: /**
269: * @return array{id: string, status: int, headers?: array<string, string>, body?: mixed}
270: */
271: private static function dispatchInnerRequest(
272: array $requestData,
273: RuntimeSchemaInterface $schema,
274: ODataServiceInterface $service,
275: ReadGate $gate,
276: Request $request,
277: ): array {
278: $url = $requestData['url'];
279:
280: // Strip full URL prefix (http://host/...) down to path.
281: if (preg_match('#^https?://[^/]+(/.*)$#', $url, $m)) {
282: $url = $m[1];
283: }
284:
285: // Resolve the path relative to the service route.
286: $route = $service->route();
287: if (str_starts_with($url, $route . '/')) {
288: $path = substr($url, strlen($route));
289: } elseif (str_starts_with($url, '/')) {
290: $path = substr($url, strlen('/' . ltrim($route, '/'))) ?: '/';
291: } else {
292: $path = '/' . $url;
293: }
294:
295: // Parse query string if present.
296: $queryString = null;
297: if (($qPos = strpos($path, '?')) !== false) {
298: $queryString = substr($path, $qPos + 1);
299: $path = substr($path, 0, $qPos);
300: }
301:
302: $query = [];
303: if ($queryString !== null) {
304: parse_str($queryString, $query);
305: }
306:
307: $planRequest = new ODataRequest(
308: path: $path,
309: filter: $query['$filter'] ?? null,
310: select: $query['$select'] ?? null,
311: orderBy: $query['$orderby'] ?? null,
312: top: isset($query['$top']) ? (int) $query['$top'] : null,
313: skip: isset($query['$skip']) ? (int) $query['$skip'] : null,
314: expand: $query['$expand'] ?? null,
315: search: $query['$search'] ?? null,
316: compute: $query['$compute'] ?? null,
317: count: ($query['$count'] ?? '') === 'true',
318: // Custom query options live only on this inner request's URL, never on
319: // the outer $batch envelope — carry them on the request value object.
320: customQueryOptions: CustomQueryOptions::fromQuery($query),
321: );
322:
323: try {
324: // Same read-authz gate as the direct path: a hard denial throws ForbiddenException
325: // (caught below → a per-inner 403 entry); a gated $expand is pruned + reported.
326: $plan = (new QueryPlanner)->plan($planRequest, $schema);
327: $response = $gate->execute($plan, $request, $schema, $service->endpoint());
328:
329: ob_start();
330: $response->sendContent();
331: $responseBody = ob_get_clean();
332:
333: $result = [
334: 'id' => $requestData['id'],
335: 'status' => $response->getStatusCode(),
336: ];
337:
338: $decoded = json_decode($responseBody, true);
339: if ($decoded !== null) {
340: $result['body'] = $decoded;
341: } else {
342: $result['body'] = $responseBody;
343: }
344:
345: return $result;
346: } catch (ProtocolException $e) {
347: $errorResponse = $e->toResponse();
348: return [
349: 'id' => $requestData['id'],
350: 'status' => $errorResponse->getStatusCode(),
351: 'body' => ['error' => $e->toError()],
352: ];
353: }
354: }
355:
356: private static function httpStatusText(int $status): string
357: {
358: return Response::$statusTexts[$status] ?? 'Unknown';
359: }
360: }
361: