Skip to content

Commit b2d07cc

Browse files
committed
feat: add support for parsing OData batch operation responses
1 parent fdb08b2 commit b2d07cc

File tree

7 files changed

+611
-26
lines changed

7 files changed

+611
-26
lines changed

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: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
<?php
2+
3+
namespace SaintSystems\OData;
4+
5+
class ODataBatchResponse implements IODataResponse
6+
{
7+
private IODataRequest $request;
8+
private ?string $body;
9+
private array $headers;
10+
private ?string $httpStatusCode;
11+
private array $responses;
12+
private ?string $boundary;
13+
14+
public function __construct(IODataRequest $request, ?string $body = null, ?string $httpStatusCode = null, array $headers = array())
15+
{
16+
$this->request = $request;
17+
$this->body = $body;
18+
$this->httpStatusCode = $httpStatusCode;
19+
$this->headers = $headers;
20+
$this->boundary = $this->extractBoundary();
21+
$this->responses = $this->parseBatchResponse();
22+
}
23+
24+
private function extractBoundary(): ?string
25+
{
26+
$contentType = $this->getContentTypeHeader();
27+
if ($contentType !== null && $contentType !== '' && preg_match('/boundary=(["\']?)([^"\';]+)\1/', $contentType, $matches)) {
28+
$boundary = $matches[2];
29+
30+
if (strpos(strtolower($boundary), 'batchresponse') !== false) {
31+
return $boundary;
32+
}
33+
}
34+
return null;
35+
}
36+
37+
private function getContentTypeHeader(): ?string
38+
{
39+
foreach ($this->headers as $key => $value) {
40+
if (strtolower($key) === 'content-type') {
41+
return is_array($value) ? $value[0] : $value;
42+
}
43+
}
44+
return null;
45+
}
46+
47+
private function parseBatchResponse(): array
48+
{
49+
if ($this->body === null || $this->body === '' || $this->boundary === null || $this->boundary === '') {
50+
return array();
51+
}
52+
53+
$responses = array();
54+
$parts = explode('--' . $this->boundary, $this->body);
55+
56+
foreach ($parts as $part) {
57+
$part = trim($part);
58+
// Skip empty parts and boundary end marker
59+
if ($part === '' || $part === '--' || $part === "\r\n--" || trim($part, "\r\n-") === '') {
60+
continue;
61+
}
62+
63+
if ($this->isChangesetPart($part)) {
64+
$changesetResponses = $this->parseChangesetPart($part);
65+
$responses = array_merge($responses, $changesetResponses);
66+
} else {
67+
$response = $this->parseIndividualResponse($part);
68+
if ($response !== null) {
69+
$responses[] = $response;
70+
}
71+
}
72+
}
73+
74+
return $responses;
75+
}
76+
77+
private function isChangesetPart(string $part): bool
78+
{
79+
return preg_match('/Content-Type:\s*multipart\/mixed;\s*boundary=["\']?[^"\'\s]*changeset[^"\'\s]*["\']?/i', $part) === 1;
80+
}
81+
82+
private function parseChangesetPart(string $part): array
83+
{
84+
if (preg_match('/boundary=(["\']?)([^"\'\r\n;]+)\1/i', $part, $matches) !== 1) {
85+
return array();
86+
}
87+
88+
$changesetBoundary = $matches[2];
89+
90+
if ($changesetBoundary === '' || strpos(strtolower($changesetBoundary), 'changeset') === false) {
91+
return array();
92+
}
93+
94+
// Find where changeset content starts (after empty line)
95+
$changesetContent = $this->extractChangesetContent($part);
96+
97+
// Parse individual responses within changeset
98+
$changesetParts = explode('--' . $changesetBoundary, $changesetContent);
99+
$responses = array();
100+
101+
foreach ($changesetParts as $changesetPart) {
102+
$changesetPart = trim($changesetPart);
103+
if ($changesetPart === '' || $changesetPart === '--' || trim($changesetPart, "\r\n-") === '') {
104+
continue;
105+
}
106+
107+
$response = $this->parseIndividualResponse($changesetPart);
108+
if ($response !== null) {
109+
$responses[] = $response;
110+
}
111+
}
112+
113+
return $responses;
114+
}
115+
116+
private function extractChangesetContent(string $part): string
117+
{
118+
$lines = explode("\n", $part);
119+
$contentStarted = false;
120+
$content = array();
121+
122+
foreach ($lines as $line) {
123+
$line = rtrim($line, "\r");
124+
125+
// Skip until we find an empty line (end of changeset headers)
126+
if (!$contentStarted) {
127+
if (trim($line) === '') {
128+
$contentStarted = true;
129+
}
130+
continue;
131+
}
132+
133+
$content[] = $line;
134+
}
135+
136+
return implode("\n", $content);
137+
}
138+
139+
private function parseIndividualResponse(string $part): ?ODataResponse
140+
{
141+
$lines = explode("\n", $part);
142+
$inHeaders = true;
143+
$responseHeaders = array();
144+
$responseBody = '';
145+
$statusCode = null;
146+
$foundHttpResponse = false;
147+
148+
foreach ($lines as $line) {
149+
$line = rtrim($line, "\r");
150+
151+
if ($inHeaders) {
152+
if (trim($line) === '') {
153+
// Only switch to body parsing if we've found an HTTP response line
154+
if ($foundHttpResponse) {
155+
$inHeaders = false;
156+
}
157+
continue;
158+
}
159+
160+
if (strpos($line, 'HTTP/') === 0) {
161+
$statusParts = explode(' ', $line, 3);
162+
$statusCode = (array_key_exists(1, $statusParts) && $statusParts[1] !== null) ? $statusParts[1] : (string)HttpStatusCode::OK;
163+
$foundHttpResponse = true;
164+
continue;
165+
}
166+
167+
// Only parse headers after we've found the HTTP response line
168+
if ($foundHttpResponse && strpos($line, ':') !== false) {
169+
list($key, $value) = explode(':', $line, 2);
170+
$responseHeaders[trim($key)] = trim($value);
171+
}
172+
} else {
173+
$responseBody .= $line . "\n";
174+
}
175+
}
176+
177+
$responseBody = trim($responseBody);
178+
179+
if ($statusCode !== null) {
180+
return new ODataResponse($this->request, $responseBody, $statusCode, $responseHeaders);
181+
}
182+
183+
return null;
184+
}
185+
186+
public function getBody(): array
187+
{
188+
$bodies = array();
189+
foreach ($this->responses as $response) {
190+
$bodies[] = $response->getBody();
191+
}
192+
return $bodies;
193+
}
194+
195+
public function getRawBody(): ?string
196+
{
197+
return $this->body;
198+
}
199+
200+
public function getStatus(): ?string
201+
{
202+
return $this->httpStatusCode;
203+
}
204+
205+
public function getHeaders(): array
206+
{
207+
return $this->headers;
208+
}
209+
210+
public function getResponses(): array
211+
{
212+
return $this->responses;
213+
}
214+
215+
public function getResponse(int $index): ?ODataResponse
216+
{
217+
return (array_key_exists($index, $this->responses) && $this->responses[$index] !== null) ? $this->responses[$index] : null;
218+
}
219+
}

