From 8c813bc340616e9470c211676da6063b63971280 Mon Sep 17 00:00:00 2001 From: Sami Mazouz <sychocouldy@gmail.com> Date: Tue, 1 Dec 2020 01:24:50 +0100 Subject: [PATCH] ApiSerializer Extender (#2438) --- src/Api/Event/Serializing.php | 2 + src/Api/Serializer/AbstractSerializer.php | 58 +- src/Event/GetApiRelationship.php | 2 + src/Extend/ApiSerializer.php | 162 ++++++ .../extenders/ApiSerializerTest.php | 523 ++++++++++++++++++ 5 files changed, 746 insertions(+), 1 deletion(-) create mode 100644 src/Extend/ApiSerializer.php create mode 100644 tests/integration/extenders/ApiSerializerTest.php diff --git a/src/Api/Event/Serializing.php b/src/Api/Event/Serializing.php index 6773d99d0..d0e1dae48 100644 --- a/src/Api/Event/Serializing.php +++ b/src/Api/Event/Serializing.php @@ -17,6 +17,8 @@ use Flarum\Api\Serializer\AbstractSerializer; * * This event is fired when a serializer is constructing an array of resource * attributes for API output. + * + * @deprecated in beta 15, removed in beta 16 */ class Serializing { diff --git a/src/Api/Serializer/AbstractSerializer.php b/src/Api/Serializer/AbstractSerializer.php index aa13cfb82..b20874405 100644 --- a/src/Api/Serializer/AbstractSerializer.php +++ b/src/Api/Serializer/AbstractSerializer.php @@ -16,6 +16,7 @@ use Flarum\Event\GetApiRelationship; use Flarum\User\User; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Support\Arr; use InvalidArgumentException; use LogicException; use Psr\Http\Message\ServerRequestInterface as Request; @@ -47,6 +48,16 @@ abstract class AbstractSerializer extends BaseAbstractSerializer */ protected static $container; + /** + * @var callable[] + */ + protected static $mutators = []; + + /** + * @var array + */ + protected static $customRelations = []; + /** * @return Request */ @@ -83,6 +94,18 @@ abstract class AbstractSerializer extends BaseAbstractSerializer $attributes = $this->getDefaultAttributes($model); + foreach (array_reverse(array_merge([static::class], class_parents($this))) as $class) { + if (isset(static::$mutators[$class])) { + foreach (static::$mutators[$class] as $callback) { + $attributes = array_merge( + $attributes, + $callback($this, $model, $attributes) + ); + } + } + } + + // Deprecated in beta 15, removed in beta 16 static::$dispatcher->dispatch( new Serializing($this, $model, $attributes) ); @@ -102,7 +125,7 @@ abstract class AbstractSerializer extends BaseAbstractSerializer * @param DateTime|null $date * @return string|null */ - protected function formatDate(DateTime $date = null) + public function formatDate(DateTime $date = null) { if ($date) { return $date->format(DateTime::RFC3339); @@ -130,10 +153,20 @@ abstract class AbstractSerializer extends BaseAbstractSerializer */ protected function getCustomRelationship($model, $name) { + // Deprecated in beta 15, removed in beta 16 $relationship = static::$dispatcher->until( new GetApiRelationship($this, $name, $model) ); + foreach (array_merge([static::class], class_parents($this)) as $class) { + $callback = Arr::get(static::$customRelations, "$class.$name"); + + if (is_callable($callback)) { + $relationship = $callback($this, $model); + break; + } + } + if ($relationship && ! ($relationship instanceof Relationship)) { throw new LogicException( 'GetApiRelationship handler must return an instance of '.Relationship::class @@ -280,4 +313,27 @@ abstract class AbstractSerializer extends BaseAbstractSerializer { static::$container = $container; } + + /** + * @param string $serializerClass + * @param callable $mutator + */ + public static function addMutator(string $serializerClass, callable $mutator) + { + if (! isset(static::$mutators[$serializerClass])) { + static::$mutators[$serializerClass] = []; + } + + static::$mutators[$serializerClass][] = $mutator; + } + + /** + * @param string $serializerClass + * @param string $relation + * @param callable $callback + */ + public static function setRelationship(string $serializerClass, string $relation, callable $callback) + { + static::$customRelations[$serializerClass][$relation] = $callback; + } } diff --git a/src/Event/GetApiRelationship.php b/src/Event/GetApiRelationship.php index f7f62b546..1905292b0 100644 --- a/src/Event/GetApiRelationship.php +++ b/src/Event/GetApiRelationship.php @@ -21,6 +21,8 @@ use Flarum\Api\Serializer\AbstractSerializer; * @see AbstractSerializer::hasOne() * @see AbstractSerializer::hasMany() * @see https://github.com/tobscure/json-api + * + * @deprecated in beta 15, removed in beta 16 */ class GetApiRelationship { diff --git a/src/Extend/ApiSerializer.php b/src/Extend/ApiSerializer.php new file mode 100644 index 000000000..ac2475c32 --- /dev/null +++ b/src/Extend/ApiSerializer.php @@ -0,0 +1,162 @@ +<?php + +/* + * This file is part of Flarum. + * + * For detailed copyright and license information, please view the + * LICENSE file that was distributed with this source code. + */ + +namespace Flarum\Extend; + +use Flarum\Api\Serializer\AbstractSerializer; +use Flarum\Extension\Extension; +use Flarum\Foundation\ContainerUtil; +use Illuminate\Contracts\Container\Container; + +class ApiSerializer implements ExtenderInterface +{ + private $serializerClass; + private $attributes = []; + private $mutators = []; + private $relationships = []; + + /** + * @param string $serializerClass The ::class attribute of the serializer you are modifying. + * This serializer should extend from \Flarum\Api\Serializer\AbstractSerializer. + */ + public function __construct(string $serializerClass) + { + $this->serializerClass = $serializerClass; + } + + /** + * @param string $name: The name of the attribute. + * @param callable|string $callback + * + * The callback can be a closure or an invokable class, and should accept: + * - $serializer: An instance of this serializer. + * - $model: An instance of the model being serialized. + * - $attributes: An array of existing attributes. + * + * The callable should return: + * - The value of the attribute. + * + * @return self + */ + public function attribute(string $name, $callback) + { + $this->attributes[$name] = $callback; + + return $this; + } + + /** + * Add to or modify the attributes array of this serializer. + * + * @param callable|string $callback + * + * The callback can be a closure or an invokable class, and should accept: + * - $serializer: An instance of this serializer. + * - $model: An instance of the model being serialized. + * - $attributes: An array of existing attributes. + * + * The callable should return: + * - An array of additional attributes to merge with the existing array. + * Or a modified $attributes array. + * + * @return self + */ + public function mutate($callback) + { + $this->mutators[] = $callback; + + return $this; + } + + /** + * Establish a simple hasOne relationship from this serializer to another serializer. + * This represents a one-to-one relationship. + * + * @param string $name: The name of the relation. Has to be unique from other relation names. + * The relation has to exist in the model handled by this serializer. + * @param string $serializerClass: The ::class attribute the serializer that handles this relation. + * This serializer should extend from \Flarum\Api\Serializer\AbstractSerializer. + * @return self + */ + public function hasOne(string $name, string $serializerClass) + { + return $this->relationship($name, function (AbstractSerializer $serializer, $model) use ($serializerClass, $name) { + return $serializer->hasOne($model, $serializerClass, $name); + }); + } + + /** + * Establish a simple hasMany relationship from this serializer to another serializer. + * This represents a one-to-many relationship. + * + * @param string $name: The name of the relation. Has to be unique from other relation names. + * The relation has to exist in the model handled by this serializer. + * @param string $serializerClass: The ::class attribute the serializer that handles this relation. + * This serializer should extend from \Flarum\Api\Serializer\AbstractSerializer. + * @return self + */ + public function hasMany(string $name, string $serializerClass) + { + return $this->relationship($name, function (AbstractSerializer $serializer, $model) use ($serializerClass, $name) { + return $serializer->hasMany($model, $serializerClass, $name); + }); + } + + /** + * Add a relationship from this serializer to another serializer. + * + * @param string $name: The name of the relation. Has to be unique from other relation names. + * The relation has to exist in the model handled by this serializer. + * @param callable|string $callback + * + * The callable can be a closure or an invokable class, and should accept: + * - $serializer: An instance of this serializer. + * - $model: An instance of the model being serialized. + * + * The callable should return: + * - $relationship: An instance of \Tobscure\JsonApi\Relationship. + * + * @return self + */ + public function relationship(string $name, $callback) + { + $this->relationships[$this->serializerClass][$name] = $callback; + + return $this; + } + + public function extend(Container $container, Extension $extension = null) + { + if (! empty($this->attributes)) { + $this->mutators[] = function ($serializer, $model, $attributes) use ($container) { + foreach ($this->attributes as $attributeName => $callback) { + $callback = ContainerUtil::wrapCallback($callback, $container); + + $attributes[$attributeName] = $callback($serializer, $model, $attributes); + } + + return $attributes; + }; + } + + foreach ($this->mutators as $mutator) { + $mutator = ContainerUtil::wrapCallback($mutator, $container); + + AbstractSerializer::addMutator($this->serializerClass, $mutator); + } + + foreach ($this->relationships as $serializerClass => $relationships) { + foreach ($relationships as $relation => $callback) { + $callback = ContainerUtil::wrapCallback($callback, $container); + + AbstractSerializer::setRelationship($serializerClass, $relation, $callback); + } + } + } +} diff --git a/tests/integration/extenders/ApiSerializerTest.php b/tests/integration/extenders/ApiSerializerTest.php new file mode 100644 index 000000000..6e08ae9ea --- /dev/null +++ b/tests/integration/extenders/ApiSerializerTest.php @@ -0,0 +1,523 @@ +<?php + +/* + * This file is part of Flarum. + * + * For detailed copyright and license information, please view the + * LICENSE file that was distributed with this source code. + */ + +namespace Flarum\Tests\integration\extenders; + +use Carbon\Carbon; +use Flarum\Api\Serializer\AbstractSerializer; +use Flarum\Api\Serializer\BasicUserSerializer; +use Flarum\Api\Serializer\DiscussionSerializer; +use Flarum\Api\Serializer\ForumSerializer; +use Flarum\Api\Serializer\PostSerializer; +use Flarum\Api\Serializer\UserSerializer; +use Flarum\Discussion\Discussion; +use Flarum\Extend; +use Flarum\Post\Post; +use Flarum\Tests\integration\RetrievesAuthorizedUsers; +use Flarum\Tests\integration\TestCase; +use Flarum\User\User; + +class ApiSerializerTest extends TestCase +{ + use RetrievesAuthorizedUsers; + + protected function prepDb() + { + $this->prepareDatabase([ + 'users' => [ + $this->adminUser(), + $this->normalUser() + ], + 'discussions' => [ + ['id' => 1, 'title' => 'Custom Discussion Title', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 0, 'comment_count' => 1, 'is_private' => 0], + ['id' => 2, 'title' => 'Custom Discussion Title', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 0, 'comment_count' => 1, 'is_private' => 0], + ['id' => 3, 'title' => 'Custom Discussion Title', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 0, 'comment_count' => 1, 'is_private' => 0], + ], + 'posts' => [ + ['id' => 1, 'discussion_id' => 3, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'discussionRenamed', 'content' => '<t><p>can i haz relationz?</p></t>'], + ], + ]); + } + + protected function prepSettingsDb() + { + $this->prepareDatabase([ + 'settings' => [ + ['key' => 'customPrefix.customSetting', 'value' => 'customValue'] + ], + ]); + } + + /** + * @test + */ + public function custom_attributes_dont_exist_by_default() + { + $this->app(); + + $response = $this->send( + $this->request('GET', '/api', [ + 'authenticatedAs' => 1, + ]) + ); + + $payload = json_decode($response->getBody(), true); + + $this->assertArrayNotHasKey('customAttribute', $payload['data']['attributes']); + } + + /** + * @test + */ + public function custom_attributes_exist_if_added() + { + $this->extend( + (new Extend\ApiSerializer(ForumSerializer::class)) + ->mutate(function () { + return [ + 'customAttribute' => true + ]; + }) + ); + + $this->app(); + + $response = $this->send( + $this->request('GET', '/api', [ + 'authenticatedAs' => 1, + ]) + ); + + $payload = json_decode($response->getBody(), true); + + $this->assertArrayHasKey('customAttribute', $payload['data']['attributes']); + } + + /** + * @test + */ + public function custom_attributes_with_invokable_exist_if_added() + { + $this->extend( + (new Extend\ApiSerializer(ForumSerializer::class)) + ->mutate(CustomAttributesInvokableClass::class) + ); + + $this->app(); + + $response = $this->send( + $this->request('GET', '/api', [ + 'authenticatedAs' => 1, + ]) + ); + + $payload = json_decode($response->getBody(), true); + + $this->assertArrayHasKey('customAttributeFromInvokable', $payload['data']['attributes']); + } + + /** + * @test + */ + public function custom_attributes_exist_if_added_to_parent_class() + { + $this->extend( + (new Extend\ApiSerializer(BasicUserSerializer::class)) + ->mutate(function () { + return [ + 'customAttribute' => true + ]; + }) + ); + + $this->app(); + + $response = $this->send( + $this->request('GET', '/api/users/2', [ + 'authenticatedAs' => 1, + ]) + ); + + $payload = json_decode($response->getBody(), true); + + $this->assertArrayHasKey('customAttribute', $payload['data']['attributes']); + } + + /** + * @test + */ + public function custom_attributes_prioritize_child_classes() + { + $this->extend( + (new Extend\ApiSerializer(BasicUserSerializer::class)) + ->mutate(function () { + return [ + 'customAttribute' => 'initialValue' + ]; + }), + (new Extend\ApiSerializer(UserSerializer::class)) + ->mutate(function () { + return [ + 'customAttribute' => 'newValue' + ]; + }) + ); + + $this->app(); + + $response = $this->send( + $this->request('GET', '/api/users/2', [ + 'authenticatedAs' => 1, + ]) + ); + + $payload = json_decode($response->getBody(), true); + + $this->assertArrayHasKey('customAttribute', $payload['data']['attributes']); + $this->assertEquals('newValue', $payload['data']['attributes']['customAttribute']); + } + + /** + * @test + */ + public function custom_single_attribute_exists_if_added() + { + $this->extend( + (new Extend\ApiSerializer(ForumSerializer::class)) + ->attribute('customSingleAttribute', function () { + return true; + })->attribute('customSingleAttribute_0', function () { + return 0; + }) + ); + + $this->app(); + + $response = $this->send( + $this->request('GET', '/api', [ + 'authenticatedAs' => 1, + ]) + ); + + $payload = json_decode($response->getBody(), true); + + $this->assertArrayHasKey('customSingleAttribute', $payload['data']['attributes']); + $this->assertArrayHasKey('customSingleAttribute_0', $payload['data']['attributes']); + $this->assertEquals(0, $payload['data']['attributes']['customSingleAttribute_0']); + } + + /** + * @test + */ + public function custom_single_attribute_with_invokable_exists_if_added() + { + $this->extend( + (new Extend\ApiSerializer(ForumSerializer::class)) + ->attribute('customSingleAttribute_1', CustomSingleAttributeInvokableClass::class) + ); + + $this->app(); + + $response = $this->send( + $this->request('GET', '/api', [ + 'authenticatedAs' => 1, + ]) + ); + + $payload = json_decode($response->getBody(), true); + + $this->assertArrayHasKey('customSingleAttribute_1', $payload['data']['attributes']); + } + + /** + * @test + */ + public function custom_single_attribute_exists_if_added_to_parent_class() + { + $this->extend( + (new Extend\ApiSerializer(BasicUserSerializer::class)) + ->attribute('customSingleAttribute_2', function () { + return true; + }) + ); + + $this->app(); + + $response = $this->send( + $this->request('GET', '/api/users/2', [ + 'authenticatedAs' => 1, + ]) + ); + + $payload = json_decode($response->getBody(), true); + + $this->assertArrayHasKey('customSingleAttribute_2', $payload['data']['attributes']); + } + + /** + * @test + */ + public function custom_single_attribute_prioritizes_child_classes() + { + $this->extend( + (new Extend\ApiSerializer(BasicUserSerializer::class)) + ->attribute('customSingleAttribute_3', function () { + return 'initialValue'; + }), + (new Extend\ApiSerializer(UserSerializer::class)) + ->attribute('customSingleAttribute_3', function () { + return 'newValue'; + }) + ); + + $this->app(); + + $response = $this->send( + $this->request('GET', '/api/users/2', [ + 'authenticatedAs' => 1, + ]) + ); + + $payload = json_decode($response->getBody(), true); + + $this->assertArrayHasKey('customSingleAttribute_3', $payload['data']['attributes']); + $this->assertEquals('newValue', $payload['data']['attributes']['customSingleAttribute_3']); + } + + /** + * @test + */ + public function custom_attributes_can_be_overriden() + { + $this->extend( + (new Extend\ApiSerializer(BasicUserSerializer::class)) + ->attribute('someCustomAttribute', function () { + return 'newValue'; + })->mutate(function () { + return [ + 'someCustomAttribute' => 'initialValue', + 'someOtherCustomAttribute' => 'initialValue', + ]; + })->attribute('someOtherCustomAttribute', function () { + return 'newValue'; + }) + ); + + $this->app(); + + $response = $this->send( + $this->request('GET', '/api/users/2', [ + 'authenticatedAs' => 1, + ]) + ); + + $payload = json_decode($response->getBody(), true); + + $this->assertArrayHasKey('someCustomAttribute', $payload['data']['attributes']); + $this->assertEquals('newValue', $payload['data']['attributes']['someCustomAttribute']); + $this->assertArrayHasKey('someOtherCustomAttribute', $payload['data']['attributes']); + $this->assertEquals('newValue', $payload['data']['attributes']['someOtherCustomAttribute']); + } + + /** + * @test + */ + public function custom_hasMany_relationship_exists_if_added() + { + $this->extend( + (new Extend\Model(User::class)) + ->hasMany('customSerializerRelation', Discussion::class, 'user_id'), + (new Extend\ApiSerializer(UserSerializer::class)) + ->hasMany('customSerializerRelation', DiscussionSerializer::class) + ); + + $this->prepDb(); + + $request = $this->request('GET', '/api/users/2', [ + 'authenticatedAs' => 1, + ]); + + $serializer = $this->app()->getContainer()->make(UserSerializer::class); + $serializer->setRequest($request); + + $relationship = $serializer->getRelationship(User::find(2), 'customSerializerRelation'); + + $this->assertNotEmpty($relationship); + $this->assertCount(3, $relationship->toArray()['data']); + } + + /** + * @test + */ + public function custom_hasOne_relationship_exists_if_added() + { + $this->extend( + (new Extend\Model(User::class)) + ->hasOne('customSerializerRelation', Discussion::class, 'user_id'), + (new Extend\ApiSerializer(UserSerializer::class)) + ->hasOne('customSerializerRelation', DiscussionSerializer::class) + ); + + $this->prepDb(); + + $request = $this->request('GET', '/api/users/2', [ + 'authenticatedAs' => 1, + ]); + + $serializer = $this->app()->getContainer()->make(UserSerializer::class); + $serializer->setRequest($request); + + $relationship = $serializer->getRelationship(User::find(2), 'customSerializerRelation'); + + $this->assertNotEmpty($relationship); + $this->assertEquals('discussions', $relationship->toArray()['data']['type']); + } + + /** + * @test + */ + public function custom_relationship_exists_if_added() + { + $this->extend( + (new Extend\Model(User::class)) + ->hasOne('customSerializerRelation', Discussion::class, 'user_id'), + (new Extend\ApiSerializer(UserSerializer::class)) + ->relationship('customSerializerRelation', function (AbstractSerializer $serializer, $model) { + return $serializer->hasOne($model, DiscussionSerializer::class, 'customSerializerRelation'); + }) + ); + + $this->prepDb(); + + $request = $this->request('GET', '/api/users/2', [ + 'authenticatedAs' => 1, + ]); + + $serializer = $this->app()->getContainer()->make(UserSerializer::class); + $serializer->setRequest($request); + + $relationship = $serializer->getRelationship(User::find(2), 'customSerializerRelation'); + + $this->assertNotEmpty($relationship); + $this->assertEquals('discussions', $relationship->toArray()['data']['type']); + } + + /** + * @test + */ + public function custom_relationship_with_invokable_exists_if_added() + { + $this->extend( + (new Extend\Model(User::class)) + ->hasOne('customSerializerRelation', Discussion::class, 'user_id'), + (new Extend\ApiSerializer(UserSerializer::class)) + ->relationship('customSerializerRelation', CustomRelationshipInvokableClass::class) + ); + + $this->prepDb(); + + $request = $this->request('GET', '/api/users/2', [ + 'authenticatedAs' => 1, + ]); + + $serializer = $this->app()->getContainer()->make(UserSerializer::class); + $serializer->setRequest($request); + + $relationship = $serializer->getRelationship(User::find(2), 'customSerializerRelation'); + + $this->assertNotEmpty($relationship); + $this->assertEquals('discussions', $relationship->toArray()['data']['type']); + } + + /** + * @test + */ + public function custom_relationship_is_inherited_to_child_classes() + { + $this->extend( + (new Extend\Model(User::class)) + ->hasMany('anotherCustomRelation', Discussion::class, 'user_id'), + (new Extend\ApiSerializer(BasicUserSerializer::class)) + ->hasMany('anotherCustomRelation', DiscussionSerializer::class) + ); + + $this->prepDb(); + + $request = $this->request('GET', '/api/users/2', [ + 'authenticatedAs' => 1, + ]); + + $serializer = $this->app()->getContainer()->make(UserSerializer::class); + $serializer->setRequest($request); + + $relationship = $serializer->getRelationship(User::find(2), 'anotherCustomRelation'); + + $this->assertNotEmpty($relationship); + $this->assertCount(3, $relationship->toArray()['data']); + } + + /** + * @test + */ + public function custom_relationship_prioritizes_child_classes() + { + $this->extend( + (new Extend\Model(User::class)) + ->hasOne('postCustomRelation', Post::class, 'user_id'), + (new Extend\Model(User::class)) + ->hasOne('discussionCustomRelation', Discussion::class, 'user_id'), + (new Extend\ApiSerializer(BasicUserSerializer::class)) + ->hasOne('postCustomRelation', PostSerializer::class), + (new Extend\ApiSerializer(UserSerializer::class)) + ->relationship('postCustomRelation', function (AbstractSerializer $serializer, $model) { + return $serializer->hasOne($model, DiscussionSerializer::class, 'discussionCustomRelation'); + }) + ); + + $this->prepDb(); + + $request = $this->request('GET', '/api/users/2', [ + 'authenticatedAs' => 1, + ]); + + $serializer = $this->app()->getContainer()->make(UserSerializer::class); + $serializer->setRequest($request); + + $relationship = $serializer->getRelationship(User::find(2), 'postCustomRelation'); + + $this->assertNotEmpty($relationship); + $this->assertEquals('discussions', $relationship->toArray()['data']['type']); + } +} + +class CustomAttributesInvokableClass +{ + public function __invoke() + { + return [ + 'customAttributeFromInvokable' => true + ]; + } +} + +class CustomSingleAttributeInvokableClass +{ + public function __invoke() + { + return true; + } +} + +class CustomRelationshipInvokableClass +{ + public function __invoke(AbstractSerializer $serializer, $model) + { + return $serializer->hasOne($model, DiscussionSerializer::class, 'customSerializerRelation'); + } +}