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