diff --git a/extensions/mentions/extend.php b/extensions/mentions/extend.php index a85d04dab..a58dc5764 100644 --- a/extensions/mentions/extend.php +++ b/extensions/mentions/extend.php @@ -9,16 +9,14 @@ namespace Flarum\Mentions; -use Flarum\Api\Controller; -use Flarum\Api\Serializer\BasicPostSerializer; -use Flarum\Api\Serializer\BasicUserSerializer; -use Flarum\Api\Serializer\CurrentUserSerializer; -use Flarum\Api\Serializer\GroupSerializer; -use Flarum\Api\Serializer\PostSerializer; +use Flarum\Api\Context; +use Flarum\Api\Endpoint; +use Flarum\Api\Resource; +use Flarum\Api\Schema; use Flarum\Approval\Event\PostWasApproved; use Flarum\Extend; use Flarum\Group\Group; -use Flarum\Mentions\Api\LoadMentionedByRelationship; +use Flarum\Mentions\Api\PostResourceFields; use Flarum\Post\Event\Deleted; use Flarum\Post\Event\Hidden; use Flarum\Post\Event\Posted; @@ -27,7 +25,6 @@ use Flarum\Post\Event\Revised; use Flarum\Post\Filter\PostSearcher; use Flarum\Post\Post; use Flarum\Search\Database\DatabaseSearchDriver; -use Flarum\Tags\Api\Serializer\TagSerializer; use Flarum\User\User; return [ @@ -60,50 +57,43 @@ return [ ->namespace('flarum-mentions', __DIR__.'/views'), (new Extend\Notification()) - ->type(Notification\PostMentionedBlueprint::class, PostSerializer::class, ['alert']) - ->type(Notification\UserMentionedBlueprint::class, PostSerializer::class, ['alert']) - ->type(Notification\GroupMentionedBlueprint::class, PostSerializer::class, ['alert']), + ->type(Notification\PostMentionedBlueprint::class, ['alert']) + ->type(Notification\UserMentionedBlueprint::class, ['alert']) + ->type(Notification\GroupMentionedBlueprint::class, ['alert']), - (new Extend\ApiSerializer(BasicPostSerializer::class)) - ->hasMany('mentionedBy', BasicPostSerializer::class) - ->hasMany('mentionsPosts', BasicPostSerializer::class) - ->hasMany('mentionsUsers', BasicUserSerializer::class) - ->hasMany('mentionsGroups', GroupSerializer::class) - ->attribute('mentionedByCount', function (BasicPostSerializer $serializer, Post $post) { - // Only if it was eager loaded. - return $post->getAttribute('mentioned_by_count') ?? 0; + (new Extend\ApiResource(Resource\PostResource::class)) + ->fields(PostResourceFields::class) + ->endpoint([Endpoint\Index::class, Endpoint\Show::class], function (Endpoint\Index|Endpoint\Show $endpoint): Endpoint\Endpoint { + return $endpoint->addDefaultInclude(['mentionedBy', 'mentionedBy.user', 'mentionedBy.discussion']); + }) + ->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index { + return $endpoint->eagerLoad(['mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionsPosts.discussion', 'mentionsGroups']); }), - (new Extend\ApiController(Controller\ShowDiscussionController::class)) - ->addInclude(['posts.mentionedBy', 'posts.mentionedBy.user', 'posts.mentionedBy.discussion']) - ->load([ - 'posts.mentionsUsers', 'posts.mentionsPosts', 'posts.mentionsPosts.user', - 'posts.mentionsPosts.discussion', 'posts.mentionsGroups' - ]) - ->loadWhere('posts.mentionedBy', LoadMentionedByRelationship::mutateRelation(...)) - ->prepareDataForSerialization(LoadMentionedByRelationship::countRelation(...)), + (new Extend\ApiResource(Resource\DiscussionResource::class)) + ->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index { + return $endpoint->eagerLoad([ + 'firstPost.mentionsUsers', 'firstPost.mentionsPosts', + 'firstPost.mentionsPosts.user', 'firstPost.mentionsPosts.discussion', 'firstPost.mentionsGroups', + 'lastPost.mentionsUsers', 'lastPost.mentionsPosts', + 'lastPost.mentionsPosts.user', 'lastPost.mentionsPosts.discussion', 'lastPost.mentionsGroups', + ]); + }) + ->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\Show { + return $endpoint->addDefaultInclude(['posts.mentionedBy', 'posts.mentionedBy.user', 'posts.mentionedBy.discussion']) + ->eagerLoad([ + 'posts.mentionsUsers', 'posts.mentionsPosts', 'posts.mentionsPosts.user', + 'posts.mentionsPosts.discussion', 'posts.mentionsGroups' + ]); + }), - (new Extend\ApiController(Controller\ListDiscussionsController::class)) - ->load([ - 'firstPost.mentionsUsers', 'firstPost.mentionsPosts', - 'firstPost.mentionsPosts.user', 'firstPost.mentionsPosts.discussion', 'firstPost.mentionsGroups', - 'lastPost.mentionsUsers', 'lastPost.mentionsPosts', - 'lastPost.mentionsPosts.user', 'lastPost.mentionsPosts.discussion', 'lastPost.mentionsGroups', + (new Extend\ApiResource(Resource\UserResource::class)) + ->fields(fn () => [ + Schema\Boolean::make('canMentionGroups') + ->visible(fn (User $user, Context $context) => $context->getActor()->id === $user->id) + ->get(fn (User $user) => $user->can('mentionGroups')), ]), - (new Extend\ApiController(Controller\ShowPostController::class)) - ->addInclude(['mentionedBy', 'mentionedBy.user', 'mentionedBy.discussion']) - // We wouldn't normally need to eager load on a single model, - // but we do so here for visibility scoping. - ->loadWhere('mentionedBy', LoadMentionedByRelationship::mutateRelation(...)) - ->prepareDataForSerialization(LoadMentionedByRelationship::countRelation(...)), - - (new Extend\ApiController(Controller\ListPostsController::class)) - ->addInclude(['mentionedBy', 'mentionedBy.user', 'mentionedBy.discussion']) - ->load(['mentionsUsers', 'mentionsPosts', 'mentionsPosts.user', 'mentionsPosts.discussion', 'mentionsGroups']) - ->loadWhere('mentionedBy', LoadMentionedByRelationship::mutateRelation(...)) - ->prepareDataForSerialization(LoadMentionedByRelationship::countRelation(...)), - (new Extend\Settings) ->serializeToForum('allowUsernameMentionFormat', 'flarum-mentions.allow_username_format', 'boolval'), @@ -119,11 +109,6 @@ return [ ->addFilter(PostSearcher::class, Filter\MentionedFilter::class) ->addFilter(PostSearcher::class, Filter\MentionedPostFilter::class), - (new Extend\ApiSerializer(CurrentUserSerializer::class)) - ->attribute('canMentionGroups', function (CurrentUserSerializer $serializer, User $user): bool { - return $user->can('mentionGroups'); - }), - // Tag mentions (new Extend\Conditional()) ->whenExtensionEnabled('flarum-tags', fn () => [ @@ -131,18 +116,23 @@ return [ ->render(Formatter\FormatTagMentions::class) ->unparse(Formatter\UnparseTagMentions::class), - (new Extend\ApiSerializer(BasicPostSerializer::class)) - ->hasMany('mentionsTags', TagSerializer::class), - - (new Extend\ApiController(Controller\ShowDiscussionController::class)) - ->load(['posts.mentionsTags']), - - (new Extend\ApiController(Controller\ListDiscussionsController::class)) - ->load([ - 'firstPost.mentionsTags', 'lastPost.mentionsTags', + (new Extend\ApiResource(Resource\PostResource::class)) + ->fields(fn () => [ + Schema\Relationship\ToMany::make('mentionsTags') + ->type('tags'), ]), - (new Extend\ApiController(Controller\ListPostsController::class)) - ->load(['mentionsTags']), + (new Extend\ApiResource(Resource\DiscussionResource::class)) + ->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\Show { + return $endpoint->eagerLoad(['posts.mentionsTags']); + }) + ->endpoint(Endpoint\Index::class, function (Endpoint\Index $endpoint): Endpoint\Index { + return $endpoint->eagerLoad(['firstPost.mentionsTags', 'lastPost.mentionsTags']); + }), + + (new Extend\ApiResource(Resource\PostResource::class)) + ->endpoint([Endpoint\Index::class, Endpoint\Show::class], function (Endpoint\Index|Endpoint\Show $endpoint): Endpoint\Endpoint { + return $endpoint->eagerLoad(['mentionsTags']); + }), ]), ]; diff --git a/extensions/mentions/src/Api/LoadMentionedByRelationship.php b/extensions/mentions/src/Api/LoadMentionedByRelationship.php deleted file mode 100644 index 47bceafb7..000000000 --- a/extensions/mentions/src/Api/LoadMentionedByRelationship.php +++ /dev/null @@ -1,82 +0,0 @@ -with(['mentionsPosts', 'mentionsPosts.user', 'mentionsPosts.discussion', 'mentionsUsers']) - ->whereVisibleTo($actor) - ->oldest() - // Limiting a relationship results is only possible because - // the Post model uses the \Staudenmeir\EloquentEagerLimit\HasEagerLimit - // trait. - ->limit(self::$maxMentionedBy); - } - - /** - * Called using the @see ApiController::prepareDataForSerialization extender. - */ - public static function countRelation(AbstractSerializeController $controller, mixed $data, ServerRequestInterface $request): array - { - $actor = RequestUtil::getActor($request); - $loadable = null; - - if ($data instanceof Discussion) { - // We do this because the ShowDiscussionController manipulates the posts - // in a way that some of them are just ids. - $loadable = $data->posts->filter(function ($post) { - return $post instanceof Post; - }); - - // firstPost and lastPost might have been included in the API response, - // so we have to make sure counts are also loaded for them. - if ($data->firstPost) { - $loadable->push($data->firstPost); - } - - if ($data->lastPost) { - $loadable->push($data->lastPost); - } - } elseif ($data instanceof Collection) { - $loadable = $data; - } elseif ($data instanceof Post) { - $loadable = $data->newCollection([$data]); - } - - if ($loadable) { - $loadable->loadCount([ - 'mentionedBy' => function ($query) use ($actor) { - return $query->whereVisibleTo($actor); - } - ]); - } - - return []; - } -} diff --git a/extensions/mentions/src/Api/PostResourceFields.php b/extensions/mentions/src/Api/PostResourceFields.php new file mode 100644 index 000000000..be188e2e0 --- /dev/null +++ b/extensions/mentions/src/Api/PostResourceFields.php @@ -0,0 +1,31 @@ +countRelation('mentionedBy'), + + Schema\Relationship\ToMany::make('mentionedBy') + ->type('posts') + ->includable() + ->limit(static::$maxMentionedBy), + Schema\Relationship\ToMany::make('mentionsPosts') + ->type('posts'), + Schema\Relationship\ToMany::make('mentionsUsers') + ->type('users'), + Schema\Relationship\ToMany::make('mentionsGroups') + ->type('groups'), + ]; + } +} diff --git a/extensions/mentions/tests/integration/api/ListPostsTest.php b/extensions/mentions/tests/integration/api/ListPostsTest.php index ab0964648..1e7c22b65 100644 --- a/extensions/mentions/tests/integration/api/ListPostsTest.php +++ b/extensions/mentions/tests/integration/api/ListPostsTest.php @@ -11,6 +11,7 @@ namespace Flarum\Mentions\Tests\integration\api\discussions; use Carbon\Carbon; use Flarum\Mentions\Api\LoadMentionedByRelationship; +use Flarum\Mentions\Api\PostResourceFields; use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; use Illuminate\Support\Arr; @@ -167,7 +168,7 @@ class ListPostsTest extends TestCase $mentionedBy = $data['relationships']['mentionedBy']['data']; // Only displays a limited amount of mentioned by posts - $this->assertCount(LoadMentionedByRelationship::$maxMentionedBy, $mentionedBy); + $this->assertCount(PostResourceFields::$maxMentionedBy, $mentionedBy); // Of the limited amount of mentioned by posts, they must be visible to the actor $this->assertEquals([102, 104, 105, 106], Arr::pluck($mentionedBy, 'id')); } @@ -187,14 +188,14 @@ class ListPostsTest extends TestCase ]) ); - $data = json_decode($response->getBody()->getContents(), true)['data']; + $data = json_decode($body = $response->getBody()->getContents(), true)['data'] ?? []; - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $body); $mentionedBy = $data[0]['relationships']['mentionedBy']['data']; // Only displays a limited amount of mentioned by posts - $this->assertCount(LoadMentionedByRelationship::$maxMentionedBy, $mentionedBy); + $this->assertCount(PostResourceFields::$maxMentionedBy, $mentionedBy); // Of the limited amount of mentioned by posts, they must be visible to the actor $this->assertEquals([102, 104, 105, 106], Arr::pluck($mentionedBy, 'id')); } @@ -203,7 +204,7 @@ class ListPostsTest extends TestCase * @dataProvider mentionedByIncludeProvider * @test */ - public function mentioned_by_relation_returns_limited_results_and_shows_only_visible_posts_in_show_discussion_endpoint(string $include) + public function mentioned_by_relation_returns_limited_results_and_shows_only_visible_posts_in_show_discussion_endpoint(?string $include) { $this->prepareMentionedByData(); @@ -216,15 +217,18 @@ class ListPostsTest extends TestCase ]) ); - $included = json_decode($response->getBody()->getContents(), true)['included']; + $included = json_decode($body = $response->getBody()->getContents(), true)['included'] ?? []; + + $this->assertEquals(200, $response->getStatusCode(), $body); $mentionedBy = collect($included) ->where('type', 'posts') ->where('id', 101) - ->first()['relationships']['mentionedBy']['data']; + ->first()['relationships']['mentionedBy']['data'] ?? null; + $this->assertNotNull($mentionedBy, 'Mentioned by relation not included'); // Only displays a limited amount of mentioned by posts - $this->assertCount(LoadMentionedByRelationship::$maxMentionedBy, $mentionedBy); + $this->assertCount(PostResourceFields::$maxMentionedBy, $mentionedBy); // Of the limited amount of mentioned by posts, they must be visible to the actor $this->assertEquals([102, 104, 105, 106], Arr::pluck($mentionedBy, 'id')); } @@ -234,7 +238,7 @@ class ListPostsTest extends TestCase return [ ['posts,posts.mentionedBy'], ['posts.mentionedBy'], - [''], + [null], ]; } @@ -250,10 +254,54 @@ class ListPostsTest extends TestCase ]) ); - $data = json_decode($response->getBody()->getContents(), true)['data']; + $data = json_decode($body = $response->getBody()->getContents(), true)['data'] ?? []; - $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(200, $response->getStatusCode(), $body); $this->assertEquals(0, $data['attributes']['mentionedByCount']); } + + /** @test */ + public function mentioned_by_count_works_on_show_endpoint() + { + $this->prepareMentionedByData(); + + // List posts endpoint + $response = $this->send( + $this->request('GET', '/api/posts/101', [ + 'authenticatedAs' => 1, + ]) + ); + + $data = json_decode($body = $response->getBody()->getContents(), true)['data'] ?? []; + + $this->assertEquals(200, $response->getStatusCode(), $body); + + $this->assertEquals(10, $data['attributes']['mentionedByCount']); + } + + /** @test */ + public function mentioned_by_count_works_on_list_endpoint() + { + $this->prepareMentionedByData(); + + // List posts endpoint + $response = $this->send( + $this->request('GET', '/api/posts', [ + 'authenticatedAs' => 1, + ])->withQueryParams([ + 'filter' => ['discussion' => 100], + ]) + ); + + $data = json_decode($body = $response->getBody()->getContents(), true)['data'] ?? []; + + $this->assertEquals(200, $response->getStatusCode(), $body); + + $post101 = collect($data)->where('id', 101)->first(); + $post112 = collect($data)->where('id', 112)->first(); + + $this->assertEquals(10, $post101['attributes']['mentionedByCount']); + $this->assertEquals(0, $post112['attributes']['mentionedByCount']); + } } diff --git a/framework/core/src/Api/Resource/AbstractDatabaseResource.php b/framework/core/src/Api/Resource/AbstractDatabaseResource.php index d1265656c..b1429967c 100644 --- a/framework/core/src/Api/Resource/AbstractDatabaseResource.php +++ b/framework/core/src/Api/Resource/AbstractDatabaseResource.php @@ -14,7 +14,6 @@ use Flarum\Api\Resource\Concerns\Bootable; use Flarum\Api\Resource\Concerns\Extendable; use Flarum\Foundation\DispatchEventsTrait; use Flarum\User\User; -use Illuminate\Support\Arr; use RuntimeException; use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Laravel\EloquentResource as BaseResource; diff --git a/framework/core/src/Api/Schema/Number.php b/framework/core/src/Api/Schema/Number.php index 8ae0c0eae..396c32477 100644 --- a/framework/core/src/Api/Schema/Number.php +++ b/framework/core/src/Api/Schema/Number.php @@ -2,8 +2,13 @@ namespace Flarum\Api\Schema; -class Number extends Attribute +use Tobyz\JsonApiServer\Schema\Concerns\GetsRelationAggregates; +use Tobyz\JsonApiServer\Schema\Contracts\RelationAggregator; + +class Number extends Attribute implements RelationAggregator { + use GetsRelationAggregates; + public static function make(string $name): static { return (new static($name)) diff --git a/framework/core/src/Extend/ApiResource.php b/framework/core/src/Extend/ApiResource.php index 47588d30a..0f40d3b80 100644 --- a/framework/core/src/Extend/ApiResource.php +++ b/framework/core/src/Extend/ApiResource.php @@ -71,7 +71,7 @@ class ApiResource implements ExtenderInterface public function endpoint(string|array $endpointClass, callable|string $mutator): self { foreach ((array) $endpointClass as $endpointClassItem) { - $this->endpoint[$endpointClassItem] = $mutator; + $this->endpoint[$endpointClassItem][] = $mutator; } return $this; @@ -111,7 +111,7 @@ class ApiResource implements ExtenderInterface public function field(string|array $field, callable|string $mutator): self { foreach ((array) $field as $fieldItem) { - $this->field[$fieldItem] = $mutator; + $this->field[$fieldItem][] = $mutator; } return $this; @@ -151,7 +151,7 @@ class ApiResource implements ExtenderInterface public function sort(string|array $sort, callable|string $mutator): self { foreach ((array) $sort as $sortItem) { - $this->sort[$sortItem] = $mutator; + $this->sort[$sortItem][] = $mutator; } return $this; @@ -189,12 +189,14 @@ class ApiResource implements ExtenderInterface foreach ($endpoints as $key => $endpoint) { $endpointClass = $endpoint::class; - if (isset($this->endpoint[$endpointClass])) { - $mutateEndpoint = ContainerUtil::wrapCallback($this->endpoint[$endpointClass], $container); - $endpoint = $mutateEndpoint($endpoint, $resource); + if (! empty($this->endpoint[$endpointClass])) { + foreach ($this->endpoint[$endpointClass] as $mutator) { + $mutateEndpoint = ContainerUtil::wrapCallback($mutator, $container); + $endpoint = $mutateEndpoint($endpoint, $resource); - if (! $endpoint instanceof Endpoint) { - throw new \RuntimeException('The endpoint mutator must return an instance of ' . Endpoint::class); + if (! $endpoint instanceof Endpoint) { + throw new \RuntimeException('The endpoint mutator must return an instance of ' . Endpoint::class); + } } } @@ -219,12 +221,14 @@ class ApiResource implements ExtenderInterface } foreach ($fields as $key => $field) { - if (isset($this->field[$field->name])) { - $mutateField = ContainerUtil::wrapCallback($this->field[$field->name], $container); - $field = $mutateField($field); + if (! empty($this->field[$field->name])) { + foreach ($this->field[$field->name] as $mutator) { + $mutateField = ContainerUtil::wrapCallback($mutator, $container); + $field = $mutateField($field); - if (! $field instanceof Field) { - throw new \RuntimeException('The field mutator must return an instance of ' . Field::class); + if (! $field instanceof Field) { + throw new \RuntimeException('The field mutator must return an instance of ' . Field::class); + } } } @@ -249,12 +253,14 @@ class ApiResource implements ExtenderInterface } foreach ($sorts as $key => $sort) { - if (isset($this->sort[$sort->name])) { - $mutateSort = ContainerUtil::wrapCallback($this->sort[$sort], $container); - $sort = $mutateSort($sort); + if (! empty($this->sort[$sort->name])) { + foreach ($this->sort[$sort->name] as $mutator) { + $mutateSort = ContainerUtil::wrapCallback($mutator, $container); + $sort = $mutateSort($sort); - if (! $sort instanceof Sort) { - throw new \RuntimeException('The sort mutator must return an instance of ' . Sort::class); + if (! $sort instanceof Sort) { + throw new \RuntimeException('The sort mutator must return an instance of ' . Sort::class); + } } }