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');
+    }
+}