src/ODataRequest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,9 +247,9 @@ public function execute()
247247
return [(string) $result->getBody(), null];
248248
}
249249

250-
// Wrap response in ODataResponse layer
250+
// Wrap response in appropriate ODataResponse layer using factory
251251
try {
252-
$response = new ODataResponse(
252+
$response = ODataResponseFactory::create(
253253
$this,
254254
(string) $result->getBody(),
255255
$result->getStatusCode(),

src/ODataResponse.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
* @package SaintSystems.OData
2626
* @license https://opensource.org/licenses/MIT MIT License
2727
*/
28-
class ODataResponse implements IODataResponse
28+
class ODataResponse implements IODataEntityResponse
2929
{
3030
/**
3131
* The request

src/ODataResponseFactory.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace SaintSystems\OData;
4+
5+
class ODataResponseFactory
6+
{
7+
public static function create(IODataRequest $request, ?string $body = null, ?string $statusCode = null, array $headers = array())
8+
{
9+
$contentType = self::getContentType($headers);
10+
11+
// Batch response detection - Microsoft uses multipart/mixed with batchresponse_ boundary
12+
if ($contentType !== null &&
13+
strpos($contentType, 'multipart/mixed') === 0 &&
14+
strpos($contentType, 'boundary=batchresponse_') !== false) {
15+
return new ODataBatchResponse($request, $body, $statusCode, $headers);
16+
}
17+
18+
return new ODataResponse($request, $body, $statusCode, $headers);
19+
}
20+
21+
private static function getContentType(array $headers): ?string
22+
{
23+
foreach ($headers as $key => $value) {
24+
if (strtolower($key) === 'content-type' && $value !== null) {
25+
return $value;
26+
}
27+
}
28+
29+
return null;
30+
}
31+
}

0 commit comments

Comments
 (0)