diff --git a/src/Controller/Controller.php b/src/Controller/Controller.php index 9f9bf83..e2fb87e 100644 --- a/src/Controller/Controller.php +++ b/src/Controller/Controller.php @@ -217,7 +217,6 @@ function ($d) { $model ); - if ($parsed !== null) { //overwrite if ($overwrite @@ -238,6 +237,12 @@ function ($d) { return $directives; } + /** + * @param $object + * @param ISource|null $source + * @param string[] ...$properties + * @throws MissingParametersException + */ public static function requireProperties( $object, ISource $source = null, diff --git a/src/Controller/Helper/RequestBodyQueue.php b/src/Controller/Helper/RequestBodyQueue.php index f2a2dfa..e18b763 100644 --- a/src/Controller/Helper/RequestBodyQueue.php +++ b/src/Controller/Helper/RequestBodyQueue.php @@ -17,42 +17,217 @@ */ namespace Phramework\JSONAPI\Controller\Helper; +use Phramework\Exceptions\MissingParametersException; +use Phramework\Exceptions\NotFoundException; +use Phramework\Exceptions\RequestException; +use Phramework\Exceptions\Source\ISource; +use Phramework\Exceptions\Source\Pointer; +use Phramework\JSONAPI\Relationship; +use Phramework\JSONAPI\ResourceModel; use Phramework\JSONAPI\ValidationModel; +use Phramework\Validate\ArrayValidator; +use Phramework\Validate\EnumValidator; +use Phramework\Validate\ObjectValidator; +use Phramework\Validate\StringValidator; /** * @license https://www.apache.org/licenses/LICENSE-2.0 Apache-2.0 * @author Xenofon Spafaridis * @since 3.0.0 */ -trait RequestBodyQueue +abstract class RequestBodyQueue { /** * @todo + * @param \stdClass $resource Primary data resource */ public static function handleResource( \stdClass $resource, + ISource $source, + ResourceModel $model, ValidationModel $validationModel, array $validationCallbacks = [] - ) { - $attributes = $validationModel->getAttributes()->parse( - $resource->attributes ?? new \stdClass() - ); + ) + { + //Fetch request attributes + $requestAttributes = $resource->attributes ?? new \stdClass(); + $requestRelationships = $resource->relationships ?? new \stdClass(); + + /* + * Validate attributes against attributes validator + */ + $parsedAttributes = $validationModel->getAttributes() + ->setSource(new Pointer($source->getPath() . '/attributes')) + ->parse( + $requestAttributes + ); + + //$parsedRelationships = new \stdClass(); - //getParsedRelationshipAttributes + /** + * Format, object with + * - relationshipKey1 -> id1 + * - relationshipKey2 -> [id1, id2] + */ $relationships = new \stdClass(); - //todo - //Call Validation callbacks + /** + * Foreach request relationship + * - check if relationship exists + * - if TYPE_TO_ONE check if data is object with type and id + * - if TYPE_TO_MANY check if data is an array of objects with type and id + * - check if types are correct + * - copy ids to $relationshipAttributes object + */ + foreach ($requestRelationships as $rKey => $rValue) { + if (!$model->issetRelationship($rKey)) { + throw new RequestException(sprintf( + 'Relationship "%s" is not defined', + $rKey + )); + } + + $rSource = new Pointer( + $source->getPath() . '/relationships/' . $rKey + ); + + if (!isset($rValue->data)) { + throw new MissingParametersException( + ['data'], + $rSource + ); + } + + $r = $model->getRelationship($rKey); + $resourceType = $r->getResourceModel()->getResourceType(); + + $relationshipData = $rValue->data; + + $itemValidator = (new ObjectValidator( + (object) [ + 'id' => new StringValidator(), // $r->getResourceModel()->getIdAttributeValidator(), + 'type' => new EnumValidator([$resourceType], true) + ], + ['id', 'type'] + ))->setSource(new Pointer( + $rSource->getPath() . '/data' + )); + + switch ($r->getType()) { + case Relationship::TYPE_TO_ONE: + $itemValidator->parse($relationshipData); + + //Push relationship for this relationship key + $relationships->{$rKey} = $relationshipData->id; + break; + case Relationship::TYPE_TO_MANY: + $parsed = (new ArrayValidator( + 0, + null, + $itemValidator + ))->setSource(new Pointer( + $rSource->getPath() . '/data' + ))->parse($relationshipData); + + if (count($parsed)) { + //Push relationship for this relationship key + $relationships->{$rKey} = array_map( + function (\stdClass $p) { + return $p->id; + }, + $parsed + ); + } + break; + } + } + + /* + * Validate relationships against relationships validator if is set + */ + if ($validationModel->getRelationships() !== null) { + $validator = $validationModel->getRelationships(); + + foreach ($validator->properties as $k => &$p) { + //force source to be ../data/id + $p->setSource(new Pointer( + $source->getPath() . '/relationships/' . $k . '/data/id' + )); + } + + $validator + //set source that will be used for missing parameters exception + ->setSource(new Pointer( + $source->getPath() . '/relationships' + )) + ->parse( + $relationships + ); + } + + /* + * Foreach request relationship + * Check if requested relationship resources exist + * Copy TYPE_TO_ONE attributes to primary data's attributes + */ + foreach ($relationships as $rKey => $rValue) { + if ($rValue === null) { //null added by relationship validator ? + //filer out null relationships, careful with TO_ONE might be needed as null + unset($relationships->{$rKey}); + continue; + } + + $r = $model->getRelationship($rKey); + $rResourceModel = $r->getResourceModel(); + + //Convert to array + $tempIds = ( + is_array($rValue) + ? $rValue + : [$rValue] + ); + + $data = $rResourceModel->getById( + $tempIds + ); + + /* + * Check if any of given ids is not found + */ + foreach ($data as $dId => $dValue) { + if ($dValue === null) { + throw new NotFoundException(sprintf( + 'Resource of type "%s" and id "%s" is not found', + $rResourceModel->getResourceType(), + $dId + )); + } + } + + /* + * Copy to primary attributes + * //todo make sure getRecordDataAttribute is not null + * //todo what if a TO_MANY has getRecordDataAttribute ? + */ + if ($r->getType() === Relationship::TYPE_TO_ONE) { + $parsedAttributes->{$r->getRecordDataAttribute()} = $rValue; + } + } + /* + * Call Validation callbacks + */ foreach ($validationCallbacks as $callback) { $callback( $resource, - $attributes, //parsed - $relationships //parsed + $parsedAttributes, //parsed + $relationships, //parsed + $source ); - - //todo } - return new ResourceQueueItem($attributes, $relationships); + return new ResourceQueueItem( + $parsedAttributes, + $relationships + ); } } diff --git a/src/Controller/Helper/RequestWithBody.php b/src/Controller/Helper/RequestWithBody.php new file mode 100644 index 0000000..2415d55 --- /dev/null +++ b/src/Controller/Helper/RequestWithBody.php @@ -0,0 +1,68 @@ + + * @since 3.0.0 + */ +class RequestWithBody +{ + public static function prepareData( + ServerRequestInterface $request, + int $bulkLimit = null + ) { + //todo figure out a permanent solution to have body as object instead of array (recursive), for every framework + $body = json_decode(json_encode($request->getParsedBody())); + + Controller::requireProperties($body, new Pointer('/'), 'data'); + + //Access request body primary data + $data = $body->data; + + /** + * @var bool + */ + $isBulk = true; + + //Treat all request data (bulk or not) as an array of resources + if (is_object($data) + || (is_array($data) && Util::isArrayAssoc($data)) + ) { + $isBulk = false; + $data = [$data]; + } + + //check bulk limit + if ($bulkLimit !== null && count($data) > $bulkLimit) { + throw new RequestException(sprintf( + 'Number of bulk requests is exceeding the maximum of %s', + $bulkLimit + )); + } + + return [$data, $isBulk]; + } +} diff --git a/src/Controller/Patch.php b/src/Controller/Patch.php index 950403d..a3ee8df 100644 --- a/src/Controller/Patch.php +++ b/src/Controller/Patch.php @@ -18,6 +18,7 @@ namespace Phramework\JSONAPI\Controller; use Phramework\JSONAPI\Controller\Helper\RequestBodyQueue; +use Phramework\JSONAPI\Controller\Helper\RequestWithBody; use Phramework\JSONAPI\Directive\Directive; use Phramework\JSONAPI\ResourceModel; use Psr\Http\Message\ResponseInterface; @@ -31,37 +32,58 @@ */ trait Patch { - use RequestBodyQueue; - //prototype + //@todo figure out view callback arguments public static function handlePatch( ServerRequestInterface $request, ResponseInterface $response, ResourceModel $model, - string $id, array $validationCallbacks = [], callable $viewCallback = null, - int $bulkLimit = null, + int $bulkLimit = 1, array $directives = [] ) : ResponseInterface { - //Validate id using model's validator - $id = $model->getIdAttributeValidator()->parse($id); + list($data, $isBulk) = RequestWithBody::prepareData( + $request, + $bulkLimit + ); //gather data as a queue - //check bulk limit ?? - - //on each validate - //prefer PATCH validation model $validationModel = $model->getValidationModel( 'PATCH' ); - //check if exists + //on each + + // validate + + //check if exists + + //call validation callbacks - //on each call validation callback + //gather output for view callback - //204 or view callback + //return view callback, it MUST return a ResponseInterface + if ($viewCallback !== null) { + return $viewCallback( + $request, + $response + ); + } + + return Patch::defaultPatchViewCallback( + $request, + $response + ); + } + + public static function defaultPatchViewCallback( + ServerRequestInterface $request, + ResponseInterface $response + ) : ResponseInterface { + //Return 204 No Content + return Response::noContent($response); } } diff --git a/src/Controller/Post.php b/src/Controller/Post.php index 185ca8f..472d9c6 100644 --- a/src/Controller/Post.php +++ b/src/Controller/Post.php @@ -19,10 +19,15 @@ use Phramework\Exceptions\ForbiddenException; use Phramework\Exceptions\IncorrectParameterException; +use Phramework\Exceptions\MissingParametersException; use Phramework\Exceptions\RequestException; +use Phramework\Exceptions\ServerException; use Phramework\Exceptions\Source\Pointer; use Phramework\JSONAPI\Controller\Helper\RequestBodyQueue; +use Phramework\JSONAPI\Controller\Helper\RequestWithBody; +use Phramework\JSONAPI\Controller\Helper\ResourceQueueItem; use Phramework\JSONAPI\Directive\Directive; +use Phramework\JSONAPI\Relationship; use Phramework\JSONAPI\ResourceModel; use Phramework\Util\Util; use Phramework\Validate\EnumValidator; @@ -34,48 +39,47 @@ * @license https://www.apache.org/licenses/LICENSE-2.0 Apache-2.0 * @author Xenofon Spafaridis * @since 3.0.0 - * @todo modify to allow batch, remove id ? + * @todo modify to allow batch */ trait Post { - use RequestBodyQueue; - - //prototype + /** + * Handle HTTP POST request method to create new resources + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @param ResourceModel $model + * @param array $validationCallbacks function of + * - \stdClass $resource + * - \stdClass $parsedAttributes + * - \stdClass $parsedRelationships + * - ISource $source + * returning void + * @param callable|null $viewCallback function of + * - ServerRequestInterface $request, + * - ResponseInterface $response, + * - string[] $ids + * - returning ResponseInterface + * @param int|null $bulkLimit + * @param Directive[] $directives + * @return ResponseInterface + * @throws ForbiddenException + * @throws RequestException + * @throws MissingParametersException + * @throws ServerException + */ public static function handlePost( ServerRequestInterface $request, ResponseInterface $response, ResourceModel $model, array $validationCallbacks = [], callable $viewCallback = null, //function (request, response, $ids) : ResponseInterface - int $bulkLimit = null, //todo decide 1 or null for default + int $bulkLimit = null, array $directives = [] ) : ResponseInterface { - //todo figure out a permanent solution to have body as object instead of array, for every framework - $body = json_decode(json_encode($request->getParsedBody())); - - //Access request body primary data - $data = $body->data ?? new \stdClass(); - - /** - * @var bool - */ - $isBulk = true; - - //Treat all request data (bulk or not) as an array of resources - if (is_object($data) - || (is_array($data) && Util::isArrayAssoc($data)) - ) { - $isBulk = false; - $data = [$data]; - } - - //check bulk limit - if ($bulkLimit !== null && count($data) > $bulkLimit) { - throw new RequestException(sprintf( - 'Number of batch requests is exceeding the maximum of %s', - $bulkLimit - )); - } + list($data, $isBulk) = RequestWithBody::prepareData( + $request, + $bulkLimit + ); $typeValidator = (new EnumValidator([$model->getResourceType()])); @@ -87,7 +91,10 @@ public static function handlePost( ); $bulkIndex = 0; - //gather data as a queue + + /* + * gather data as a queue + */ foreach ($data as $resource) { //Prepare exception source $source = new Pointer( @@ -109,38 +116,32 @@ public static function handlePost( //Throw exception if resource id is forced if (property_exists($resource, 'id')) { - //todo include source - throw new ForbiddenException( - 'Unsupported request to create a resource with a client-generated ID' + throw new IncorrectParameterException( + 'additionalProperties', + 'Unsupported request to create a resource with a client-generated id', + new Pointer($source->getPath()) ); } - //Fetch request attributes - $requestAttributes = $resource->attributes ?? new \stdClass(); - $requestRelationships = $resource->relationships ?? new \stdClass(); - - //todo use helper class - $queueItem = (object) [ - 'attributes' => $requestAttributes, - 'relationships' => $requestRelationships - ]; + /* + * Will call validationCallbacks + * Will call $validationModel attribute validator on attributes + * Will call $validationModel relationship validator on relationships + * Will copy TO_ONE relationship data to parsed attributes + */ + $item = RequestBodyQueue::handleResource( + $resource, + $source, + $model, + $validationModel, + $validationCallbacks + ); - $requestQueue->push($queueItem); + $requestQueue->push($item); ++$bulkIndex; } - //on each validate - //todo - foreach ($requestQueue as $i => $q) { - $validationModel->attributes - ->setSource(new Pointer('/data/' . $i . '/attributes')) - ->parse($q->attributes); - } - - //on each call validation callback - //todo - //post /** @@ -149,12 +150,17 @@ public static function handlePost( */ $ids = []; - //process queue + /* + * process queue + */ while (!$requestQueue->isEmpty()) { + /** + * @var ResourceQueueItem + */ $queueItem = $requestQueue->pop(); $id = $model->post( - $queueItem->attributes + $queueItem->getAttributes() ); Controller::assertUnknownError( @@ -162,19 +168,36 @@ public static function handlePost( 'Unknown error while posting resource' ); - //POST item's relationships - $relationships = $queueItem->relationships; - - foreach ($relationships as $key => $relationship) { - //Call post relationship method to post each of relationships pairs - //todo fix - foreach ($relationship->resources as $resourceId) { - call_user_func( - $relationship->callback, - $id, - $resourceId, - null //$additionalAttributes - ); + /** + * @var \stdClass + */ + $relationships = $queueItem->getRelationships(); + + /** + * POST item's relationships + * @param string[] $rValue + */ + foreach ($relationships as $rKey => $rValue) { + $r = $model->getRelationship($rKey); + + if ($r->getType() == Relationship::TYPE_TO_MANY) { + if (!isset($r->getCallbacks()->{'POST'})) { + throw new ServerException(sprintf( + 'POST callback is not defined for relationship "%s"', + $rKey + )); + } + + /* + * Call post relationship callback to post each of relationships pairs + */ + foreach ($rValue as $v) { + call_user_func( + $r->getCallbacks()->{'POST'}, + $id, //Inserted resource id + $v + ); + } } } @@ -185,7 +208,6 @@ public static function handlePost( } //return view callback, it MUST return a ResponseInterface - if ($viewCallback !== null) { return $viewCallback( $request, @@ -194,13 +216,25 @@ public static function handlePost( ); } - /*if (count($ids) === 1) { - //Prepare response with 201 Created status code - return Response::created( + return Post::defaultPostViewCallback( + $request, + $response, + $ids + ); + } + + public static function defaultPostViewCallback( + ServerRequestInterface $request, + ResponseInterface $response, + array $ids + ) : ResponseInterface { + if (count($ids) === 1) { + //Prepare Location header + $response = Response::created( $response, - 'link' . $ids[0] // location + 'link/' . $ids[0] // location //todo ); - }*/ //see https://stackoverflow.com/questions/11309444/can-the-location-header-be-used-for-multiple-resource-locations-in-a-201-created + } //see https://stackoverflow.com/questions/11309444/can-the-location-header-be-used-for-multiple-resource-locations-in-a-201-created //Return 204 No Content return Response::noContent($response); diff --git a/src/Directive/FilterAttribute.php b/src/Directive/FilterAttribute.php index de94d7a..48e849a 100644 --- a/src/Directive/FilterAttribute.php +++ b/src/Directive/FilterAttribute.php @@ -118,7 +118,7 @@ public static function parse($filterKey, $filterValue) } //@todo is this required? - $singleFilterValue = urldecode($singleFilterValue); + $singleFilterValue = urldecode((string) $singleFilterValue); list($operator, $operand) = Operator::parse($singleFilterValue); diff --git a/src/Model/DataSourceTrait.php b/src/Model/DataSourceTrait.php index 2962569..a5281a6 100644 --- a/src/Model/DataSourceTrait.php +++ b/src/Model/DataSourceTrait.php @@ -326,8 +326,8 @@ function ($directive) { //Prepare filter $filter = new Filter( is_array($id) - ? $id - : [$id] + ? $id + : [$id] ); //Force array for primary data if (!empty($passedfilter)) { diff --git a/src/Relationship.php b/src/Relationship.php index 769e40a..6c995e2 100644 --- a/src/Relationship.php +++ b/src/Relationship.php @@ -22,6 +22,7 @@ * @since 0.0.0 * @license https://www.apache.org/licenses/LICENSE-2.0 Apache-2.0 * @author Xenofon Spafaridis + * @todo POST callbacks should be defined as f ($insertedId, $relationshipResourceId) */ class Relationship { @@ -78,12 +79,12 @@ class Relationship protected $flags; /** - * @param ResourceModel $model Class path of relationship resource resourceModel + * @param ResourceModel $resourceModel Class path of relationship resource resourceModel * @param int $type *[Optional] Relationship type * @param string $recordDataAttribute *[Optional] Attribute name in record containing relationship data * @param \stdClass $callbacks *[Optional] Callable method can be used * to fetch relationship data, see TODO - * @param int $flags *[Optional] Relationship flags + * @param int $flags *[Optional] Relationship flags * @throws \Exception When is not null, callable or object of callbacks * @example * ```php @@ -103,7 +104,7 @@ class Relationship * ``` */ public function __construct( - ResourceModel $model, + ResourceModel $resourceModel, int $type = Relationship::TYPE_TO_ONE, string $recordDataAttribute = null, \stdClass $callbacks = null, @@ -115,14 +116,14 @@ public function __construct( foreach ($callbacks as $method => $callback) { if (!is_string($method)) { throw new \LogicException(sprintf( - 'callback method "%s" must be string', + 'Method "%s" for callback must be string', $method )); } if (!is_callable($callback)) { throw new \LogicException(sprintf( - 'callback for method "%s" must be a callable', + 'Callback for method "%s" must be a callable', $method )); } @@ -131,7 +132,7 @@ public function __construct( $this->callbacks = $callbacks; } - $this->resourceModel = $model; + $this->resourceModel = $resourceModel; $this->type = $type; $this->recordDataAttribute = $recordDataAttribute; $this->flags = $flags; diff --git a/tests/APP/DataSource/MemoryDataSource.php b/tests/APP/DataSource/MemoryDataSource.php index 58c81cb..b2eff56 100644 --- a/tests/APP/DataSource/MemoryDataSource.php +++ b/tests/APP/DataSource/MemoryDataSource.php @@ -159,7 +159,7 @@ public function post( if (!property_exists($attributes, $idAttribute)) { //generate an id - $attributes->{$idAttribute} = md5(mt_rand()); + $attributes->{$idAttribute} = md5((string) mt_rand()); } $table = $this->resourceModel->getVariable('table'); diff --git a/tests/APP/Models/Article.php b/tests/APP/Models/Article.php new file mode 100644 index 0000000..ea1ac6e --- /dev/null +++ b/tests/APP/Models/Article.php @@ -0,0 +1,122 @@ + + * @since 1.0 + */ +class Article extends Model +{ + use ModelTrait; + + protected static function defineModel() : ResourceModel + { + $r = (new ResourceModel('article', new MemoryDataSource())); + return $r + ->addVariable('table', 'article') + ->setSortableAttributes( + 'id' + )->setValidationModel( + new ValidationModel( + new ObjectValidator( + (object) [ + 'title' => new StringValidator(), + 'body' => new StringValidator(), + 'status' => (new UnsignedIntegerValidator(0, 1)) + ->setDefault(1) + ], + ['title', 'body'], + false + ), + new ObjectValidator( + (object) [ + 'author' => User::getResourceModel()->getIdAttributeValidator(), + 'tag' => new ArrayValidator( + 0, + null, + Tag::getResourceModel()->getIdAttributeValidator() + ) + ], + ['author'], + false + ) + ), + 'POST' + ) + /*->setValidationModel( + new ValidationModel( + new ObjectValidator( + (object) [ + 'title' => new StringValidator(), + 'body' => new StringValidator(), + 'status' => (new UnsignedIntegerValidator(0, 1)) + ], + [], + false + ), + new ObjectValidator( + (object) [ + 'author' => User::getResourceModel()->getIdAttributeValidator() + ], + [], + false + ) + ), + 'PATCH' + )*/ + ->setRelationships( + (object) [ + 'author' => new Relationship( + User::getResourceModel(), + Relationship::TYPE_TO_ONE, + 'creator-user_id' + ), + 'tag' => new Relationship( + Tag::getResourceModel(), + Relationship::TYPE_TO_MANY, + 'tag_id', + (object) [ + /** + * @param string $articleId + * @param string $tagId + */ + 'POST' => function (string $articleId, string $tagId) use (&$r) { + //todo use actual datasource to store connection + var_dump(sprintf('post (%s, %s', + $articleId, + $tagId + )); + } + ] + ) + ] + ); + } +} diff --git a/tests/APP/Models/Tag.php b/tests/APP/Models/Tag.php index 7c16b01..dd9d85c 100644 --- a/tests/APP/Models/Tag.php +++ b/tests/APP/Models/Tag.php @@ -22,6 +22,9 @@ use Phramework\JSONAPI\ResourceModel; use Phramework\JSONAPI\Model; use Phramework\JSONAPI\ModelTrait; +use Phramework\JSONAPI\ValidationModel; +use Phramework\Validate\ObjectValidator; +use Phramework\Validate\StringValidator; /** * @since 3.0.0 @@ -38,7 +41,19 @@ class Tag extends Model protected static function defineModel() : ResourceModel { $model = (new ResourceModel('tag', new MemoryDataSource())) - ->addVariable('table', 'tag'); + ->addVariable('table', 'tag') + ->setValidationModel( + new ValidationModel( + new ObjectValidator( + (object) [ + 'name' => new StringValidator(2, 10) + ], + ['name'], + false + ) + ), + 'POST' + ); return $model; } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index add71ad..486d0d0 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -124,4 +124,17 @@ 'group_id' => '2', 'tag_id' => ['2'] ] +); + +MemoryDataSource::addTable('article'); + +MemoryDataSource::insert( + 'article', + (object) [ + 'id' => '1', + 'title' => 'Hello World', + 'body' => 'Lorem ipsum', + 'status' => 1, + 'creator-user_id' => '2' + ] ); \ No newline at end of file diff --git a/tests/src/Controller/Helper/RequestBodyQueueTest.php b/tests/src/Controller/Helper/RequestBodyQueueTest.php new file mode 100644 index 0000000..d00dc3c --- /dev/null +++ b/tests/src/Controller/Helper/RequestBodyQueueTest.php @@ -0,0 +1,498 @@ + + * Using \Phramework\JSONAPI\APP\Models\Tag model for tests + */ +class RequestBodyQueueTest extends \PHPUnit_Framework_TestCase +{ + use Post; + + /** + * Expect missing relationships author + * @covers ::handleResource + * @group relationships + * @group missing + */ + public function testMissingRelationships() + { + //omit relationships + $request = (new ServerRequest()) + ->withParsedBody((object) [ + 'data' => (object) [ + 'type' => Article::getResourceType(), + 'attributes' => (object) [ + 'title' => 'abcd', + 'body' => 'abcdef', + 'status' => 1 + ] + ] + ]); + + $this->expectMissing( + $request, + ['author'], + '/data/relationships', + Article::getResourceModel() + ); + } + + /** + * Expect missing relationships author + * @covers ::handleResource + * @group relationships + * @group missing + */ + public function testMissingRelationshipsData() + { + //omit relationships + $request = (new ServerRequest()) + ->withParsedBody((object) [ + 'data' => (object) [ + 'type' => Article::getResourceType(), + 'attributes' => (object) [ + 'title' => 'abcd', + 'body' => 'abcdef', + 'status' => 1 + ], + 'relationships' => (object) [ + 'author' => (object) [] + ] + ] + ]); + + $this->expectMissing( + $request, + ['data'], + '/data/relationships/author', + Article::getResourceModel() + ); + } + + /** + * Expect missing relationships author + * @covers ::handleResource + * @group relationships + * @group missing + */ + public function testMissingRelationshipsDataIdType() + { + //omit relationships + $request = (new ServerRequest()) + ->withParsedBody((object) [ + 'data' => (object) [ + 'type' => Article::getResourceType(), + 'attributes' => (object) [ + 'title' => 'abcd', + 'body' => 'abcdef', + 'status' => 1 + ], + 'relationships' => (object) [ + 'author' => (object) [ + 'data' => (object) [ //empty + ] + ] + ] + ] + ]); + + $this->expectMissing( + $request, + ['id', 'type'], + '/data/relationships/author/data', + Article::getResourceModel() + ); + } + + /** + * @covers ::handleResource + * @group relationships + * @group incorrect + */ + public function testRelationshipsIncorrectId() + { + $request = $this->getArticleRequest('', User::getResourceType()); + + try { + $this->handlePost( + $request, + new Response(), + Article::getResourceModel() + ); + } catch (IncorrectParametersException $e) { + $this->assertCount( + 1, + $e->getExceptions() + ); + + /** + * @var IncorrectParameterException + */ + $e = $e->getExceptions()[0]; + + $this->assertInstanceOf( + IncorrectParameterException::class, + $e + ); + + $this->assertSame( + 'minLength', + $e->getFailure(), + 'Expect minLength since empty string is given' + ); + + $this->assertEquals( + new Pointer('/data/relationships/author/data/id'), + $e->getSource() + ); + } catch (\Exception $e) { + $this->fail('Expected Exception has not been raised'); + } + } + + /** + * @covers ::handleResource + * @group relationships + * @expectedException \Phramework\Exceptions\NotFoundException + * @expectedExceptionCode 404 + * @expectedExceptionMessageRegExp /user/ + */ + public function testRelationshipsNotFound() + { + $request = $this->getArticleRequest(md5('abcd'), User::getResourceType()); + + $this->handlePost( + $request, + new Response(), + Article::getResourceModel() + ); + } + + /** + * @covers ::handleResource + * @group relationship + * @group incorrect + */ + public function testRelationshipsIncorrectType() + { + $request = $this->getArticleRequest('1', Tag::getResourceType()); + + try { + $this->handlePost( + $request, + new Response(), + Article::getResourceModel() + ); + } catch (IncorrectParametersException $e) { + $this->assertCount( + 1, + $e->getExceptions() + ); + + /** + * @var IncorrectParameterException + */ + $e = $e->getExceptions()[0]; + + $this->assertInstanceOf( + IncorrectParameterException::class, + $e + ); + + $this->assertSame( + 'enum', + $e->getFailure(), + 'Expect not expected type is given' + ); + + $this->assertEquals( + new Pointer('/data/relationships/author/data/type'), + $e->getSource() + ); + } catch (\Exception $e) { + $this->fail('Expected Exception has not been raised'); + } + } + + /** + * Expect author id to be in inserted resource + * @covers ::handleResource + * @covers \Phramework\JSONAPI\Controller\Post::handlePost + * @group relationship + */ + public function testRelationshipsToOneSuccess() + { + $user = User::get(new Page(1))[0]; + + $request = $this->getArticleRequest($user->id, User::getResourceType()); + + $unit = $this; + + $this->handlePost( + $request, + new Response(), + Article::getResourceModel(), + [ + function ( + \stdClass $resource, + \stdClass $parsedAttributes, + \stdClass $parsedRelationships, + ISource $source + ) use ($unit, $user) { + $unit->assertSame( + $user->id, + $parsedAttributes->{'creator-user_id'} + ); + + $unit->assertSame( + $user->id, + $parsedRelationships->{'author'} + ); + } + ], + function ( + ServerRequestInterface $request, + ResponseInterface $response, + array $ids + ) use ($unit, $user) : ResponseInterface { + $data = Article::getById($ids[0]); + + $this->assertSame( + $user->id, + $data->relationships->author->data->id + ); + + return Post::defaultPostViewCallback( + $request, + $response, + $ids + ); + } + ); + } + + /** + * Expect author id to be in inserted resource + * @covers ::handleResource + * @covers \Phramework\JSONAPI\Controller\Post::handlePost + * @group relationship + */ + public function testRelationshipsToManySuccess() + { + $user = User::get(new Page(1))[0]; + + $tags = Tag::get(new Page(2)); + + $request = (new ServerRequest()) + ->withParsedBody((object) [ + 'data' => (object) [ + 'type' => Article::getResourceType(), + 'attributes' => (object) [ + 'title' => 'abcd', + 'body' => 'abcdef', + 'status' => 1 + ], + 'relationships' => (object) [ + 'author' => (object) [ + 'data' => (object) [ + 'id' => $user->id, + 'type' => User::getResourceType() + ] + ], + 'tag' => (object) [ + 'data' => [ + (object) [ + 'id' => $tags[0]->id, + 'type' => Tag::getResourceType() + ], + (object) [ + 'id' => $tags[1]->id, + 'type' => Tag::getResourceType() + ] + ] + ] + ] + ] + ]); + + $unit = $this; + + $this->handlePost( + $request, + new Response(), + Article::getResourceModel() + /*[ + function ( + \stdClass $resource, + \stdClass $parsedAttributes, + \stdClass $parsedRelationships, + ISource $source + ) use ($unit, $user) { + $unit->assertSame( + $user->id, + $parsedAttributes->{'creator-user_id'} + ); + + $unit->assertSame( + $user->id, + $parsedRelationships->{'author'} + ); + } + ],*/ + /*function ( + ServerRequestInterface $request, + ResponseInterface $response, + array $ids + ) use ($unit, $user) : ResponseInterface { + $data = Article::getById($ids[0]); + + $this->assertSame( + $user->id, + $data->relationships->author->data->id + ); + + return Post::defaultPostViewCallback( + $request, + $response, + $ids + ); + }*/ + ); + + $this->markTestIncomplete('must check for execution-insertion of tag data'); + } + + /** + * @covers ::handleResource + * @group relationship + * @expectedException \Phramework\Exceptions\RequestException + * @expectedExceptionCode 400 + * @expectedExceptionMessageRegExp /Relationship/i + * @expectedExceptionMessageRegExp /abcd/i + */ + public function testRelationshipsNotDefinedException() + { + $request = (new ServerRequest()) + ->withParsedBody((object) [ + 'data' => (object) [ + 'type' => Article::getResourceType(), + 'attributes' => (object) [ + 'title' => 'abcd', + 'body' => 'abcdef', + 'status' => 1 + ], + 'relationships' => (object) [ + 'abcd' => (object) [ + ] + ] + ] + ]); + + $this->handlePost( + $request, + new Response(), + Article::getResourceModel() + ); + } + + /* + * Helper methods area + */ + + /** + * Helper method to assert missing parameters + * @param ServerRequestInterface $request + * @param array $missingParameters + * @param string $pointerPath + */ + private function expectMissing( + ServerRequestInterface $request, + array $missingParameters, + string $pointerPath, + ResourceModel $resourceModel + ) { + try { + $response = $this->handlePost( + $request, + new Response(), + $resourceModel + ); + } catch (MissingParametersException $e) { + $this->assertEquals( + $missingParameters, + $e->getParameters() + ); + + $this->assertEquals( + new Pointer($pointerPath), + $e->getSource() + ); + } catch (\Exception $e) { + $this->fail('Expected Exception has not been raised'); + } + } + + private function getArticleRequest( + string $authorId, + string $authorType + ) : ServerRequestInterface { + $request = (new ServerRequest()) + ->withParsedBody((object) [ + 'data' => (object) [ + 'type' => Article::getResourceType(), + 'attributes' => (object) [ + 'title' => 'abcd', + 'body' => 'abcdef', + 'status' => 1 + ], + 'relationships' => (object) [ + 'author' => (object) [ + 'data' => (object) [ + 'id' => $authorId, + 'type' => $authorType + ] + ] + ] + ] + ]); + + return $request; + } +} diff --git a/tests/src/Controller/PostTest.php b/tests/src/Controller/PostTest.php new file mode 100644 index 0000000..5b2727b --- /dev/null +++ b/tests/src/Controller/PostTest.php @@ -0,0 +1,637 @@ + + * Using \Phramework\JSONAPI\APP\Models\Tag model for tests + */ +class PostTest extends \PHPUnit_Framework_TestCase +{ + use Post; + + /** + * @covers ::handlePost + */ + public function testHandlePost() + { + $request = (new ServerRequest()) + ->withParsedBody((object) [ + 'data' => (object) [ + 'type' => Tag::getResourceType(), + 'attributes' => (object) [ + 'name' => 'aaaaa' + ] + ] + ]); + + $response = $this->handlePost( + $request, + new Response(), + Tag::getResourceModel() + ); + + $this->assertSame( + 204, + $response->getStatusCode() + ); + + $this->markTestIncomplete('test actual resource created'); + $this->markTestIncomplete('test headers'); + $this->markTestIncomplete('test body'); + } + + /** + * @covers ::defaultPostViewCallback + */ + public function testDefaultViewCallback() + { + $request = $this->getValidTagRequest('abcd'); + + $response = $this->defaultPostViewCallback( + $request, + new Response(), + ['1', '2', '3'] + ); + + $this->assertSame( + 204, + $response->getStatusCode() + ); + } + + /** + * @covers ::defaultPostViewCallback + */ + public function testDefaultViewCallbackSingle() + { + $request = $this->getValidTagRequest('abcd'); + + $response = $this->defaultPostViewCallback( + $request, + new Response(), + ['1'] + ); + + $this->assertSame( + 204, + $response->getStatusCode() + ); + + $this->assertTrue( + $response->hasHeader('Location') + ); + } + + /* + * Missing + */ + + /** + * @covers ::handlePost + * @group missing + */ + public function testMissingPrimaryData() + { + $request = (new ServerRequest()); + + $this->expectMissing( + $request, + ['data'], + '/', + Tag::getResourceModel() + ); + } + + /** + * @covers ::handlePost + * @group missing + */ + public function testMissingType() + { + $request = (new ServerRequest()) + ->withParsedBody((object) [ + 'data' => (object) [ + ] + ]); + + $this->expectMissing( + $request, + ['type'], + '/data', + Tag::getResourceModel() + ); + } + + /** + * Expect exception with missing /data/attributes/name since its required + * @covers ::handlePost + * @group missing + */ + public function testMissingAttributes() + { + $request = (new ServerRequest()) + ->withParsedBody((object) [ + 'data' => (object) [ + 'type' => Tag::getResourceType() + ] + ]); + + $this->expectMissing( + $request, + ['name'], + '/data/attributes', + Tag::getResourceModel() + ); + } + + /* + * Test bulk + */ + + /** + * @covers ::handlePost + * @group bulk + */ + public function testBulk() + { + $request = (new ServerRequest()) + ->withParsedBody((object) [ + 'data' => [ + (object) [ + 'type' => Tag::getResourceType(), + 'attributes' => (object) [ + 'name' => 'abcd' + ] + ], + (object) [ + 'type' => Tag::getResourceType(), + 'attributes' => (object) [ + 'name' => 'abcdef' + ] + ], + ] + ]); + + $response = $this->handlePost( + $request, + new Response(), + Tag::getResourceModel(), + [], + null, + 2 + ); + + $this->assertSame( + 204, + $response->getStatusCode() + ); + } + + /** + * Expect exception since 2 resources are given with bulk limit of 1 + * also expect exception message to contain word bulk + * @covers ::handlePost + * @expectedException \Phramework\Exceptions\RequestException + * @expectedExceptionCode 400 + * @expectedExceptionMessageRegExp /bulk/ + * @group bulk + */ + public function testBulkMaximum() + { + $request = (new ServerRequest()) + ->withParsedBody((object) [ + 'data' => [ + (object) [ + 'type' => Tag::getResourceType() + ], + (object) [ + 'type' => Tag::getResourceType() + ], + ] + ]); + + $response = $this->handlePost( + $request, + new Response(), + Tag::getResourceModel(), + [], + null, + 1 //Set bulk limit of 1 + ); + } + + /* + * Incorrect parameters + */ + + /** + * @covers ::handlePost + * @group incorrect + */ + public function testUnsupportedRequestWithId() + { + $request = (new ServerRequest()) + ->withParsedBody((object) [ + 'data' => (object) [ + 'type' => Tag::getResourceType(), + 'id' => md5((string) mt_rand()), //inject unsupported id + 'attributes' => (object) [ + 'name' => 'aaaaa' + ] + ] + ]); + + try { + $this->handlePost( + $request, + new Response(), + Tag::getResourceModel() + ); + } catch (IncorrectParameterException $e) { + $this->assertSame( + 'additionalProperties', + $e->getFailure() + ); + + $this->assertSame( + '/data', + $e->getSource()->getPath() + ); + + $this->assertRegExp( + '/id/', + $e->getDetail(), + 'Expect detail message to contain "id" word' + ); + + } catch (\Exception $e) { + $this->fail('Expected Exception has not been raised'); + } + } + + /** + * @covers ::handlePost + * @group incorrect + */ + public function testIncorrectAttributes() + { + $request = (new ServerRequest()) + ->withParsedBody((object) [ + 'data' => (object) [ + 'type' => Tag::getResourceType(), + 'attributes' => (object) [ + 'name' => '' //since expecting 2 to 10 + ] + ] + ]); + + try { + $response = $this->handlePost( + $request, + new Response(), + Tag::getResourceModel() + ); + } catch (IncorrectParametersException $e) { + $this->assertCount( + 1, + $e->getExceptions() + ); + + /** + * @var IncorrectParameterException + */ + $e = $e->getExceptions()[0]; + + $this->assertInstanceOf( + IncorrectParameterException::class, + $e + ); + + $this->assertSame( + 'minLength', + $e->getFailure() + ); + + $this->assertEquals( + new Pointer('/data/attributes/name'), + $e->getSource() + ); + } catch (\Exception $e) { + $this->fail('Expected Exception has not been raised'); + } + } + + + /* + * Validation callback + */ + + /** + * This test will use pass a validation callback in order to have additional checks + * @covers ::handlePost + * @group validationCallbacks + */ + public function testValidationCallbacksAdditionalException() + { + $name = 'aaaaa'; + + $request = $this->getValidTagRequest($name); + + try { + + $response = $this->handlePost( + $request, + new Response(), + Tag::getResourceModel(), + [ + function ( + \stdClass $resource, + \stdClass $parsedAttributes, + \stdClass $parsedRelationships, + ISource $source + ) use ($name) { + (new StringValidator()) + ->setNot( + (new StringValidator()) + ->setEnum([$name]) + ) + ->setSource(new Pointer( + $source->getPath() . '/attributes/name' + )) + ->parse($parsedAttributes->name); + } + ] + ); + } catch (IncorrectParameterException $e) { + $this->assertInstanceOf( + IncorrectParameterException::class, + $e + ); + + $this->assertSame( + 'not', + $e->getFailure() + ); + + $this->assertEquals( + new Pointer('/data/attributes/name'), + $e->getSource() + ); + } catch (\Exception $e) { + $this->fail('Expected Exception has not been raised'); + } + } + + /** + * This test will use pass a validation callback in order to modify attributes + * @covers ::handlePost + * @group validationCallbacks + */ + public function testValidationCallbacksModifyAttributes() + { + $name = 'aaaaa'; + $newName = str_repeat($name, 2); + + $request = $this->getValidTagRequest($name); + + $unit = $this; + + $response = $this->handlePost( + $request, + new Response(), + Tag::getResourceModel(), + [ + function ( + \stdClass $resource, + \stdClass &$parsedAttributes, + \stdClass $parsedRelationships, + ISource $source + ) use ($newName) { + $parsedAttributes->name = $newName; + } + ], + function ( + ServerRequestInterface $request, + ResponseInterface $response, + array $ids + ) use ($unit, $newName) : ResponseInterface { + $data = Tag::getById($ids[0]); + + $unit->assertSame( + $newName, + $data->attributes->name, + 'Expect inserted name to have same value with modified instead of original' + ); + + return Post::defaultPostViewCallback( + $request, + $response, + $ids + ); + } + ); + } + + /* + * View callback + */ + + /** + * This test will use pass a viewCallback in order to have a modified response + * It will also ensure that status, headers and body can be modified + * Additionally it will check the structure of body if it's identical to inserted resource + * @covers ::handlePost + */ + public function testViewCallback() + { + $name = 'aaaaa'; + + $request = $this->getValidTagRequest($name); + + $unit = $this; + + $response = $this->handlePost( + $request, + new Response(), + Tag::getResourceModel(), + [], + function ( + ServerRequestInterface $request, + ResponseInterface $response, + array $ids + ) use ($unit) : ResponseInterface { + $unit->assertCount( + 1, + $ids + ); + + $data = Tag::getById($ids[0]); + + $response = Controller::viewData( + $response, + $data + ); + + $response = $response + ->withStatus(203) + ->withAddedHeader( + 'x-phramework', $ids[0] + ); + + return $response; + } //set viewCallback + ); + + $this->assertSame( + 203, + $response->getStatusCode() + ); + + $this->assertTrue( + $response->hasHeader('x-phramework') + ); + + $object = json_decode( + $response->getBody()->__toString() + ); + + /* + * Test inserted resource structure + */ + + $validate = (new ObjectValidator( + (object) [ + 'data' => new ObjectValidator( + (object) [ + 'name' => (new StringValidator()) + ->setEnum([$name]) + ], + ['type', 'attributes'] + ) + ], + ['data'] + ))->validate($object); + + $this->assertTrue( + $validate->status + ); + } + + /** + * Expect author id to be in inserted resource + * @covers ::handlePost + * @group relationship + */ + /*public function testRelationshipsToOneSuccess() + { + return (new RequestBodyQueueTest())->testRelationshipsToOneSuccess(); + }*/ + + /** + * Expect tag ids to be in inserted resource + * @covers ::handlePost + * @group relationship + */ + /*public function testRelationshipsToManySuccess() + { + return (new RequestBodyQueueTest())->testRelationshipsToManySuccess(); + }*/ + + + /* + * Helper methods area + */ + + /** + * Helper method to assert missing parameters + * @param ServerRequestInterface $request + * @param array $missingParameters + * @param string $pointerPath + */ + private function expectMissing( + ServerRequestInterface $request, + array $missingParameters, + string $pointerPath, + ResourceModel $resourceModel + ) { + try { + $response = $this->handlePost( + $request, + new Response(), + $resourceModel + ); + } catch (MissingParametersException $e) { + $this->assertEquals( + $missingParameters, + $e->getParameters() + ); + + $this->assertEquals( + new Pointer($pointerPath), + $e->getSource() + ); + } catch (\Exception $e) { + $this->fail('Expected Exception has not been raised'); + } + } + + private function getValidTagRequest(string $name) : ServerRequestInterface + { + $request = (new ServerRequest()) + ->withParsedBody((object) [ + 'data' => (object) [ + 'type' => Tag::getResourceType(), + 'attributes' => (object) [ + 'name' => $name + ] + ] + ]); + + return $request; + } +} diff --git a/tests/src/DataSource/DataSourceTest.php b/tests/src/DataSource/DataSourceTest.php new file mode 100644 index 0000000..46f0051 --- /dev/null +++ b/tests/src/DataSource/DataSourceTest.php @@ -0,0 +1,57 @@ + + * @coversDefaultClass Phramework\JSONAPI\DataSource\DataSource + */ +class DataSourceTest extends \PHPUnit_Framework_TestCase +{ + /** + * @covers ::setResourceModel + */ + public function testSetResourceModel() + { + $dataSource = new DatabaseDataSource(); + + $dataSource->setResourceModel( + Tag::getResourceModel() + ); + + return $dataSource; + } + + /** + * @covers ::getResourceModel + * @depends testSetResourceModel + */ + public function testGetResourceModel(DataSource $dataSource) + { + $resourceModel = $dataSource->getResourceModel(); + + $this->assertSame( + Tag::getResourceModel(), + $resourceModel + ); + } +} diff --git a/tests/src/DataSource/DatabaseDataSourceTest.php b/tests/src/DataSource/DatabaseDataSourceTest.php new file mode 100644 index 0000000..3587c4b --- /dev/null +++ b/tests/src/DataSource/DatabaseDataSourceTest.php @@ -0,0 +1,127 @@ + + * @coversDefaultClass Phramework\JSONAPI\DataSource\DatabaseDataSource + */ +class DatabaseDataSourceTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var DatabaseDataSource + */ + protected $dataSource; + + public function setUp() + { + $this->dataSource = new DatabaseDataSource(Tag::getResourceModel()); + } + + /** + * @covers ::__construct + */ + public function testConstruct() + { + $dataSource = new DatabaseDataSource(Tag::getResourceModel()); + + $this->assertSame( + Tag::getResourceModel(), + $dataSource->getResourceModel() + ); + } + + /** + * @covers ::requireTableSetting + */ + public function testRequireTableSettingSuccess() + { + $dataSource = new DatabaseDataSource(Tag::getResourceModel()); + + $this->assertSame( + Tag::getResourceModel()->getVariable('table'), + $dataSource->requireTableSetting() + ); + } + + /** + * @covers ::requireTableSetting + * @expectedException \LogicException + */ + public function testRequireTableSettingFailure() + { + $dataSource = new DatabaseDataSource(); + + $model = (new ResourceModel('s', $dataSource)); + + $dataSource->requireTableSetting(); + } + + /** + * @covers ::handleFilter + */ + public function testHandleFilter() + { + $filter = new Filter(); + + $q = $this->dataSource->handleGet( + 'SELECT * FROM "table" {{filter}}', + false, + [$filter] + ); + + $this->assertInternalType('string', $q); + + $pattern = sprintf( + '/^SELECT \* FROM "table"\s*$/' + ); + + $this->assertRegExp($pattern, trim($q)); + } + + /** + * @covers ::handleFilter + * @covers ::handleGet + */ + public function testHandleFilter2() + { + $filter = new Filter( + ['1', '2'] + ); + + $q = $this->dataSource->handleGet( + 'SELECT * FROM "table" {{filter}}', + false, + [$filter] + ); + + $this->assertInternalType('string', $q); + + $pattern = sprintf( + '/^SELECT \* FROM "table"\s* WHERE "id"\s*IN\s*\(\'1\',\'2\'\)\s*$/' + ); + + $this->assertRegExp($pattern, trim($q)); + } +} diff --git a/tests/src/Directive/FilterTest.php b/tests/src/Directive/FilterTest.php index 451bb04..6337ad3 100644 --- a/tests/src/Directive/FilterTest.php +++ b/tests/src/Directive/FilterTest.php @@ -374,7 +374,7 @@ public function testParseFromRequestFailureNotAllowedAttribute() Filter::parseFromRequest( $this->request->withQueryParams([ 'filter' => [ - 'not-found' => 1 + 'not-found' => '1' ] ]), $this->articleModel diff --git a/tests/src/ModelTest.php b/tests/src/ModelTest.php index 14c4174..1e16a53 100644 --- a/tests/src/ModelTest.php +++ b/tests/src/ModelTest.php @@ -1,4 +1,5 @@