-
Notifications
You must be signed in to change notification settings - Fork 113
feat: add support for parsing OData batch operation responses #195
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
wikando-ml
wants to merge
2
commits into
saintsystems:master
Choose a base branch
from
wikando:master
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| <?php | ||
|
|
||
| namespace SaintSystems\OData; | ||
|
|
||
| interface IODataEntityResponse extends IODataResponse | ||
| { | ||
| /** | ||
| * Converts the response JSON object to a OData SDK object | ||
| * | ||
| * @param mixed $returnType The type to convert the object(s) to | ||
| * | ||
| * @return mixed object or array of objects of type $returnType | ||
| */ | ||
| public function getResponseAsObject($returnType); | ||
|
|
||
| /** | ||
| * Gets the skip token of a response object from OData | ||
| * | ||
| * @return string skip token, if provided | ||
| */ | ||
| public function getSkipToken(); | ||
|
|
||
| /** | ||
| * Gets the Id of response object (if set) from OData | ||
| * | ||
| * @return mixed id if this was an insert, if provided | ||
| */ | ||
| public function getId(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,277 @@ | ||
| <?php | ||
|
|
||
| namespace SaintSystems\OData; | ||
|
|
||
| use SaintSystems\OData\Exception\ODataException; | ||
|
|
||
| class ODataBatchResponse implements IODataResponse | ||
| { | ||
| private IODataRequest $request; | ||
| private ?string $body; | ||
| /** | ||
| * @var array<string, string|array<int, string>> | ||
| */ | ||
| private array $headers; | ||
| private int $httpStatusCode; | ||
| /** | ||
| * @var array<int, ODataResponse> | ||
| */ | ||
| private array $responses; | ||
| private string $boundary; | ||
|
|
||
| /** | ||
| * @throws ODataException | ||
| */ | ||
| public function __construct(IODataRequest $request, string $body, int $httpStatusCode, array $headers = []) | ||
| { | ||
| $this->request = $request; | ||
| $this->body = $body; | ||
| $this->httpStatusCode = $httpStatusCode; | ||
| $this->headers = $headers; | ||
| $this->boundary = $this->extractBoundary(); | ||
| $this->responses = $this->parseBatchResponse(); | ||
| } | ||
|
|
||
| /** | ||
| * @throws ODataException | ||
| */ | ||
| private function extractBoundary(): string | ||
| { | ||
| $contentType = $this->getContentTypeHeader(); | ||
| if ($contentType !== null && preg_match('/^multipart\/mixed;\s*boundary=(["\']?)([^"\';]+)\1/', $contentType, $matches)) { | ||
| return $matches[2]; | ||
| } | ||
|
|
||
| if ($contentType === null) { | ||
| throw new ODataException( | ||
| 'No boundary found in batch response content-type header (content-type header is missing).' | ||
| ); | ||
| } | ||
| throw new ODataException('No boundary found in batch response content-type header: ' . $contentType); | ||
| } | ||
|
|
||
| private function getContentTypeHeader(): ?string | ||
| { | ||
| foreach ($this->headers as $key => $value) { | ||
| if (strtolower($key) === 'content-type') { | ||
| return is_array($value) ? $value[0] : $value; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| private function parseBatchResponse(): array | ||
| { | ||
| if ($this->body === null || $this->body === '') { | ||
| return []; | ||
| } | ||
|
|
||
| $responses = []; | ||
| $parts = explode('--' . $this->boundary, $this->body); | ||
|
|
||
| foreach ($parts as $part) { | ||
| $part = trim($part); | ||
| // Skip empty parts and boundary end marker | ||
| if ('' === $part || '--' === $part) { | ||
| continue; | ||
| } | ||
|
|
||
| $changesetBoundary = $this->extractChangesetBoundary($part); | ||
| if (null === $changesetBoundary) { | ||
| $responses[] = $this->parseIndividualResponse($part); | ||
| } else { | ||
| $changesetResponses = $this->parseChangesetPart($part, $changesetBoundary); | ||
| array_push($responses, ...$changesetResponses); | ||
| } | ||
| } | ||
|
|
||
| return $responses; | ||
| } | ||
|
|
||
| private function extractChangesetBoundary(string $part): ?string | ||
| { | ||
| if (preg_match('/^Content-Type:\s*multipart\/mixed;\s*boundary=["\']?([^"\'\s;]+)["\']?/i', $part, $matches)) { | ||
| return $matches[1]; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| private function parseChangesetPart(string $part, string $changesetBoundary): array | ||
| { | ||
| // Find where changeset content starts (after empty line) | ||
| $changesetContent = $this->extractChangesetContent($part); | ||
|
|
||
| // Parse individual responses within changeset | ||
| $changesetParts = explode('--' . $changesetBoundary, $changesetContent); | ||
| $responses = []; | ||
|
|
||
| foreach ($changesetParts as $changesetPart) { | ||
| $changesetPart = trim($changesetPart); | ||
| if ('' === $changesetPart || '--' === $changesetPart) { | ||
| continue; | ||
| } | ||
|
|
||
| $responses[] = $this->parseIndividualResponse($changesetPart); | ||
| } | ||
|
|
||
| return $responses; | ||
| } | ||
|
|
||
| private function extractChangesetContent(string $part): string | ||
| { | ||
| $separator = $this->detectHeaderSeparator($part); | ||
|
|
||
| $changesetParts = explode($separator, $part, 2); | ||
|
|
||
| return $changesetParts[1]; | ||
| } | ||
|
|
||
| /** | ||
| * @throws ODataException | ||
| */ | ||
| private function detectHeaderSeparator(string $part): string | ||
| { | ||
| if (strpos($part, "\r\n\r\n") !== false) { | ||
| return "\r\n\r\n"; | ||
| } | ||
| if (strpos($part, "\n\n") !== false) { | ||
| return "\n\n"; | ||
| } | ||
| throw new ODataException('No header/body separator found in changeset part: ' . $part); | ||
| } | ||
|
|
||
| private function parseIndividualResponse(string $part): ODataResponse | ||
| { | ||
| $separator = $this->detectHeaderSeparator($part); | ||
|
|
||
| $responseParts = explode($separator, $part, 3); | ||
|
|
||
| if (count($responseParts) < 2) { | ||
| throw new ODataException('Unexpected header format in response part: ' . $part); | ||
| } | ||
|
|
||
| $responseHeaders = $responseParts[0]; | ||
| $httpHeaders = $responseParts[1]; | ||
| $responseBody = $responseParts[2] ?? ''; | ||
|
|
||
| $responseHeadersResult = $this->parseHttpHeaders($responseHeaders); | ||
|
|
||
| $httpHeadersResult = $this->parseHttpHeaders($httpHeaders); | ||
| $responseHeaders = $httpHeadersResult['headers']; | ||
| $statusCode = $httpHeadersResult['statusCode']; | ||
|
|
||
| if (null === $statusCode) { | ||
| throw new ODataException('No http status code found in response part: ' . $part); | ||
| } | ||
|
|
||
| if (array_key_exists('Content-ID', $responseHeadersResult['headers'])) { | ||
| $responseHeaders['Content-ID'] = $responseHeadersResult['headers']['Content-ID']; | ||
| } | ||
|
|
||
| return new ODataResponse($this->request, $responseBody, $statusCode, $responseHeaders); | ||
| } | ||
|
|
||
| /** | ||
| * Parse HTTP headers with support for multi-line headers (header folding) | ||
| * | ||
| * @param string $headerString Raw HTTP header block | ||
| * @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 | ||
| */ | ||
| private function parseHttpHeaders(string $headerString): array | ||
| { | ||
| // Unfold headers: replace CRLF followed by whitespace with a single space | ||
| $headerString = preg_replace('/\r?\n[ \t]+/', ' ', $headerString); | ||
|
|
||
| $lines = explode("\n", $headerString); | ||
| $result = [ | ||
| 'statusCode' => null, | ||
| 'statusText' => '', | ||
| 'headers' => [] | ||
| ]; | ||
|
|
||
| foreach ($lines as $index => $line) { | ||
| $line = rtrim($line, "\r"); | ||
|
|
||
| // Try to parse first line as status line (e.g., "HTTP/1.1 412 Precondition Failed") | ||
| if (0 === $index && preg_match('/^HTTP\/\d\.\d\s+(\d{3})(\s+(.*))?$/', $line, $matches)) { | ||
| $result['statusCode'] = (int)$matches[1]; | ||
| $result['statusText'] = trim($matches[3] ?? ''); | ||
| continue; | ||
| } | ||
|
|
||
| // Skip empty lines | ||
| if (trim($line) === '') { | ||
| continue; | ||
| } | ||
|
|
||
| // Parse header line | ||
| if (strpos($line, ':') !== false) { | ||
| [$name, $value] = explode(':', $line, 2); | ||
| $name = trim($name); | ||
| $value = trim($value); | ||
|
|
||
| // Store multiple headers with same name as array | ||
| if (array_key_exists($name, $result['headers'])) { | ||
| if (!is_array($result['headers'][$name])) { | ||
| $result['headers'][$name] = [$result['headers'][$name]]; | ||
| } | ||
| $result['headers'][$name][] = $value; | ||
| } else { | ||
| $result['headers'][$name] = $value; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return $result; | ||
| } | ||
|
|
||
| /** | ||
| * Get the decoded bodies of all responses in the batch | ||
| * | ||
| * @return array<int, array> Array of decoded response bodies, where each element is the JSON-decoded body | ||
| * of an individual response in the batch. Returns empty array if no responses. | ||
| */ | ||
| public function getBody(): array | ||
| { | ||
| $bodies = []; | ||
| foreach ($this->responses as $response) { | ||
| $bodies[] = $response->getBody(); | ||
| } | ||
| return $bodies; | ||
| } | ||
|
|
||
| public function getRawBody(): ?string | ||
| { | ||
| return $this->body; | ||
| } | ||
|
|
||
| public function getStatus(): int | ||
| { | ||
| return $this->httpStatusCode; | ||
| } | ||
|
|
||
| /** | ||
| * Get the headers of the batch response | ||
| * | ||
| * @return array<string, string|array<int, string>> | ||
| */ | ||
| public function getHeaders(): array | ||
| { | ||
| return $this->headers; | ||
| } | ||
|
|
||
| /** | ||
| * Get all individual responses in the batch | ||
| * | ||
| * @return array<int, ODataResponse> | ||
| */ | ||
| public function getResponses(): array | ||
| { | ||
| return $this->responses; | ||
| } | ||
|
|
||
| public function getResponse(int $index): ?ODataResponse | ||
| { | ||
| return $this->responses[$index] ?? null; | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Scouting: This is passed directly to Guzzle, where it is interpreted as seconds, not milliseconds.