From c3f3356c83727a79c4cef00c0a53e0e30444e437 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Sat, 8 Oct 2016 00:14:44 +0200 Subject: [PATCH] adding support to send and fetch attachments --- .travis.yml | 5 +- CHANGELOG.md | 13 +++ UPGRADE.md | 8 ++ composer.json | 7 +- src/Api/StatementsApiClient.php | 116 +++++++++++++++++++++-- src/Api/StatementsApiClientInterface.php | 11 ++- src/Http/MultipartStatementBody.php | 65 +++++++++++++ src/Request/Handler.php | 15 +-- src/Request/HandlerInterface.php | 4 +- tests/Api/ApiClientTest.php | 1 + tests/Api/StatementsApiClientTest.php | 8 +- 11 files changed, 224 insertions(+), 29 deletions(-) create mode 100644 src/Http/MultipartStatementBody.php diff --git a/.travis.yml b/.travis.yml index e113b89..0ece38e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,7 +19,7 @@ matrix: fast_finish: true include: - php: 5.4 - env: deps="low" + env: $COMPOSER_OPTIONS="--prefer-lowest --ignore-platform-reqs" - php: 5.6 env: PACKAGES="php-http/discovery:^1.0 php-http/guzzle6-adapter:^1.0 php-http/message:^1.0" - php: 7.0 @@ -31,8 +31,7 @@ before_install: install: - if [ "$PACKAGES" != "" ]; then composer require --no-update $PACKAGES; fi - - if [ "$deps" = "low" ]; then composer update --prefer-lowest --prefer-stable --ignore-platform-reqs; fi - - if [ "$deps" = "" ]; then composer install; fi + - composer update --prefer-stable $COMPOSER_OPTIONS script: - vendor/bin/phpspec run diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cdff56..096c6d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,19 @@ CHANGELOG * Bumped the required versions of all `php-xapi` packages to the `1.x` release series. +* Include the raw attachment content wrapped in a `multipart/mixed` encoded + request when raw content is part of a statement's attachment. + +* Added the possibility to decide whether or not to include attachments when + requesting statements from an LRS. A second optional `$attachments` argument + (defaulting to `true`) has been added for this purpose to the `getStatement()`, + `getVoidedStatement()`, and `getStatements()` methods of the `StatementsApiClient` + class and the `StatementsApiClientInterface`. + +* An optional fifth `$headers` parameter has been added to the `createRequest()` + method of the `HandlerInterface` and the `Handler` class which allows to pass + custom headers when performing HTTP requests. + 0.4.0 ----- diff --git a/UPGRADE.md b/UPGRADE.md index b473170..c51870e 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -43,6 +43,14 @@ Upgrading from 0.4 to 0.5 You can avoid calling `setHttpClient()` and `setRequestFactory` by installing the [HTTP discovery](http://php-http.org/en/latest/discovery.html) package. +* A second optional `$attachments` argument (defaulting to `true`) has been added + to the `getStatement()`, `getVoidedStatement()`, and `getStatements()` methods + of the `StatementsApiClient` class and the `StatementsApiClientInterface`. + +* An optional fifth `$headers` parameter has been added to the `createRequest()` + method of the `HandlerInterface` and the `Handler` class which allows to pass + custom headers when performing HTTP requests. + Upgrading from 0.2 to 0.3 ------------------------- diff --git a/composer.json b/composer.json index d7ab972..528e734 100644 --- a/composer.json +++ b/composer.json @@ -20,9 +20,9 @@ "php-http/message-factory": "^1.0", "php-xapi/exception": "^0.1.0", "php-xapi/model": "^1.0", - "php-xapi/serializer": "^1.0", - "php-xapi/serializer-implementation": "^1.0", - "php-xapi/symfony-serializer": "^1.0", + "php-xapi/serializer": "^2.0", + "php-xapi/serializer-implementation": "^2.0", + "php-xapi/symfony-serializer": "^2.0", "psr/http-message": "^1.0" }, "require-dev": { @@ -30,6 +30,7 @@ "php-http/mock-client": "^0.3", "php-xapi/test-fixtures": "^1.0" }, + "minimum-stability": "dev", "suggest": { "php-http/discovery": "For automatic discovery of HTTP clients and request factories" }, diff --git a/src/Api/StatementsApiClient.php b/src/Api/StatementsApiClient.php index a83476a..ebd05af 100644 --- a/src/Api/StatementsApiClient.php +++ b/src/Api/StatementsApiClient.php @@ -11,6 +11,7 @@ namespace Xabbuh\XApi\Client\Api; +use Xabbuh\XApi\Client\Http\MultipartStatementBody; use Xabbuh\XApi\Client\Request\HandlerInterface; use Xabbuh\XApi\Model\StatementId; use Xabbuh\XApi\Serializer\ActorSerializerInterface; @@ -102,23 +103,29 @@ public function voidStatement(Statement $statement, Actor $actor) /** * {@inheritDoc} */ - public function getStatement(StatementId $statementId) + public function getStatement(StatementId $statementId, $attachments = true) { - return $this->doGetStatements('statements', array('statementId' => $statementId->getValue())); + return $this->doGetStatements('statements', array( + 'statementId' => $statementId->getValue(), + 'attachments' => $attachments ? 'true' : 'false', + )); } /** * {@inheritDoc} */ - public function getVoidedStatement(StatementId $statementId) + public function getVoidedStatement(StatementId $statementId, $attachments = true) { - return $this->doGetStatements('statements', array('voidedStatementId' => $statementId->getValue())); + return $this->doGetStatements('statements', array( + 'voidedStatementId' => $statementId->getValue(), + 'attachments' => $attachments ? 'true' : 'false', + )); } /** * {@inheritDoc} */ - public function getStatements(StatementsFilter $filter = null) + public function getStatements(StatementsFilter $filter = null, $attachments = true) { $urlParameters = array(); @@ -152,17 +159,50 @@ public function getNextStatements(StatementResult $statementResult) */ private function doStoreStatements($statements, $method = 'post', $parameters = array(), $validStatusCode = 200) { + $attachments = array(); + if (is_array($statements)) { + foreach ($statements as $statement) { + if (null !== $statement->getAttachments()) { + foreach ($statement->getAttachments() as $attachment) { + if ($attachment->getContent()) { + $attachments[] = $attachment; + } + } + } + } + $serializedStatements = $this->statementSerializer->serializeStatements($statements); } else { + if (null !== $statements->getAttachments()) { + foreach ($statements->getAttachments() as $attachment) { + if ($attachment->getContent()) { + $attachments[] = $attachment; + } + } + } + $serializedStatements = $this->statementSerializer->serializeStatement($statements); } + $headers = array(); + + if (!empty($attachments)) { + $builder = new MultipartStatementBody($serializedStatements, $attachments); + $headers = array( + 'Content-Type' => 'multipart/mixed; boundary='.$builder->getBoundary(), + ); + $body = $builder->build(); + } else { + $body = $serializedStatements; + } + $request = $this->requestHandler->createRequest( $method, 'statements', $parameters, - $serializedStatements + $body, + $headers ); $response = $this->requestHandler->executeRequest($request, array($validStatusCode)); $statementIds = json_decode((string) $response->getBody()); @@ -200,10 +240,70 @@ private function doGetStatements($url, array $urlParameters = array()) $request = $this->requestHandler->createRequest('get', $url, $urlParameters); $response = $this->requestHandler->executeRequest($request, array(200)); + $contentType = $response->getHeader('Content-Type')[0]; + $body = (string) $response->getBody(); + $attachments = array(); + + if (false !== strpos($contentType, 'application/json')) { + $serializedStatement = $body; + } else { + $boundary = substr($contentType, strpos($contentType, '=') + 1); + $parts = $this->parseMultipartResponseBody($body, $boundary); + $serializedStatement = $parts[0]['content']; + + unset($parts[0]); + + foreach ($parts as $part) { + $attachments[$part['headers']['X-Experience-API-Hash'][0]] = array( + 'type' => $part['headers']['Content-Type'][0], + 'content' => $part['content'], + ); + } + } + if (isset($urlParameters['statementId']) || isset($urlParameters['voidedStatementId'])) { - return $this->statementSerializer->deserializeStatement((string) $response->getBody()); + return $this->statementSerializer->deserializeStatement($serializedStatement, $attachments); } else { - return $this->statementResultSerializer->deserializeStatementResult((string) $response->getBody()); + return $this->statementResultSerializer->deserializeStatementResult($serializedStatement, $attachments); } } + + private function parseMultipartResponseBody($body, $boundary) + { + $parts = array(); + $lines = explode("\r\n", $body); + $currentPart = null; + $isHeaderLine = true; + + foreach ($lines as $line) { + if (false !== strpos($line, '--'.$boundary)) { + if (null !== $currentPart) { + $parts[] = $currentPart; + } + + $currentPart = array( + 'headers' => array(), + 'content' => '', + ); + $isBoundaryLine = true; + $isHeaderLine = true; + } else { + $isBoundaryLine = false; + } + + if ('' === $line) { + $isHeaderLine = false; + continue; + } + + if (!$isBoundaryLine && !$isHeaderLine) { + $currentPart['content'] .= $line; + } elseif (!$isBoundaryLine && $isHeaderLine) { + list($name, $value) = explode(':', $line, 2); + $currentPart['headers'][$name][] = $value; + } + } + + return $parts; + } } diff --git a/src/Api/StatementsApiClientInterface.php b/src/Api/StatementsApiClientInterface.php index d27e1a9..6e2e66b 100644 --- a/src/Api/StatementsApiClientInterface.php +++ b/src/Api/StatementsApiClientInterface.php @@ -73,36 +73,39 @@ public function voidStatement(Statement $statement, Actor $actor); * Retrieves a single {@link Statement Statement}. * * @param StatementId $statementId The Statement id + * @param bool $attachments Whether or not to request raw attachment data * * @return Statement The Statement * * @throws NotFoundException if no statement with the given id could be found * @throws XApiException for all other xAPI related problems */ - public function getStatement(StatementId $statementId); + public function getStatement(StatementId $statementId, $attachments = true); /** * Retrieves a voided {@link Statement Statement}. * * @param StatementId $statementId The id of the voided Statement + * @param bool $attachments Whether or not to request raw attachment data * * @return Statement The voided Statement * * @throws NotFoundException if no statement with the given id could be found * @throws XApiException for all other xAPI related problems */ - public function getVoidedStatement(StatementId $statementId); + public function getVoidedStatement(StatementId $statementId, $attachments = true); /** * Retrieves a collection of {@link Statement Statements}. * - * @param StatementsFilter $filter Optional Statements filter + * @param StatementsFilter $filter Optional Statements filter + * @param bool $attachments Whether or not to request raw attachment data * * @return StatementResult The {@link StatementResult} * * @throws XApiException in case of any problems related to the xAPI */ - public function getStatements(StatementsFilter $filter = null); + public function getStatements(StatementsFilter $filter = null, $attachments = true); /** * Returns the next {@link Statement Statements} for a limited Statement diff --git a/src/Http/MultipartStatementBody.php b/src/Http/MultipartStatementBody.php new file mode 100644 index 0000000..7f9f454 --- /dev/null +++ b/src/Http/MultipartStatementBody.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Xabbuh\XApi\Client\Http; + +use Xabbuh\XApi\Model\Attachment; + +/** + * HTTP message body containing serialized statements and their attachments. + * + * @author Christian Flothmann + */ +final class MultipartStatementBody +{ + private $boundary; + private $serializedStatements; + private $attachments; + + /** + * @param string $serializedStatements The JSON encoded statement(s) + * @param Attachment[] $attachments The statement attachments that include not only a file URL + */ + public function __construct($serializedStatements, array $attachments) + { + $this->boundary = uniqid(); + $this->serializedStatements = $serializedStatements; + $this->attachments = $attachments; + } + + public function getBoundary() + { + return $this->boundary; + } + + public function build() + { + $body = '--'.$this->boundary."\r\n"; + $body .= "Content-Type: application/json\r\n"; + $body .= 'Content-Length: '.strlen($this->serializedStatements)."\r\n"; + $body .= "\r\n"; + $body .= $this->serializedStatements."\r\n"; + + foreach ($this->attachments as $attachment) { + $body .= '--'.$this->boundary."\r\n"; + $body .= 'Content-Type: '.$attachment->getContentType()."\r\n"; + $body .= "Content-Transfer-Encoding: binary\r\n"; + $body .= 'Content-Length: '.$attachment->getLength()."\r\n"; + $body .= 'X-Experience-API-Hash: '.$attachment->getSha2()."\r\n"; + $body .= "\r\n"; + $body .= $attachment->getContent()."\r\n"; + } + + $body .= '--'.$this->boundary.'--'."\r\n"; + + return $body; + } +} diff --git a/src/Request/Handler.php b/src/Request/Handler.php index 53f3bfe..88f26f4 100644 --- a/src/Request/Handler.php +++ b/src/Request/Handler.php @@ -49,7 +49,7 @@ public function __construct(HttpClient $httpClient, RequestFactory $requestFacto /** * {@inheritDoc} */ - public function createRequest($method, $uri, array $urlParameters = array(), $body = null) + public function createRequest($method, $uri, array $urlParameters = array(), $body = null, array $headers = array()) { if (!in_array(strtoupper($method), array('GET', 'POST', 'PUT', 'DELETE'))) { throw new \InvalidArgumentException(sprintf('"%s" is no valid HTTP method (expected one of [GET, POST, PUT, DELETE]) in an xAPI context.', $method)); @@ -61,12 +61,15 @@ public function createRequest($method, $uri, array $urlParameters = array(), $bo $uri .= '?'.http_build_query($urlParameters); } - $request = $this->requestFactory->createRequest(strtoupper($method), $uri, array( - 'X-Experience-API-Version' => $this->version, - 'Content-Type' => 'application/json', - ), $body); + if (!isset($headers['X-Experience-API-Version'])) { + $headers['X-Experience-API-Version'] = $this->version; + } + + if (!isset($headers['Content-Type'])) { + $headers['Content-Type'] = 'application/json'; + } - return $request; + return $this->requestFactory->createRequest(strtoupper($method), $uri, $headers, $body); } /** diff --git a/src/Request/HandlerInterface.php b/src/Request/HandlerInterface.php index f07ea6f..7e63d8a 100644 --- a/src/Request/HandlerInterface.php +++ b/src/Request/HandlerInterface.php @@ -13,6 +13,7 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; use Xabbuh\XApi\Common\Exception\XApiException; /** @@ -27,12 +28,13 @@ interface HandlerInterface * @param string $uri The URI to send the request to * @param array $urlParameters Optional url parameters * @param string $body An optional request body + * @param array $headers Optional additional HTTP headers * * @return RequestInterface The request * * @throws \InvalidArgumentException when no valid HTTP method is given */ - public function createRequest($method, $uri, array $urlParameters = array(), $body = null); + public function createRequest($method, $uri, array $urlParameters = array(), $body = null, array $headers = array()); /** * Performs the given HTTP request. diff --git a/tests/Api/ApiClientTest.php b/tests/Api/ApiClientTest.php index 9632eb1..74ea88a 100644 --- a/tests/Api/ApiClientTest.php +++ b/tests/Api/ApiClientTest.php @@ -93,6 +93,7 @@ protected function validateRetrieveApiCall($method, $uri, array $urlParameters, $rawResponse = 'the-server-response'; $response = $this->getMockBuilder('\Psr\Http\Message\ResponseInterface')->getMock(); $response->expects($this->any())->method('getStatusCode')->willReturn($statusCode); + $response->expects($this->any())->method('getHeader')->with('Content-Type')->willReturn(array('application/json')); $response->expects($this->any())->method('getBody')->willReturn($rawResponse); $request = $this->validateRequest($method, $uri, $urlParameters); diff --git a/tests/Api/StatementsApiClientTest.php b/tests/Api/StatementsApiClientTest.php index 64e32c2..fb155aa 100644 --- a/tests/Api/StatementsApiClientTest.php +++ b/tests/Api/StatementsApiClientTest.php @@ -193,7 +193,7 @@ public function testGetStatement() $this->validateRetrieveApiCall( 'get', 'statements', - array('statementId' => $statementId), + array('statementId' => $statementId, 'attachments' => 'true'), 200, 'Statement', $statement @@ -211,7 +211,7 @@ public function testGetStatementWithNotExistingStatement() $this->validateRetrieveApiCall( 'get', 'statements', - array('statementId' => $statementId), + array('statementId' => $statementId, 'attachments' => 'true'), 404, 'Statement', 'There is no statement associated with this id' @@ -227,7 +227,7 @@ public function testGetVoidedStatement() $this->validateRetrieveApiCall( 'get', 'statements', - array('voidedStatementId' => $statementId), + array('voidedStatementId' => $statementId, 'attachments' => 'true'), 200, 'Statement', $statement @@ -245,7 +245,7 @@ public function testGetVoidedStatementWithNotExistingStatement() $this->validateRetrieveApiCall( 'get', 'statements', - array('voidedStatementId' => $statementId), + array('voidedStatementId' => $statementId, 'attachments' => 'true'), 404, 'Statement', 'There is no statement associated with this id'