XAPI: improve requests for statements and activities state - refs BT#19078

pull/3973/head
Angel Fernando Quiroz Campos 5 years ago
parent a0d051a0b6
commit 833cf593b3
  1. 1
      plugin/xapi/.htaccess
  2. 51
      plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementGetController.php
  3. 45
      plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementPostController.php
  4. 4
      plugin/xapi/php-xapi/lrs-bundle/src/Controller/StatementPutController.php
  5. 20
      plugin/xapi/php-xapi/repository-doctrine-orm/src/StatementRepository.php
  6. 30
      plugin/xapi/src/Lrs/ActivitiesStateController.php
  7. 4
      plugin/xapi/src/Lrs/BaseController.php
  8. 205
      plugin/xapi/src/Lrs/LrsRequest.php
  9. 88
      plugin/xapi/src/Lrs/StatementsController.php

@ -0,0 +1 @@
AcceptPathInfo On

@ -17,6 +17,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Xabbuh\XApi\Common\Exception\NotFoundException;
use Xabbuh\XApi\Model\IRL;
use Xabbuh\XApi\Model\Statement;
use Xabbuh\XApi\Model\StatementId;
use Xabbuh\XApi\Model\StatementResult;
@ -30,9 +31,9 @@ use XApi\Repository\Api\StatementRepositoryInterface;
/**
* @author Jérôme Parmentier <jerome.parmentier@acensi.fr>
*/
final class StatementGetController
class StatementGetController
{
private static $getParameters = [
protected static $getParameters = [
'statementId' => true,
'voidedStatementId' => true,
'agent' => true,
@ -47,12 +48,13 @@ final class StatementGetController
'format' => true,
'attachments' => true,
'ascending' => true,
'cursor' => true,
];
private $repository;
private $statementSerializer;
private $statementResultSerializer;
private $statementsFilterFactory;
protected $repository;
protected $statementSerializer;
protected $statementResultSerializer;
protected $statementsFilterFactory;
public function __construct(StatementRepositoryInterface $repository, StatementSerializerInterface $statementSerializer, StatementResultSerializerInterface $statementResultSerializer, StatementsFilterFactory $statementsFilterFactory)
{
@ -86,14 +88,19 @@ final class StatementGetController
} else {
$statements = $this->repository->findStatementsBy($this->statementsFilterFactory->createFromParameterBag($query));
$response = $this->buildMultiStatementsResponse($statements, $includeAttachments);
$response = $this->buildMultiStatementsResponse($statements, $query, $includeAttachments);
}
} catch (NotFoundException $e) {
$response = $this->buildMultiStatementsResponse([]);
$response = $this->buildMultiStatementsResponse([], $query)
->setStatusCode(Response::HTTP_NOT_FOUND)
->setContent('');
} catch (\Exception $exception) {
$response = Response::create('', Response::HTTP_BAD_REQUEST);
}
$now = new \DateTime();
$response->headers->set('X-Experience-API-Consistent-Through', $now->format(\DateTime::ATOM));
$response->headers->set('Content-Type', 'application/json');
return $response;
}
@ -103,11 +110,11 @@ final class StatementGetController
*
* @return JsonResponse|MultipartResponse
*/
private function buildSingleStatementResponse(Statement $statement, $includeAttachments = false)
protected function buildSingleStatementResponse(Statement $statement, $includeAttachments = false)
{
$json = $this->statementSerializer->serializeStatement($statement);
$response = new JsonResponse($json, 200, [], true);
$response = new Response($json, 200);
if ($includeAttachments) {
$response = $this->buildMultipartResponse($response, [$statement]);
@ -124,11 +131,15 @@ final class StatementGetController
*
* @return JsonResponse|MultipartResponse
*/
private function buildMultiStatementsResponse(array $statements, $includeAttachments = false)
protected function buildMultiStatementsResponse(array $statements, ParameterBag $query, $includeAttachments = false)
{
$json = $this->statementResultSerializer->serializeStatementResult(new StatementResult($statements));
$moreUrlPath = $statements ? $this->generateMoreIrl($query) : null;
$response = new JsonResponse($json, 200, [], true);
$json = $this->statementResultSerializer->serializeStatementResult(
new StatementResult($statements, $moreUrlPath)
);
$response = new Response($json, 200);
if ($includeAttachments) {
$response = $this->buildMultipartResponse($response, $statements);
@ -142,7 +153,7 @@ final class StatementGetController
*
* @return MultipartResponse
*/
private function buildMultipartResponse(JsonResponse $statementResponse, array $statements)
protected function buildMultipartResponse(JsonResponse $statementResponse, array $statements)
{
$attachmentsParts = [];
@ -160,7 +171,7 @@ final class StatementGetController
*
* @throws BadRequestHttpException if the parameters does not comply with the xAPI specification
*/
private function validate(ParameterBag $query)
protected function validate(ParameterBag $query)
{
$hasStatementId = $query->has('statementId');
$hasVoidedStatementId = $query->has('voidedStatementId');
@ -185,4 +196,14 @@ final class StatementGetController
throw new BadRequestHttpException('Request must not contain statementId or voidedStatementId parameters, and also any other parameter besides "attachments" or "format".');
}
}
protected function generateMoreIrl(ParameterBag $query): IRL
{
$params = $query->all();
$params['cursor'] = empty($params['cursor']) ? 1 : $params['cursor'] + 1;
return IRL::fromString(
'/plugin/xapi/lrs.php/statements?'.http_build_query($params)
);
}
}

@ -11,22 +11,57 @@
namespace XApi\LrsBundle\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Xabbuh\XApi\Common\Exception\NotFoundException;
use Xabbuh\XApi\Model\Statement;
use XApi\Repository\Api\StatementRepositoryInterface;
/**
* @author Jérôme Parmentier <jerome.parmentier@acensi.fr>
*/
final class StatementPostController
{
public function postStatement(Request $request, Statement $statement)
/**
* @var StatementRepositoryInterface
*/
private $repository;
public function __construct(StatementRepositoryInterface $repository)
{
$this->repository = $repository;
}
/**
* @param Statement[] $statements
*/
public function postStatements(Request $request, array $statements)
public function postStatements(Request $request, array $statements): JsonResponse
{
$statementsToStore = [];
/** @var Statement $statement */
foreach ($statements as $statement) {
if (null === $statementId = $statement->getId()) {
$statementsToStore[] = $statement;
continue;
}
try {
$existingStatement = $this->repository->findStatementById($statement->getId());
if (!$existingStatement->equals($statement)) {
throw new ConflictHttpException('The new statement is not equal to an existing statement with the same id.');
}
} catch (NotFoundException $e) {
$statementsToStore[] = $statement;
}
}
$uuids = [];
foreach ($statementsToStore as $statement) {
$uuids[] = $this->repository->storeStatement($statement, true)->getValue();
}
return new JsonResponse($uuids);
}
}

@ -32,7 +32,7 @@ final class StatementPutController
$this->repository = $repository;
}
public function putStatement(Request $request, Statement $statement)
public function putStatement(Request $request, Statement $statement): Response
{
if (null === $statementId = $request->query->get('statementId')) {
throw new BadRequestHttpException('Required statementId parameter is missing.');
@ -55,6 +55,8 @@ final class StatementPutController
throw new ConflictHttpException('The new statement is not equal to an existing statement with the same id.');
}
} catch (NotFoundException $e) {
$statement = $statement->withId($id);
$this->repository->storeStatement($statement, true);
}

@ -12,6 +12,7 @@
namespace XApi\Repository\ORM;
use Doctrine\ORM\EntityRepository;
use XApi\Repository\Doctrine\Mapping\Context;
use XApi\Repository\Doctrine\Mapping\Statement;
use XApi\Repository\Doctrine\Repository\Mapping\StatementRepository as BaseStatementRepository;
@ -33,6 +34,25 @@ final class StatementRepository extends EntityRepository implements BaseStatemen
*/
public function findStatements(array $criteria)
{
if (!empty($criteria['registration'])) {
$context = $this->_em->getRepository(Context::class)->findOneBy([
'registration' => $criteria['registration']
]);
unset(
$criteria['registration']
);
$criteria['context'] = $context;
}
unset(
$criteria['related_activities'],
$criteria['related_agents'],
$criteria['ascending'],
$criteria['limit']
);
return parent::findBy($criteria);
}

@ -1,9 +1,8 @@
<?php
/* For licensing terms, see /license.txt */
namespace Chamilo\PluginBundle\XApi\Lrs;
/* For licensing terms, see /license.txt */
use Chamilo\PluginBundle\Entity\XApi\ActivityState;
use Database;
use Symfony\Component\HttpFoundation\JsonResponse;
@ -12,16 +11,13 @@ use Xabbuh\XApi\Model\Actor;
use Xabbuh\XApi\Serializer\Symfony\Serializer;
/**
* Class ActivitiesController.
* Class ActivitiesStateController.
*
* @package Chamilo\PluginBundle\XApi\Lrs
*/
class ActivitiesController extends BaseController
class ActivitiesStateController extends BaseController
{
/**
* @return \Symfony\Component\HttpFoundation\JsonResponse
*/
public function get()
public function get(): Response
{
$serializer = Serializer::createSerializer();
@ -70,13 +66,17 @@ class ActivitiesController extends BaseController
return JsonResponse::create($documentData);
}
/**
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*
* @return \Symfony\Component\HttpFoundation\Response
*/
public function put()
public function head(): Response
{
return $this->get()->setContent('');
}
public function post(): Response
{
return $this->put();
}
public function put(): Response
{
$activityId = $this->httpRequest->query->get('activityId');
$agent = $this->httpRequest->query->get('agent');

@ -21,8 +21,8 @@ abstract class BaseController
/**
* BaseController constructor.
*/
public function __construct()
public function __construct(Request $httpRequest)
{
$this->httpRequest = Request::createFromGlobals();
$this->httpRequest = $httpRequest;
}
}

@ -5,10 +5,14 @@
namespace Chamilo\PluginBundle\XApi\Lrs;
use Chamilo\PluginBundle\Entity\XApi\LrsAuth;
use Database;
use Symfony\Component\HttpFoundation\Request as HttpRequest;
use Symfony\Component\HttpFoundation\Response as HttpResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Xabbuh\XApi\Common\Exception\AccessDeniedException;
use Xabbuh\XApi\Common\Exception\XApiException;
/**
* Class LrsRequest.
@ -32,56 +36,105 @@ class LrsRequest
public function send()
{
$this->validAuth();
try {
$this->alternateRequestSyntax();
$controllerName = $this->getControllerName();
$methodName = $this->getMethodName();
$response = $this->generateResponse($controllerName, $methodName);
} catch (XApiException $xApiException) {
$response = HttpResponse::create('', HttpResponse::HTTP_BAD_REQUEST);
} catch (HttpException $httpException) {
$response = HttpResponse::create(
$httpException->getMessage(),
$httpException->getStatusCode()
);
} catch (\Exception $exception) {
$response = HttpResponse::create($exception->getMessage(), HttpResponse::HTTP_BAD_REQUEST);
}
$version = $this->request->headers->get('X-Experience-API-Version');
$response->headers->set('X-Experience-API-Version', '1.0.3');
if (null === $version) {
throw new BadRequestHttpException('The "X-Experience-API-Version" header is required.');
$response->send();
}
/**
* @throws \Xabbuh\XApi\Common\Exception\AccessDeniedException
*/
private function validateAuth(): bool
{
if (!$this->request->headers->has('Authorization')) {
throw new AccessDeniedException();
}
if (!$this->isValidVersion($version)) {
throw new BadRequestHttpException("The xAPI version \"$version\" is not supported.");
$authHeader = $this->request->headers->get('Authorization');
$parts = explode('Basic ', $authHeader, 2);
if (empty($parts[1])) {
throw new AccessDeniedException();
}
$controllerName = $this->getControllerName();
$methodName = $this->getMethodName();
$authDecoded = base64_decode($parts[1]);
if ($controllerName
&& class_exists($controllerName)
&& method_exists($controllerName, $methodName)
) {
/** @var HttpResponse $response */
$response = call_user_func([new $controllerName(), $methodName]);
} else {
$response = HttpResponse::create('Not Found', HttpResponse::HTTP_NOT_FOUND);
$parts = explode(':', $authDecoded, 2);
if (empty($parts) || count($parts) !== 2) {
throw new AccessDeniedException();
}
$response->headers->set('X-Experience-API-Version', '1.0.3');
list($username, $password) = $parts;
$response->send();
$auth = Database::getManager()
->getRepository(LrsAuth::class)
->findOneBy(
['username' => $username, 'password' => $password, 'enabled' => true]
);
if (null == $auth) {
throw new AccessDeniedException();
}
return true;
}
/**
* @return string|null
*/
private function getControllerName()
private function validateVersion()
{
$version = $this->request->headers->get('X-Experience-API-Version');
if (null === $version) {
throw new BadRequestHttpException('The "X-Experience-API-Version" header is required.');
}
if (preg_match('/^1\.0(?:\.\d+)?$/', $version)) {
if ('1.0' === $version) {
$this->request->headers->set('X-Experience-API-Version', '1.0.0');
}
return;
}
throw new BadRequestHttpException("The xAPI version \"$version\" is not supported.");
}
private function getControllerName(): ?string
{
$segments = explode('/', $this->request->getPathInfo());
$segments = array_filter($segments);
$segments = array_values($segments);
if (empty($segments[1])) {
return null;
if (empty($segments)) {
throw new BadRequestHttpException('Bad request');
}
$controllerName = ucfirst($segments[1]).'Controller';
$segments = array_map('ucfirst', $segments);
$controllerName = implode('', $segments).'Controller';
return "Chamilo\\PluginBundle\\XApi\Lrs\\$controllerName";
}
/**
* @return string
*/
private function getMethodName()
private function getMethodName(): string
{
$method = $this->request->getMethod();
@ -89,60 +142,80 @@ class LrsRequest
}
/**
* @param string $version
*
* @return bool
* @throws \Xabbuh\XApi\Common\Exception\AccessDeniedException
*/
private function isValidVersion($version)
private function generateResponse(string $controllerName, string $methodName): HttpResponse
{
if (preg_match('/^1\.0(?:\.\d+)?$/', $version)) {
if ('1.0' === $version) {
$this->request->headers->set('X-Experience-API-Version', '1.0.0');
}
if (!class_exists($controllerName)
|| !method_exists($controllerName, $methodName)
) {
throw new NotFoundHttpException();
}
return true;
if ($controllerName !== AboutController::class) {
$this->validateAuth();
$this->validateVersion();
}
return false;
/** @var HttpResponse $response */
$response = call_user_func(
[
new $controllerName($this->request),
$methodName,
]
);
return $response;
}
/**
* @return bool
*/
private function validAuth()
private function alternateRequestSyntax()
{
if (!$this->request->headers->has('Authorization')) {
throw new AccessDeniedHttpException();
if ('POST' !== $this->request->getMethod()) {
return;
}
$authHeader = $this->request->headers->get('Authorization');
$parts = explode('Basic ', $authHeader, 2);
if (null === $method = $this->request->query->get('method')) {
return;
}
if (empty($parts[1])) {
throw new AccessDeniedHttpException();
if ($this->request->query->count() > 1) {
throw new BadRequestHttpException('Including other query parameters than "method" is not allowed. You have to send them as POST parameters inside the request body.');
}
$authDecoded = base64_decode($parts[1]);
$this->request->setMethod($method);
$this->request->query->remove('method');
$parts = explode(':', $authDecoded, 2);
if (null !== $content = $this->request->request->get('content')) {
$this->request->request->remove('content');
if (empty($parts) || count($parts) !== 2) {
throw new AccessDeniedHttpException();
$this->request->initialize(
$this->request->query->all(),
$this->request->request->all(),
$this->request->attributes->all(),
$this->request->cookies->all(),
$this->request->files->all(),
$this->request->server->all(),
$content
);
}
list($username, $password) = $parts;
$auth = \Database::getManager()
->getRepository(LrsAuth::class)
->findOneBy(
['username' => $username, 'password' => $password, 'enabled' => true]
);
$headerNames = [
'Authorization',
'X-Experience-API-Version',
'Content-Type',
'Content-Length',
'If-Match',
'If-None-Match',
];
foreach ($this->request->request as $key => $value) {
if (in_array($key, $headerNames, true)) {
$this->request->headers->set($key, $value);
} else {
$this->request->query->set($key, $value);
}
if (null == $auth) {
throw new AccessDeniedHttpException();
$this->request->request->remove($key);
}
return true;
}
}

@ -4,9 +4,16 @@
namespace Chamilo\PluginBundle\XApi\Lrs;
use Symfony\Component\HttpFoundation\Response;
use Xabbuh\XApi\Model\Statement;
use Xabbuh\XApi\Serializer\Symfony\ActorSerializer;
use Xabbuh\XApi\Serializer\Symfony\Serializer;
use Xabbuh\XApi\Serializer\Symfony\SerializerFactory;
use XApi\LrsBundle\Controller\StatementGetController;
use XApi\LrsBundle\Controller\StatementHeadController;
use XApi\LrsBundle\Controller\StatementPostController;
use XApi\LrsBundle\Controller\StatementPutController;
use XApi\LrsBundle\Model\StatementsFilterFactory;
use XApi\Repository\Doctrine\Mapping\Statement as StatementEntity;
use XApi\Repository\Doctrine\Repository\StatementRepository;
use XApiPlugin;
@ -18,6 +25,48 @@ use XApiPlugin;
*/
class StatementsController extends BaseController
{
public function get(): Response
{
$pluginEm = XApiPlugin::getEntityManager();
$serializer = Serializer::createSerializer();
$factory = new SerializerFactory($serializer);
$getStatementController = new StatementGetController(
new StatementRepository(
$pluginEm->getRepository(StatementEntity::class)
),
$factory->createStatementSerializer(),
$factory->createStatementResultSerializer(),
new StatementsFilterFactory(
new ActorSerializer($serializer)
)
);
return $getStatementController->getStatement($this->httpRequest);
}
public function head(): Response
{
$pluginEm = XApiPlugin::getEntityManager();
$serializer = Serializer::createSerializer();
$factory = new SerializerFactory($serializer);
$headStatementController = new StatementHeadController(
new StatementRepository(
$pluginEm->getRepository(StatementEntity::class)
),
$factory->createStatementSerializer(),
$factory->createStatementResultSerializer(),
new StatementsFilterFactory(
new ActorSerializer($serializer)
)
);
return $headStatementController->getStatement($this->httpRequest);
}
/**
* @return \Symfony\Component\HttpFoundation\Response
*/
@ -38,15 +87,38 @@ class StatementsController extends BaseController
return $putStatementController->putStatement($this->httpRequest, $statement);
}
/**
* @param string $content
*
* @return \Xabbuh\XApi\Model\Statement
*/
private function deserializeStatement($content)
public function post(): Response
{
$serializer = Serializer::createSerializer();
$pluginEm = XApiPlugin::getEntityManager();
$postStatementController = new StatementPostController(
new StatementRepository(
$pluginEm->getRepository(StatementEntity::class)
)
);
$content = $this->httpRequest->getContent();
if (substr($content, 0, 1) !== '[') {
$content = "[$content]";
}
$statements = $this->deserializeStatements($content);
return $postStatementController->postStatements($this->httpRequest, $statements);
}
private function deserializeStatement(string $content = ''): Statement
{
$factory = new SerializerFactory(Serializer::createSerializer());
return $factory->createStatementSerializer()->deserializeStatement($content);
}
private function deserializeStatements(string $content = ''): array
{
$factory = new SerializerFactory(Serializer::createSerializer());
return $serializer->deserialize($content, Statement::class, 'json');
return $factory->createStatementSerializer()->deserializeStatements($content);
}
}

Loading…
Cancel
Save