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