Skip to content

Commit 9a0ba38

Browse files
wikando-dtwikando-mlCopilot
authored
feat: add support for parsing OData batch operation responses
Co-authored-by: Micha Leykum <73173831+wikando-ml@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent fdb08b2 commit 9a0ba38

10 files changed

+1265
-46
lines changed

src/BatchRequestBuilder.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,9 @@ public function endChangeset()
145145
/**
146146
* Execute the batch request
147147
*
148-
* @return IODataResponse
148+
* @return ODataBatchResponse
149149
*/
150-
public function execute()
150+
public function execute() : ODataBatchResponse
151151
{
152152
// End any open changeset
153153
if ($this->currentChangeset !== null) {
@@ -173,7 +173,7 @@ public function execute()
173173
// Restore original custom headers for future requests
174174
$this->client->setHeaders($originalHeaders);
175175

176-
return $response;
176+
return $response[0];
177177
}
178178

179179
/**

src/GuzzleHttpProvider.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public function __construct($config = [])
4141

4242
/**
4343
* Gets the timeout limit of the cURL request
44-
* @return integer The timeout in ms
44+
* @return integer The timeout in seconds
4545
*/
4646
public function getTimeout()
4747
{
@@ -51,7 +51,7 @@ public function getTimeout()
5151
/**
5252
* Sets the timeout limit of the cURL request
5353
*
54-
* @param integer $timeout The timeout in ms
54+
* @param integer $timeout The timeout in seconds
5555
*
5656
* @return $this
5757
*/

src/IODataEntityResponse.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace SaintSystems\OData;
4+
5+
interface IODataEntityResponse extends IODataResponse
6+
{
7+
/**
8+
* Converts the response JSON object to a OData SDK object
9+
*
10+
* @param mixed $returnType The type to convert the object(s) to
11+
*
12+
* @return mixed object or array of objects of type $returnType
13+
*/
14+
public function getResponseAsObject($returnType);
15+
16+
/**
17+
* Gets the skip token of a response object from OData
18+
*
19+
* @return string skip token, if provided
20+
*/
21+
public function getSkipToken();
22+
23+
/**
24+
* Gets the Id of response object (if set) from OData
25+
*
26+
* @return mixed id if this was an insert, if provided
27+
*/
28+
public function getId();
29+
}

src/IODataResponse.php

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -31,27 +31,4 @@ public function getStatus();
3131
* @var array The response headers
3232
*/
3333
public function getHeaders();
34-
35-
/**
36-
* Converts the response JSON object to a OData SDK object
37-
*
38-
* @param mixed $returnType The type to convert the object(s) to
39-
*
40-
* @return mixed object or array of objects of type $returnType
41-
*/
42-
public function getResponseAsObject($returnType);
43-
44-
/**
45-
* Gets the skip token of a response object from OData
46-
*
47-
* @return string skip token, if provided
48-
*/
49-
public function getSkipToken();
50-
51-
/**
52-
* Gets the Id of response object (if set) from OData
53-
*
54-
* @return mixed id if this was an insert, if provided
55-
*/
56-
public function getId();
5734
}

src/ODataBatchResponse.php

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
<?php
2+
3+
namespace SaintSystems\OData;
4+
5+
use SaintSystems\OData\Exception\ODataException;
6+
7+
class ODataBatchResponse implements IODataResponse
8+
{
9+
private IODataRequest $request;
10+
private ?string $body;
11+
/**
12+
* @var array<string, string|array<int, string>>
13+
*/
14+
private array $headers;
15+
private int $httpStatusCode;
16+
/**
17+
* @var array<int, ODataResponse>
18+
*/
19+
private array $responses;
20+
private string $boundary;
21+
22+
/**
23+
* @throws ODataException
24+
*/
25+
public function __construct(IODataRequest $request, string $body, int $httpStatusCode, array $headers = [])
26+
{
27+
$this->request = $request;
28+
$this->body = $body;
29+
$this->httpStatusCode = $httpStatusCode;
30+
$this->headers = $headers;
31+
$this->boundary = $this->extractBoundary();
32+
$this->responses = $this->parseBatchResponse();
33+
}
34+
35+
/**
36+
* @throws ODataException
37+
*/
38+
private function extractBoundary(): string
39+
{
40+
$contentType = $this->getContentTypeHeader();
41+
if ($contentType !== null && preg_match('/^multipart\/mixed;\s*boundary=(["\']?)([^"\';]+)\1$/', $contentType, $matches)) {
42+
return $matches[2];
43+
}
44+
45+
if ($contentType === null) {
46+
throw new ODataException(
47+
'No boundary found in batch response content-type header (content-type header is missing).'
48+
);
49+
}
50+
throw new ODataException('No boundary found in batch response content-type header: ' . $contentType);
51+
}
52+
53+
private function getContentTypeHeader(): ?string
54+
{
55+
foreach ($this->headers as $key => $value) {
56+
if (strtolower($key) === 'content-type') {
57+
return is_array($value) ? $value[0] : $value;
58+
}
59+
}
60+
return null;
61+
}
62+
63+
private function parseBatchResponse(): array
64+
{
65+
if ($this->body === null || $this->body === '') {
66+
return [];
67+
}
68+
69+
$responses = [];
70+
$parts = explode('--' . $this->boundary, $this->body);
71+
72+
foreach ($parts as $part) {
73+
$part = trim($part);
74+
// Skip empty parts and boundary end marker
75+
if ('' === $part || '--' === $part) {
76+
continue;
77+
}
78+
79+
$changesetBoundary = $this->extractChangesetBoundary($part);
80+
if (null === $changesetBoundary) {
81+
$responses[] = $this->parseIndividualResponse($part);
82+
} else {
83+
$changesetResponses = $this->parseChangesetPart($part, $changesetBoundary);
84+
array_push($responses, ...$changesetResponses);
85+
}
86+
}
87+
88+
return $responses;
89+
}
90+
91+
private function extractChangesetBoundary(string $part): ?string
92+
{
93+
if (preg_match('/^Content-Type:\s*multipart\/mixed;\s*boundary=["\']?([^"\'\s;]+)["\']?/i', $part, $matches)) {
94+
return $matches[1];
95+
}
96+
return null;
97+
}
98+
99+
private function parseChangesetPart(string $part, string $changesetBoundary): array
100+
{
101+
// Find where changeset content starts (after empty line)
102+
$changesetContent = $this->extractChangesetContent($part);
103+
104+
// Parse individual responses within changeset
105+
$changesetParts = explode('--' . $changesetBoundary, $changesetContent);
106+
$responses = [];
107+
108+
foreach ($changesetParts as $changesetPart) {
109+
$changesetPart = trim($changesetPart);
110+
if ('' === $changesetPart || '--' === $changesetPart) {
111+
continue;
112+
}
113+
114+
$responses[] = $this->parseIndividualResponse($changesetPart);
115+
}
116+
117+
return $responses;
118+
}
119+
120+
private function extractChangesetContent(string $part): string
121+
{
122+
$separator = $this->detectHeaderSeparator($part);
123+
124+
$changesetParts = explode($separator, $part, 2);
125+
126+
return $changesetParts[1];
127+
}
128+
129+
/**
130+
* @throws ODataException
131+
*/
132+
private function detectHeaderSeparator(string $part): string
133+
{
134+
if (strpos($part, "\r\n\r\n") !== false) {
135+
return "\r\n\r\n";
136+
}
137+
if (strpos($part, "\n\n") !== false) {
138+
return "\n\n";
139+
}
140+
throw new ODataException('No header/body separator found in changeset part: ' . $part);
141+
}
142+
143+
private function parseIndividualResponse(string $part): ODataResponse
144+
{
145+
$separator = $this->detectHeaderSeparator($part);
146+
147+
$responseParts = explode($separator, $part, 3);
148+
149+
if (count($responseParts) < 2) {
150+
throw new ODataException('Unexpected header format in response part: ' . $part);
151+
}
152+
153+
$responseHeaders = $responseParts[0];
154+
$httpHeaders = $responseParts[1];
155+
$responseBody = $responseParts[2] ?? '';
156+
157+
$responseHeadersResult = $this->parseHttpHeaders($responseHeaders);
158+
159+
$httpHeadersResult = $this->parseHttpHeaders($httpHeaders);
160+
$responseHeaders = $httpHeadersResult['headers'];
161+
$statusCode = $httpHeadersResult['statusCode'];
162+
163+
if (null === $statusCode) {
164+
throw new ODataException('No http status code found in response part: ' . $part);
165+
}
166+
167+
if (array_key_exists('Content-ID', $responseHeadersResult['headers'])) {
168+
$responseHeaders['Content-ID'] = $responseHeadersResult['headers']['Content-ID'];
169+
}
170+
171+
return new ODataResponse($this->request, $responseBody, $statusCode, $responseHeaders);
172+
}
173+
174+
/**
175+
* Parse HTTP headers with support for multi-line headers (header folding)
176+
*
177+
* @param string $headerString Raw HTTP header block
178+
* @return array{statusCode: int|null, statusText: string, headers: array<string, string|array<int, string>>} Parsed headers with status code, status text, and header key-value pairs
179+
*/
180+
private function parseHttpHeaders(string $headerString): array
181+
{
182+
// Unfold headers: replace CRLF followed by whitespace with a single space
183+
$headerString = preg_replace('/\r?\n[ \t]+/', ' ', $headerString);
184+
185+
$lines = explode("\n", $headerString);
186+
$result = [
187+
'statusCode' => null,
188+
'statusText' => '',
189+
'headers' => []
190+
];
191+
192+
foreach ($lines as $index => $line) {
193+
$line = rtrim($line, "\r");
194+
195+
// Try to parse first line as status line (e.g., "HTTP/1.1 412 Precondition Failed")
196+
if (0 === $index && preg_match('/^HTTP\/\d\.\d\s+(\d{3})(\s+(.*))?$/', $line, $matches)) {
197+
$result['statusCode'] = (int)$matches[1];
198+
$result['statusText'] = trim($matches[3] ?? '');
199+
continue;
200+
}
201+
202+
// Skip empty lines
203+
if (trim($line) === '') {
204+
continue;
205+
}
206+
207+
// Parse header line
208+
if (strpos($line, ':') !== false) {
209+
[$name, $value] = explode(':', $line, 2);
210+
$name = trim($name);
211+
$value = trim($value);
212+
213+
// Store multiple headers with same name as array
214+
if (array_key_exists($name, $result['headers'])) {
215+
if (!is_array($result['headers'][$name])) {
216+
$result['headers'][$name] = [$result['headers'][$name]];
217+
}
218+
$result['headers'][$name][] = $value;
219+
} else {
220+
$result['headers'][$name] = $value;
221+
}
222+
}
223+
}
224+
225+
return $result;
226+
}
227+
228+
/**
229+
* Get the decoded bodies of all responses in the batch
230+
*
231+
* @return array<int, array> Array of decoded response bodies, where each element is the JSON-decoded body
232+
* of an individual response in the batch. Returns empty array if no responses.
233+
*/
234+
public function getBody(): array
235+
{
236+
$bodies = [];
237+
foreach ($this->responses as $response) {
238+
$bodies[] = $response->getBody();
239+
}
240+
return $bodies;
241+
}
242+
243+
public function getRawBody(): ?string
244+
{
245+
return $this->body;
246+
}
247+
248+
public function getStatus(): int
249+
{
250+
return $this->httpStatusCode;
251+
}
252+
253+
/**
254+
* Get the headers of the batch response
255+
*
256+
* @return array<string, string|array<int, string>>
257+
*/
258+
public function getHeaders(): array
259+
{
260+
return $this->headers;
261+
}
262+
263+
/**
264+
* Get all individual responses in the batch
265+
*
266+
* @return array<int, ODataResponse>
267+
*/
268+
public function getResponses(): array
269+
{
270+
return $this->responses;
271+
}
272+
273+
public function getResponse(int $index): ?ODataResponse
274+
{
275+
return $this->responses[$index] ?? null;
276+
}
277+
}

0 commit comments

Comments
 (0)