From 51e2ab8502c2fa647271303e1981bbb35bd8af13 Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Fri, 10 May 2024 14:13:14 +0100 Subject: [PATCH] chore: drop the need for a json-api-server fork --- composer.json | 2 +- .../likes/src/Api/PostResourceFields.php | 2 +- .../mentions/src/Api/PostResourceFields.php | 2 +- .../src/Api/DiscussionResourceFields.php | 2 +- framework/core/composer.json | 2 +- framework/core/src/Api/ApiServiceProvider.php | 6 +- framework/core/src/Api/Context.php | 72 ++++ .../Endpoint/Concerns/HasAuthorization.php | 7 +- .../Api/Endpoint/Concerns/HasCustomHooks.php | 4 + .../Api/Endpoint/Concerns/HasEagerLoading.php | 207 ++++++++++ .../src/Api/Endpoint/Concerns/HasHooks.php | 57 +++ .../Api/Endpoint/Concerns/IncludesData.php | 22 + .../Concerns/SavesAndValidatesData.php | 163 ++++++++ framework/core/src/Api/Endpoint/Create.php | 73 +++- framework/core/src/Api/Endpoint/Delete.php | 48 ++- framework/core/src/Api/Endpoint/Endpoint.php | 130 +++++- .../src/Api/Endpoint/EndpointInterface.php | 5 +- framework/core/src/Api/Endpoint/Index.php | 180 ++++++++- framework/core/src/Api/Endpoint/Show.php | 24 +- framework/core/src/Api/Endpoint/Update.php | 51 ++- framework/core/src/Api/JsonApi.php | 46 ++- .../Api/Resource/AbstractDatabaseResource.php | 382 ++++++++++++++++-- .../src/Api/Resource/AbstractResource.php | 5 + .../src/Api/Resource/Contracts/Attachable.php | 10 + .../src/Api/Resource/Contracts/Countable.php | 13 + .../src/Api/Resource/Contracts/Creatable.php | 10 + .../src/Api/Resource/Contracts/Deletable.php | 10 + .../src/Api/Resource/Contracts/Findable.php | 10 + .../src/Api/Resource/Contracts/Listable.php | 47 +++ .../Api/Resource/Contracts/Paginatable.php | 10 + .../src/Api/Resource/Contracts/Updatable.php | 10 + .../core/src/Api/Resource/EloquentBuffer.php | 133 ++++++ framework/core/src/Api/Schema/Attribute.php | 3 +- .../src/Api/Schema/Concerns/FlarumField.php | 37 ++ .../Schema/Concerns/FlarumRelationship.php | 30 ++ .../Concerns/GetsRelationAggregates.php | 55 +++ .../Schema/Concerns/HasValidationRules.php | 158 ++++++++ .../Schema/Contracts/RelationAggregator.php | 13 + framework/core/src/Api/Schema/Number.php | 4 +- .../src/Api/Schema/Relationship/ToMany.php | 5 +- .../src/Api/Schema/Relationship/ToOne.php | 33 +- framework/core/src/Api/Sort/SortWithCount.php | 10 + framework/core/src/Extend/ApiResource.php | 4 +- .../integration/extenders/ConditionalTest.php | 6 +- 44 files changed, 2015 insertions(+), 88 deletions(-) create mode 100644 framework/core/src/Api/Endpoint/Concerns/HasEagerLoading.php create mode 100644 framework/core/src/Api/Endpoint/Concerns/HasHooks.php create mode 100644 framework/core/src/Api/Endpoint/Concerns/IncludesData.php create mode 100644 framework/core/src/Api/Endpoint/Concerns/SavesAndValidatesData.php create mode 100644 framework/core/src/Api/Resource/Contracts/Attachable.php create mode 100644 framework/core/src/Api/Resource/Contracts/Countable.php create mode 100644 framework/core/src/Api/Resource/Contracts/Creatable.php create mode 100644 framework/core/src/Api/Resource/Contracts/Deletable.php create mode 100644 framework/core/src/Api/Resource/Contracts/Findable.php create mode 100644 framework/core/src/Api/Resource/Contracts/Listable.php create mode 100644 framework/core/src/Api/Resource/Contracts/Paginatable.php create mode 100644 framework/core/src/Api/Resource/Contracts/Updatable.php create mode 100644 framework/core/src/Api/Resource/EloquentBuffer.php create mode 100644 framework/core/src/Api/Schema/Concerns/FlarumField.php create mode 100644 framework/core/src/Api/Schema/Concerns/FlarumRelationship.php create mode 100644 framework/core/src/Api/Schema/Concerns/GetsRelationAggregates.php create mode 100644 framework/core/src/Api/Schema/Concerns/HasValidationRules.php create mode 100644 framework/core/src/Api/Schema/Contracts/RelationAggregator.php create mode 100644 framework/core/src/Api/Sort/SortWithCount.php diff --git a/composer.json b/composer.json index 1f17448cb..17431a63b 100644 --- a/composer.json +++ b/composer.json @@ -162,7 +162,7 @@ "symfony/postmark-mailer": "^6.3", "symfony/translation": "^6.3", "symfony/yaml": "^6.3", - "flarum/json-api-server": "^1.0.0", + "flarum/json-api-server": "^0.1.0", "wikimedia/less.php": "^4.1" }, "require-dev": { diff --git a/extensions/likes/src/Api/PostResourceFields.php b/extensions/likes/src/Api/PostResourceFields.php index 1bf5d8900..d40e9f628 100644 --- a/extensions/likes/src/Api/PostResourceFields.php +++ b/extensions/likes/src/Api/PostResourceFields.php @@ -51,7 +51,7 @@ class PostResourceFields Schema\Relationship\ToMany::make('likes') ->type('users') ->includable() - ->constrain(function (Builder $query, Context $context) { + ->scope(function (Builder $query, Context $context) { $actor = $context->getActor(); $grammar = $query->getQuery()->getGrammar(); diff --git a/extensions/mentions/src/Api/PostResourceFields.php b/extensions/mentions/src/Api/PostResourceFields.php index e27376ac2..64b800eaf 100644 --- a/extensions/mentions/src/Api/PostResourceFields.php +++ b/extensions/mentions/src/Api/PostResourceFields.php @@ -25,7 +25,7 @@ class PostResourceFields Schema\Relationship\ToMany::make('mentionedBy') ->type('posts') ->includable() - ->constrain(fn (Builder $query) => $query->oldest('id')->limit(static::$maxMentionedBy)), + ->scope(fn (Builder $query) => $query->oldest('id')->limit(static::$maxMentionedBy)), Schema\Relationship\ToMany::make('mentionsPosts') ->type('posts'), Schema\Relationship\ToMany::make('mentionsUsers') diff --git a/extensions/sticky/src/Api/DiscussionResourceFields.php b/extensions/sticky/src/Api/DiscussionResourceFields.php index ca4987d90..22730b514 100644 --- a/extensions/sticky/src/Api/DiscussionResourceFields.php +++ b/extensions/sticky/src/Api/DiscussionResourceFields.php @@ -23,7 +23,7 @@ class DiscussionResourceFields return [ Schema\Boolean::make('isSticky') ->writable(function (Discussion $discussion, Context $context) { - return $context->endpoint instanceof Update + return $context->updating() && $context->getActor()->can('sticky', $discussion); }) ->set(function (Discussion $discussion, bool $isSticky, Context $context) { diff --git a/framework/core/composer.json b/framework/core/composer.json index aeba06447..99847daf7 100644 --- a/framework/core/composer.json +++ b/framework/core/composer.json @@ -91,7 +91,7 @@ "symfony/translation": "^6.3", "symfony/translation-contracts": "^2.5", "symfony/yaml": "^6.3", - "flarum/json-api-server": "^1.0.0", + "flarum/json-api-server": "^0.1.0", "wikimedia/less.php": "^4.1" }, "require-dev": { diff --git a/framework/core/src/Api/ApiServiceProvider.php b/framework/core/src/Api/ApiServiceProvider.php index 4fda34143..b165e863b 100644 --- a/framework/core/src/Api/ApiServiceProvider.php +++ b/framework/core/src/Api/ApiServiceProvider.php @@ -53,7 +53,7 @@ class ApiServiceProvider extends AbstractServiceProvider $api->container($container); foreach ($resources as $resourceClass) { - /** @var \Flarum\Api\Resource\AbstractResource|\Flarum\Api\Resource\AbstractDatabaseResource $resource */ + /** @var \Flarum\Api\Resource\AbstractResource $resource */ $resource = $container->make($resourceClass); $api->resource($resource->boot($api)); } @@ -189,7 +189,7 @@ class ApiServiceProvider extends AbstractServiceProvider * * We avoid dependency injection here to avoid early resolution. * - * @var \Flarum\Api\Resource\AbstractResource|\Flarum\Api\Resource\AbstractDatabaseResource $resource + * @var \Flarum\Api\Resource\AbstractResource $resource */ $resource = (new ReflectionClass($resourceClass))->newInstanceWithoutConstructor(); @@ -199,7 +199,7 @@ class ApiServiceProvider extends AbstractServiceProvider * None of the injected dependencies should be directly used within * the `endpoints` method. Encourage using callbacks. * - * @var array $endpoints + * @var array $endpoints */ $endpoints = $resource->resolveEndpoints(true); diff --git a/framework/core/src/Api/Context.php b/framework/core/src/Api/Context.php index 2d48124d6..7eb4556e8 100644 --- a/framework/core/src/Api/Context.php +++ b/framework/core/src/Api/Context.php @@ -12,10 +12,18 @@ namespace Flarum\Api; use Flarum\Http\RequestUtil; use Flarum\Search\SearchResults; use Flarum\User\User; +use Psr\Http\Message\ServerRequestInterface; use Tobyz\JsonApiServer\Context as BaseContext; +use Tobyz\JsonApiServer\Resource\Resource; +use Tobyz\JsonApiServer\Schema\Field\Field; +use WeakMap; class Context extends BaseContext { + private WeakMap $fields; + + public int|string|null $modelId = null; + public ?array $requestIncludes = null; protected ?SearchResults $search = null; /** @@ -29,6 +37,33 @@ class Context extends BaseContext */ protected array $parameters = []; + public function __construct(\Tobyz\JsonApiServer\JsonApi $api, ServerRequestInterface $request) + { + $this->fields = new WeakMap(); + + parent::__construct($api, $request); + } + + /** + * Get the fields for the given resource, keyed by name. + * + * @return array + */ + public function fields(Resource $resource): array + { + if (isset($this->fields[$resource])) { + return $this->fields[$resource]; + } + + $fields = []; + + foreach ($resource->resolveFields() as $field) { + $fields[$field->name] = $field; + } + + return $this->fields[$resource] = $fields; + } + public function withSearchResults(SearchResults $search): static { $new = clone $this; @@ -96,4 +131,41 @@ class Context extends BaseContext { return $this->endpoint instanceof Endpoint\Index && (! $resource || is_a($this->collection, $resource)); } + + public function withRequest(ServerRequestInterface $request): static + { + $new = parent::withRequest($request); + $new->requestIncludes = null; + return $new; + } + + public function withModelId(int|string|null $id): static + { + $new = clone $this; + $new->modelId = $id; + return $new; + } + + public function withRequestIncludes(array $requestIncludes): static + { + $new = clone $this; + $new->requestIncludes = $requestIncludes; + return $new; + } + + public function extractIdFromPath(\Tobyz\JsonApiServer\Context $context): ?string + { + $currentPath = trim($context->path(), '/'); + $path = trim($context->collection->name() . $this->endpoint->path, '/'); + + if (!str_contains($path, '{id}')) { + return null; + } + + $segments = explode('/', $path); + $idSegmentIndex = array_search('{id}', $segments); + $currentPathSegments = explode('/', $currentPath); + + return $currentPathSegments[$idSegmentIndex] ?? null; + } } diff --git a/framework/core/src/Api/Endpoint/Concerns/HasAuthorization.php b/framework/core/src/Api/Endpoint/Concerns/HasAuthorization.php index aa4abfa0b..e61dcc600 100644 --- a/framework/core/src/Api/Endpoint/Concerns/HasAuthorization.php +++ b/framework/core/src/Api/Endpoint/Concerns/HasAuthorization.php @@ -14,9 +14,14 @@ use Flarum\Http\RequestUtil; use Flarum\User\Exception\NotAuthenticatedException; use Flarum\User\Exception\PermissionDeniedException; use Tobyz\JsonApiServer\Context; +use Tobyz\JsonApiServer\Schema\Concerns\HasVisibility; trait HasAuthorization { + use HasVisibility { + isVisible as parentIsVisible; + } + protected bool|Closure $authenticated = false; protected null|string|Closure $ability = null; @@ -86,6 +91,6 @@ trait HasAuthorization $actor->assertCan($ability, $context->model); } - return parent::isVisible($context); + return $this->parentIsVisible($context); } } diff --git a/framework/core/src/Api/Endpoint/Concerns/HasCustomHooks.php b/framework/core/src/Api/Endpoint/Concerns/HasCustomHooks.php index e866f612a..254b98944 100644 --- a/framework/core/src/Api/Endpoint/Concerns/HasCustomHooks.php +++ b/framework/core/src/Api/Endpoint/Concerns/HasCustomHooks.php @@ -14,6 +14,10 @@ use Tobyz\JsonApiServer\Context; trait HasCustomHooks { + use HasHooks { + resolveCallable as protected resolveHookCallable; + } + protected function resolveCallable(callable|string $callable, Context $context): callable { if (is_string($callable)) { diff --git a/framework/core/src/Api/Endpoint/Concerns/HasEagerLoading.php b/framework/core/src/Api/Endpoint/Concerns/HasEagerLoading.php new file mode 100644 index 000000000..dffa64f2b --- /dev/null +++ b/framework/core/src/Api/Endpoint/Concerns/HasEagerLoading.php @@ -0,0 +1,207 @@ + + */ + protected array $loadRelations = []; + + /** + * @var array + */ + protected array $loadRelationWhere = []; + + /** + * Eager loads relationships needed for serializer logic. + * + * @param string|string[] $relations + */ + public function eagerLoad(array|string|callable $relations): static + { + if (! is_callable($relations)) { + $this->loadRelations = array_merge($this->loadRelations, array_map('strval', (array) $relations)); + } else { + $this->loadRelations[] = $relations; + } + + return $this; + } + + /** + * Eager load relations when a relation is included in the serialized response. + * + * @param array> $includedToRelations An array of included relation to relations to load 'includedRelation' => ['relation1', 'relation2'] + */ + public function eagerLoadWhenIncluded(array $includedToRelations): static + { + return $this->eagerLoad(function (array $included) use ($includedToRelations) { + $relations = []; + + foreach ($includedToRelations as $includedRelation => $includedRelations) { + if (in_array($includedRelation, $included)) { + $relations = array_merge($relations, $includedRelations); + } + } + + return $relations; + }); + } + + /** + * Allows loading a relationship with additional query modification. + * + * @param string $relation: Relationship name, see load method description. + * @param callable $callback + * + * The callback to modify the query, should accept: + * - \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Relations\Relation $query: A query object. + * - Context $context: An instance of the API context. + * - array $relations: An array of relations that are to be loaded. + */ + public function eagerLoadWhere(string $relation, callable $callback): static + { + $this->loadRelationWhere = array_merge($this->loadRelationWhere, [$relation => $callback]); + + return $this; + } + + /** + * Eager loads relationships before serialization. + */ + protected function loadRelations(Collection $models, Context $context, array $included = []): void + { + if (! $context->collection instanceof AbstractDatabaseResource) { + return; + } + + $included = $this->stringInclude($included); + $models = $models->filter(fn ($model) => $model instanceof Model); + + $relations = $this->compileSimpleEagerLoads($context, $included); + $addedRelationWhere = $this->compileWhereEagerLoads($context); + + foreach ($addedRelationWhere as $name => $callable) { + $relations[] = $name; + } + + if (! empty($relations)) { + $relations = array_unique($relations); + } + + $whereRelations = []; + $simpleRelations = []; + + foreach ($relations as $relation) { + if (isset($addedRelationWhere[$relation])) { + $whereRelations[$relation] = $addedRelationWhere[$relation];; + } else { + $simpleRelations[] = $relation; + } + } + + if (! empty($whereRelations)) { + $models->loadMissing($whereRelations); + } + + if (! empty($simpleRelations)) { + $models->loadMissing($simpleRelations); + } + } + + protected function compileSimpleEagerLoads(Context $context, array $included): array + { + $relations = []; + + foreach ($this->loadRelations as $relation) { + if (is_callable($relation)) { + $returnedRelations = $relation($included, $context); + $relations = array_merge($relations, array_map('strval', (array) $returnedRelations)); + } else { + $relations[] = $relation; + } + } + + return $relations; + } + + protected function compileWhereEagerLoads(Context $context): array + { + $relations = []; + + foreach ($this->loadRelationWhere as $name => $callable) { + $relations[$name] = function ($query) use ($callable, $context) { + $callable($query, $context); + }; + } + + return $relations; + } + + public function getEagerLoadsFor(string $included, Context $context): array + { + $subRelations = []; + + $includes = $this->stringInclude($this->getInclude($context)); + + foreach ($this->compileSimpleEagerLoads($context, $includes) as $relation) { + if (! is_callable($relation)) { + if (Str::startsWith($relation, "$included.")) { + $subRelations[] = Str::after($relation, "$included."); + } + } else { + $returnedRelations = $relation($includes, $context); + $subRelations = array_merge($subRelations, array_map('strval', (array) $returnedRelations)); + } + } + + return $subRelations; + } + + public function getWhereEagerLoadsFor(string $included, Context $context): array + { + $subRelations = []; + + foreach ($this->loadRelationWhere as $relation => $callable) { + if (Str::startsWith($relation, "$included.")) { + $subRelations[$relation] = Str::after($relation, "$included."); + } + } + + return $subRelations; + } + + /** + * From format of: 'relation' => [ ...nested ] to ['relation', 'relation.nested'] + */ + private function stringInclude(array $include): array + { + $relations = []; + + foreach ($include as $relation => $nested) { + $relations[] = $relation; + + if (is_array($nested)) { + foreach ($this->stringInclude($nested) as $nestedRelation) { + $relations[] = $relation.'.'.$nestedRelation; + } + } + } + + return $relations; + } +} diff --git a/framework/core/src/Api/Endpoint/Concerns/HasHooks.php b/framework/core/src/Api/Endpoint/Concerns/HasHooks.php new file mode 100644 index 000000000..1af527583 --- /dev/null +++ b/framework/core/src/Api/Endpoint/Concerns/HasHooks.php @@ -0,0 +1,57 @@ +before[] = $callback; + + return $this; + } + + public function after(callable|string $callback): static + { + $this->after[] = $callback; + + return $this; + } + + protected function resolveCallable(callable|string $callable, Context $context): callable + { + if (is_string($callable)) { + return new $callable(); + } + + return $callable; + } + + protected function callBeforeHook(Context $context): void + { + foreach ($this->before as $before) { + $before = $this->resolveCallable($before, $context); + $before($context); + } + } + + protected function callAfterHook(Context $context, mixed $data): mixed + { + foreach ($this->after as $after) { + $after = $this->resolveCallable($after, $context); + $data = $after($context, $data); + + if (empty($data)) { + throw new RuntimeException('The after hook must return the data back.'); + } + } + + return $data; + } +} diff --git a/framework/core/src/Api/Endpoint/Concerns/IncludesData.php b/framework/core/src/Api/Endpoint/Concerns/IncludesData.php new file mode 100644 index 000000000..be5f24779 --- /dev/null +++ b/framework/core/src/Api/Endpoint/Concerns/IncludesData.php @@ -0,0 +1,22 @@ +defaultInclude = array_merge($this->defaultInclude ?? [], $include); + + return $this; + } + + public function removeDefaultInclude(array $include): static + { + $this->defaultInclude = array_diff($this->defaultInclude ?? [], $include); + + return $this; + } +} diff --git a/framework/core/src/Api/Endpoint/Concerns/SavesAndValidatesData.php b/framework/core/src/Api/Endpoint/Concerns/SavesAndValidatesData.php new file mode 100644 index 000000000..dfe59737f --- /dev/null +++ b/framework/core/src/Api/Endpoint/Concerns/SavesAndValidatesData.php @@ -0,0 +1,163 @@ +mutateDataBeforeValidation($context, $data); + + $collection = $context->collection; + + $rules = [ + 'attributes' => [], + 'relationships' => [], + ]; + + $messages = []; + $attributes = []; + + foreach ($context->fields($context->resource) as $field) { + $writable = $field->isWritable($context->withField($field)); + + if (! $writable) { + continue; + } + + $type = $field instanceof Attribute ? 'attributes' : 'relationships'; + + $rules[$type] = array_merge($rules[$type], $field->getValidationRules($context)); + $messages = array_merge($messages, $field->getValidationMessages($context)); + $attributes = array_merge($attributes, $field->getValidationAttributes($context)); + } + + if (method_exists($collection, 'validationFactory')) { + $factory = $collection->validationFactory(); + } else { + $loader = new ArrayLoader(); + $translator = new Translator($loader, 'en'); + $factory = new Factory($translator); + } + + $attributeValidator = $factory->make($data['attributes'], $rules['attributes'], $messages, $attributes); + $relationshipValidator = $factory->make($data['relationships'], $rules['relationships'], $messages, $attributes); + + $this->validate('attributes', $attributeValidator); + $this->validate('relationships', $relationshipValidator); + } + + /** + * @throws UnprocessableEntityException if any fields do not pass validation. + */ + protected function validate(string $type, Validator $validator): void + { + if ($validator->fails()) { + $errors = []; + + foreach ($validator->errors()->messages() as $field => $messages) { + $errors[] = [ + 'source' => ['pointer' => "/data/$type/$field"], + 'detail' => implode(' ', $messages), + ]; + } + + throw new UnprocessableEntityException($errors); + } + } + + protected function mutateDataBeforeValidation(Context $context, array $data): array + { + if (method_exists($context->resource, 'mutateDataBeforeValidation')) { + return $context->resource->mutateDataBeforeValidation($context, $data); + } + + return $data; + } + + /** + * Parse and validate a JSON:API document's `data` member. + * + * @throws BadRequestException if the `data` member is invalid. + */ + final protected function parseData(Context $context): array + { + $body = (array) $context->body(); + + if (!isset($body['data']) || !is_array($body['data'])) { + throw (new BadRequestException('data must be an object'))->setSource([ + 'pointer' => '/data', + ]); + } + + if (!isset($body['data']['type'])) { + if (isset($context->collection->resources()[0])) { + $body['data']['type'] = $context->collection->resources()[0]; + } else { + throw (new BadRequestException('data.type must be present'))->setSource([ + 'pointer' => '/data/type', + ]); + } + } + + if (isset($context->model)) { + // commented out to reduce strictness. +// if (!isset($body['data']['id'])) { +// throw (new BadRequestException('data.id must be present'))->setSource([ +// 'pointer' => '/data/id', +// ]); +// } + + if (isset($body['data']['id']) && $body['data']['id'] !== $context->resource->getId($context->model, $context)) { + throw (new ConflictException('data.id does not match the resource ID'))->setSource([ + 'pointer' => '/data/id', + ]); + } + } elseif (isset($body['data']['id'])) { + throw (new ForbiddenException('Client-generated IDs are not supported'))->setSource([ + 'pointer' => '/data/id', + ]); + } + + if (!in_array($body['data']['type'], $context->collection->resources())) { + throw (new ConflictException( + 'collection does not support this resource type', + ))->setSource(['pointer' => '/data/type']); + } + + if (array_key_exists('attributes', $body['data']) && !is_array($body['data']['attributes'])) { + throw (new BadRequestException('data.attributes must be an object'))->setSource([ + 'pointer' => '/data/attributes', + ]); + } + + if (array_key_exists('relationships', $body['data']) && !is_array($body['data']['relationships'])) { + throw (new BadRequestException('data.relationships must be an object'))->setSource([ + 'pointer' => '/data/relationships', + ]); + } + + return array_merge(['attributes' => [], 'relationships' => []], $body['data']); + } +} diff --git a/framework/core/src/Api/Endpoint/Create.php b/framework/core/src/Api/Endpoint/Create.php index 443546a72..6d9ba9175 100644 --- a/framework/core/src/Api/Endpoint/Create.php +++ b/framework/core/src/Api/Endpoint/Create.php @@ -9,17 +9,84 @@ namespace Flarum\Api\Endpoint; +use Flarum\Api\Context; use Flarum\Api\Endpoint\Concerns\HasAuthorization; use Flarum\Api\Endpoint\Concerns\HasCustomHooks; -use Tobyz\JsonApiServer\Endpoint\Create as BaseCreate; +use Flarum\Api\Endpoint\Concerns\IncludesData; +use Flarum\Api\Endpoint\Concerns\SavesAndValidatesData; +use Flarum\Database\Eloquent\Collection; +use RuntimeException; +use Tobyz\JsonApiServer\Endpoint\Concerns\ShowsResources; +use Tobyz\JsonApiServer\Resource\Creatable; +use function Tobyz\JsonApiServer\has_value; +use function Tobyz\JsonApiServer\json_api_response; +use function Tobyz\JsonApiServer\set_value; -class Create extends BaseCreate implements EndpointInterface +class Create extends Endpoint { + use SavesAndValidatesData; + use ShowsResources; + use IncludesData; use HasAuthorization; use HasCustomHooks; + public static function make(?string $name = null): static + { + return parent::make($name ?? 'create'); + } + public function setUp(): void { - parent::setUp(); + $this->route('POST', '/') + ->action(function (Context $context): ?object { + if (str_contains($context->path(), '/')) { + return null; + } + + $collection = $context->collection; + + if (!$collection instanceof Creatable) { + throw new RuntimeException( + sprintf('%s must implement %s', get_class($collection), Creatable::class), + ); + } + + $this->callBeforeHook($context); + + $data = $this->parseData($context); + + $context = $context + ->withResource($resource = $context->resource($data['type'])) + ->withModel($model = $collection->newModel($context)); + + $this->assertFieldsValid($context, $data); + $this->fillDefaultValues($context, $data); + $this->deserializeValues($context, $data); + $this->assertDataValid($context, $data); + $this->setValues($context, $data); + + $context = $context->withModel($model = $resource->createAction($model, $context)); + + $this->saveFields($context, $data); + + return $this->callAfterHook($context, $model); + }) + ->beforeSerialization(function (Context $context, object $model) { + $this->loadRelations(Collection::make([$model]), $context, $this->getInclude($context)); + }) + ->response(function (Context $context, object $model) { + return json_api_response($document = $this->showResource($context, $model)) + ->withStatus(201) + ->withHeader('Location', $document['data']['links']['self']); + }); + } + + final protected function fillDefaultValues(Context $context, array &$data): void + { + foreach ($context->fields($context->resource) as $field) { + if (!has_value($data, $field) && ($default = $field->default)) { + set_value($data, $field, $default($context->withField($field))); + } + } } } diff --git a/framework/core/src/Api/Endpoint/Delete.php b/framework/core/src/Api/Endpoint/Delete.php index 30a661a4b..b31bb8c10 100644 --- a/framework/core/src/Api/Endpoint/Delete.php +++ b/framework/core/src/Api/Endpoint/Delete.php @@ -9,12 +9,56 @@ namespace Flarum\Api\Endpoint; +use Flarum\Api\Context; use Flarum\Api\Endpoint\Concerns\HasAuthorization; use Flarum\Api\Endpoint\Concerns\HasCustomHooks; -use Tobyz\JsonApiServer\Endpoint\Delete as BaseDelete; +use Nyholm\Psr7\Response; +use RuntimeException; +use Tobyz\JsonApiServer\Resource\Deletable; +use Tobyz\JsonApiServer\Schema\Concerns\HasMeta; +use function Tobyz\JsonApiServer\json_api_response; -class Delete extends BaseDelete implements EndpointInterface +class Delete extends Endpoint { + use HasMeta; use HasAuthorization; use HasCustomHooks; + + public static function make(?string $name = null): static + { + return parent::make($name ?? 'delete'); + } + + public function setUp(): void + { + $this->route('DELETE', '/{id}') + ->action(function (Context $context) { + $model = $context->model; + + $context = $context->withResource( + $resource = $context->resource($context->collection->resource($model, $context)), + ); + + if (!$resource instanceof Deletable) { + throw new RuntimeException( + sprintf('%s must implement %s', get_class($resource), Deletable::class), + ); + } + + $this->callBeforeHook($context); + + $resource->deleteAction($model, $context); + + $this->callAfterHook($context, $model); + + return null; + }) + ->response(function (Context $context) { + if ($meta = $this->serializeMeta($context)) { + return json_api_response(['meta' => $meta]); + } + + return new Response(204); + }); + } } diff --git a/framework/core/src/Api/Endpoint/Endpoint.php b/framework/core/src/Api/Endpoint/Endpoint.php index 958b4b34c..55496998d 100644 --- a/framework/core/src/Api/Endpoint/Endpoint.php +++ b/framework/core/src/Api/Endpoint/Endpoint.php @@ -9,14 +9,140 @@ namespace Flarum\Api\Endpoint; +use Closure; +use Flarum\Api\Context; use Flarum\Api\Endpoint\Concerns\ExtractsListingParams; use Flarum\Api\Endpoint\Concerns\HasAuthorization; use Flarum\Api\Endpoint\Concerns\HasCustomHooks; -use Tobyz\JsonApiServer\Endpoint\Endpoint as BaseEndpoint; +use Flarum\Api\Endpoint\Concerns\HasEagerLoading; +use Psr\Http\Message\ResponseInterface as Response; +use RuntimeException; +use Tobyz\JsonApiServer\Endpoint\Concerns\FindsResources; +use Tobyz\JsonApiServer\Endpoint\Concerns\ShowsResources; +use Tobyz\JsonApiServer\Exception\ForbiddenException; +use Tobyz\JsonApiServer\Exception\MethodNotAllowedException; +use function Tobyz\JsonApiServer\json_api_response; -class Endpoint extends BaseEndpoint implements EndpointInterface +class Endpoint implements EndpointInterface { + use ShowsResources; + use FindsResources; + use HasEagerLoading; use HasAuthorization; use HasCustomHooks; use ExtractsListingParams; + + public string $method; + public string $path; + + protected ?Closure $action = null; + protected ?Closure $response = null; + protected array $beforeSerialization = []; + + public function __construct( + public string $name + ) { + } + + public static function make(?string $name): static + { + $endpoint = new static($name); + + $endpoint->setUp(); + + return $endpoint; + } + + protected function setUp(): void + { + } + + public function name(string $name): static + { + $this->name = $name; + + return $this; + } + + public function action(Closure $action): static + { + $this->action = $action; + + return $this; + } + + public function response(Closure $response): static + { + $this->response = $response; + + return $this; + } + + public function route(string $method, string $path): static + { + $this->method = $method; + $this->path = '/' . ltrim(rtrim($path, '/'), '/'); + + return $this; + } + + public function beforeSerialization(Closure $callback): static + { + $this->beforeSerialization[] = $callback; + + return $this; + } + + public function process(Context $context): mixed + { + if (! $this->action) { + throw new RuntimeException("No action defined for endpoint [".static::class."]"); + } + + return ($this->action)($context); + } + + /** + * @param Context $context + */ + public function handle(\Tobyz\JsonApiServer\Context $context): ?Response + { + if (! isset($this->method, $this->path)) { + throw new RuntimeException("No route defined for endpoint [".static::class."]"); + } + + if (strtolower($context->method()) !== strtolower($this->method)) { + throw new MethodNotAllowedException(); + } + + $context = $context->withModelId( + $context->collection->id($context) + ); + + if ($context->modelId) { + $context = $context->withModel( + $this->findResource($context, $context->modelId) + ); + } + + if (!$this->isVisible($context)) { + throw new ForbiddenException(); + } + + $data = $this->process($context); + + foreach ($this->beforeSerialization as $callback) { + $callback($context, $data); + } + + if ($this->response) { + return ($this->response)($context, $data); + } + + if ($context->model && $data instanceof $context->model) { + return json_api_response($this->showResource($context, $data)); + } + + return null; + } } diff --git a/framework/core/src/Api/Endpoint/EndpointInterface.php b/framework/core/src/Api/Endpoint/EndpointInterface.php index 56ea81c24..4b3b8123e 100644 --- a/framework/core/src/Api/Endpoint/EndpointInterface.php +++ b/framework/core/src/Api/Endpoint/EndpointInterface.php @@ -9,10 +9,7 @@ namespace Flarum\Api\Endpoint; -/** - * @mixin \Tobyz\JsonApiServer\Endpoint\Endpoint - */ -interface EndpointInterface +interface EndpointInterface extends \Tobyz\JsonApiServer\Endpoint\Endpoint { // } diff --git a/framework/core/src/Api/Endpoint/Index.php b/framework/core/src/Api/Endpoint/Index.php index d362958cc..a73750535 100644 --- a/framework/core/src/Api/Endpoint/Index.php +++ b/framework/core/src/Api/Endpoint/Index.php @@ -9,28 +9,64 @@ namespace Flarum\Api\Endpoint; +use Closure; use Flarum\Api\Context; use Flarum\Api\Endpoint\Concerns\ExtractsListingParams; use Flarum\Api\Endpoint\Concerns\HasAuthorization; use Flarum\Api\Endpoint\Concerns\HasCustomHooks; +use Flarum\Api\Endpoint\Concerns\IncludesData; +use Flarum\Api\Resource\Contracts\Countable; +use Flarum\Api\Resource\Contracts\Listable; +use Flarum\Database\Eloquent\Collection; use Flarum\Search\SearchCriteria; use Flarum\Search\SearchManager; use Illuminate\Contracts\Database\Eloquent\Builder; -use Tobyz\JsonApiServer\Endpoint\Index as BaseIndex; +use Psr\Http\Message\ResponseInterface as Response; +use RuntimeException; +use Tobyz\JsonApiServer\Exception\BadRequestException; +use Tobyz\JsonApiServer\Exception\Sourceable; use Tobyz\JsonApiServer\Pagination\OffsetPagination; use Tobyz\JsonApiServer\Pagination\Pagination; +use Tobyz\JsonApiServer\Schema\Concerns\HasMeta; +use Tobyz\JsonApiServer\Serializer; +use function Tobyz\JsonApiServer\apply_filters; +use function Tobyz\JsonApiServer\json_api_response; +use function Tobyz\JsonApiServer\parse_sort_string; -class Index extends BaseIndex implements EndpointInterface +class Index extends Endpoint { + use HasMeta; + use IncludesData; use HasAuthorization; use ExtractsListingParams; use HasCustomHooks; - public function setUp(): void - { - parent::setUp(); + public Closure $paginationResolver; + public ?string $defaultSort = null; + protected ?Closure $query = null; - $this + public function __construct(string $name) + { + parent::__construct($name); + + $this->paginationResolver = fn() => null; + } + + public static function make(?string $name = null): static + { + return parent::make($name ?? 'index'); + } + + public function query(?Closure $query): static + { + $this->query = $query; + + return $this; + } + + protected function setUp(): void + { + $this->route('GET', '/') ->query(function ($query, ?Pagination $pagination, Context $context): Context { // This model has a searcher API, so we'll use that instead of the default. // The searcher API allows swapping the default search engine for a custom one. @@ -67,9 +103,141 @@ class Index extends BaseIndex implements EndpointInterface } return $context; + }) + ->action(function (\Tobyz\JsonApiServer\Context $context) { + if (str_contains($context->path(), '/')) { + return null; + } + + $collection = $context->collection; + + if (!$collection instanceof Listable) { + throw new RuntimeException( + sprintf('%s must implement %s', get_class($collection), Listable::class), + ); + } + + $this->callBeforeHook($context); + + $query = $collection->query($context); + + $pagination = ($this->paginationResolver)($context); + + if ($this->query) { + $context = ($this->query)($query, $pagination, $context); + + if (! $context instanceof Context) { + throw new RuntimeException('The Index endpoint query closure must return a Context instance.'); + } + } else { + $context = $context->withQuery($query); + + $this->applySorts($query, $context); + $this->applyFilters($query, $context); + + if ($pagination) { + $pagination->apply($query); + } + } + + $meta = $this->serializeMeta($context); + + if ( + $collection instanceof Countable && + !is_null($total = $collection->count($query, $context)) + ) { + $meta['page']['total'] = $total; + } + + $models = $collection->results($query, $context); + + $models = $this->callAfterHook($context, $models); + + $total ??= null; + + return compact('models', 'meta', 'pagination', 'total'); + }) + ->beforeSerialization(function (Context $context, array $results) { + $this->loadRelations(Collection::make($results['models']), $context, $this->getInclude($context)); + }) + ->response(function (Context $context, array $results): Response { + $collection = $context->collection; + + ['models' => $models, 'meta' => $meta, 'pagination' => $pagination, 'total' => $total] = $results; + + $serializer = new Serializer($context); + + $include = $this->getInclude($context); + + foreach ($models as $model) { + $serializer->addPrimary( + $context->resource($collection->resource($model, $context)), + $model, + $include, + ); + } + + [$data, $included] = $serializer->serialize(); + + $links = []; + + if ($pagination) { + $meta['page'] = array_merge($meta['page'] ?? [], $pagination->meta()); + $links = array_merge($links, $pagination->links(count($data), $total)); + } + + return json_api_response(compact('data', 'included', 'meta', 'links')); }); } + public function defaultSort(?string $defaultSort): static + { + $this->defaultSort = $defaultSort; + + return $this; + } + + final protected function applySorts($query, Context $context): void + { + if (!($sortString = $context->queryParam('sort', $this->defaultSort))) { + return; + } + + $sorts = $context->collection->resolveSorts(); + + foreach (parse_sort_string($sortString) as [$name, $direction]) { + foreach ($sorts as $field) { + if ($field->name === $name && $field->isVisible($context)) { + $field->apply($query, $direction, $context); + continue 2; + } + } + + throw (new BadRequestException("Invalid sort: $name"))->setSource([ + 'parameter' => 'sort', + ]); + } + } + + final protected function applyFilters($query, Context $context): void + { + if (!($filters = $context->queryParam('filter'))) { + return; + } + + if (!is_array($filters)) { + throw (new BadRequestException('filter must be an array'))->setSource([ + 'parameter' => 'filter', + ]); + } + + try { + apply_filters($query, $filters, $context->collection, $context); + } catch (Sourceable $e) { + throw $e->prependSource(['parameter' => 'filter']); + } + } + public function paginate(int $defaultLimit = 20, int $maxLimit = 50): static { $this->limit = $defaultLimit; diff --git a/framework/core/src/Api/Endpoint/Show.php b/framework/core/src/Api/Endpoint/Show.php index a124ee5f5..c8d655b03 100644 --- a/framework/core/src/Api/Endpoint/Show.php +++ b/framework/core/src/Api/Endpoint/Show.php @@ -9,19 +9,37 @@ namespace Flarum\Api\Endpoint; +use Flarum\Api\Context; use Flarum\Api\Endpoint\Concerns\ExtractsListingParams; use Flarum\Api\Endpoint\Concerns\HasAuthorization; use Flarum\Api\Endpoint\Concerns\HasCustomHooks; -use Tobyz\JsonApiServer\Endpoint\Show as BaseShow; +use Flarum\Api\Endpoint\Concerns\IncludesData; +use Flarum\Database\Eloquent\Collection; +use Tobyz\JsonApiServer\Endpoint\Concerns\ShowsResources; -class Show extends BaseShow implements EndpointInterface +class Show extends Endpoint { + use ShowsResources; + use IncludesData; use HasAuthorization; use ExtractsListingParams; use HasCustomHooks; + public static function make(?string $name = null): static + { + return parent::make($name ?? 'show'); + } + public function setUp(): void { - parent::setUp(); + $this->route('GET', '/{id}') + ->action(function (Context $context): ?object { + $this->callBeforeHook($context); + + return $this->callAfterHook($context, $context->model); + }) + ->beforeSerialization(function (Context $context, object $model) { + $this->loadRelations(Collection::make([$model]), $context, $this->getInclude($context)); + }); } } diff --git a/framework/core/src/Api/Endpoint/Update.php b/framework/core/src/Api/Endpoint/Update.php index f5985ba5a..18a0cc0fd 100644 --- a/framework/core/src/Api/Endpoint/Update.php +++ b/framework/core/src/Api/Endpoint/Update.php @@ -9,17 +9,62 @@ namespace Flarum\Api\Endpoint; +use Flarum\Api\Context; use Flarum\Api\Endpoint\Concerns\HasAuthorization; use Flarum\Api\Endpoint\Concerns\HasCustomHooks; -use Tobyz\JsonApiServer\Endpoint\Update as BaseUpdate; +use Flarum\Api\Endpoint\Concerns\IncludesData; +use Flarum\Api\Endpoint\Concerns\SavesAndValidatesData; +use Flarum\Database\Eloquent\Collection; +use RuntimeException; +use Tobyz\JsonApiServer\Endpoint\Concerns\ShowsResources; +use Tobyz\JsonApiServer\Resource\Updatable; -class Update extends BaseUpdate implements EndpointInterface +class Update extends Endpoint { + use SavesAndValidatesData; + use ShowsResources; + use IncludesData; use HasAuthorization; use HasCustomHooks; + public static function make(?string $name = null): static + { + return parent::make($name ?? 'update'); + } + public function setUp(): void { - parent::setUp(); + $this->route('PATCH', '/{id}') + ->action(function (Context $context): object { + $model = $context->model; + + $context = $context->withResource( + $resource = $context->resource($context->collection->resource($model, $context)), + ); + + if (!$resource instanceof Updatable) { + throw new RuntimeException( + sprintf('%s must implement %s', get_class($resource), Updatable::class), + ); + } + + $this->callBeforeHook($context); + + $data = $this->parseData($context); + + $this->assertFieldsValid($context, $data); + $this->deserializeValues($context, $data); + $this->assertDataValid($context, $data); + $this->setValues($context, $data); + + $context = $context->withModel($model = $resource->updateAction($model, $context)); + + $this->saveFields($context, $data); + + return $this->callAfterHook($context, $model); + }) + ->beforeSerialization(function (Context $context, object $model) { + $this->loadRelations(Collection::make([$model]), $context, $this->getInclude($context)); + }); } } diff --git a/framework/core/src/Api/JsonApi.php b/framework/core/src/Api/JsonApi.php index dd192dc93..82b7d67df 100644 --- a/framework/core/src/Api/JsonApi.php +++ b/framework/core/src/Api/JsonApi.php @@ -19,9 +19,11 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Tobyz\JsonApiServer\Endpoint\Endpoint; use Tobyz\JsonApiServer\Exception\BadRequestException; +use Tobyz\JsonApiServer\Exception\ResourceNotFoundException; use Tobyz\JsonApiServer\JsonApi as BaseJsonApi; use Tobyz\JsonApiServer\Resource\Collection; use Tobyz\JsonApiServer\Resource\Resource; +use Tobyz\JsonApiServer\Schema\Field\Field; class JsonApi extends BaseJsonApi { @@ -57,9 +59,9 @@ class JsonApi extends BaseJsonApi ->withEndpoint($this->findEndpoint($collection)); } - protected function findEndpoint(?Collection $collection): Endpoint&EndpointInterface + protected function findEndpoint(?Collection $collection): EndpointInterface { - /** @var Endpoint&EndpointInterface $endpoint */ + /** @var EndpointInterface $endpoint */ foreach ($collection->resolveEndpoints() as $endpoint) { if ($endpoint->name === $this->endpointName) { return $endpoint; @@ -69,6 +71,46 @@ class JsonApi extends BaseJsonApi throw new BadRequestException('Invalid endpoint specified'); } + /** + * Get a collection by name or class. + * + * @throws ResourceNotFoundException if the collection has not been defined. + */ + public function getCollection(string $type): Collection + { + if (isset($this->collections[$type])) { + return $this->collections[$type]; + } + + foreach ($this->collections as $instance) { + if ($instance instanceof $type) { + return $instance; + } + } + + throw new ResourceNotFoundException($type); + } + + /** + * Get a resource by type or class. + * + * @throws ResourceNotFoundException if the resource has not been defined. + */ + public function getResource(string $type): Resource + { + if (isset($this->resources[$type])) { + return $this->resources[$type]; + } + + foreach ($this->resources as $instance) { + if ($instance instanceof $type) { + return $instance; + } + } + + throw new ResourceNotFoundException($type); + } + public function withRequest(Request $request): self { $this->baseRequest = $request; diff --git a/framework/core/src/Api/Resource/AbstractDatabaseResource.php b/framework/core/src/Api/Resource/AbstractDatabaseResource.php index c65cf4a58..de024b1be 100644 --- a/framework/core/src/Api/Resource/AbstractDatabaseResource.php +++ b/framework/core/src/Api/Resource/AbstractDatabaseResource.php @@ -10,37 +10,48 @@ namespace Flarum\Api\Resource; use Flarum\Api\Context as FlarumContext; -use Flarum\Api\Resource\Concerns\Bootable; -use Flarum\Api\Resource\Concerns\Extendable; -use Flarum\Api\Resource\Concerns\HasSortMap; +use Flarum\Api\Schema\Contracts\RelationAggregator; use Flarum\Foundation\DispatchEventsTrait; use Flarum\User\User; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Support\Str; +use InvalidArgumentException; use RuntimeException; use Tobyz\JsonApiServer\Context; -use Tobyz\JsonApiServer\Laravel\EloquentResource as BaseResource; +use Tobyz\JsonApiServer\Pagination\OffsetPagination; +use Tobyz\JsonApiServer\Schema\Field\Attribute; +use Tobyz\JsonApiServer\Schema\Field\Field; +use Tobyz\JsonApiServer\Schema\Field\Relationship; +use Tobyz\JsonApiServer\Schema\Field\ToMany; +use Tobyz\JsonApiServer\Schema\Type\DateTime; /** * @template M of Model - * @extends BaseResource + * @extends AbstractResource */ -abstract class AbstractDatabaseResource extends BaseResource +abstract class AbstractDatabaseResource extends AbstractResource implements + Contracts\Findable, + Contracts\Listable, + Contracts\Countable, + Contracts\Paginatable, + Contracts\Creatable, + Contracts\Updatable, + Contracts\Deletable { - use Bootable; - use Extendable; - use HasSortMap; use DispatchEventsTrait { dispatchEventsFor as traitDispatchEventsFor; } abstract public function model(): string; - /** @inheritDoc */ - public function newModel(Context $context): object - { - return new ($this->model()); - } - + /** + * @param M $model + * @param FlarumContext $context + */ public function resource(object $model, Context $context): ?string { $baseModel = $this->model(); @@ -52,29 +63,302 @@ abstract class AbstractDatabaseResource extends BaseResource return null; } - public function filters(): array + /** + * @param M $model + * @param FlarumContext $context + */ + public function getId(object $model, Context $context): string { - throw new RuntimeException('Not supported in Flarum, please use a model searcher instead https://docs.flarum.org/extend/search.'); + return $model->getKey(); } + /** + * @param M $model + * @param FlarumContext $context + */ + public function getValue(object $model, Field $field, Context $context): mixed + { + if ($field instanceof Relationship) { + return $this->getRelationshipValue($model, $field, $context); + } else { + return $this->getAttributeValue($model, $field, $context); + } + } + + /** + * @param M $model + * @param FlarumContext $context + */ + protected function getAttributeValue(Model $model, Field $field, Context $context) + { + if ($field instanceof RelationAggregator && ($aggregate = $field->getRelationAggregate())) { + $relationName = $aggregate['relation']; + + if (! $model->isRelation($relationName)) { + return $model->getAttribute($this->property($field)); + } + + $relationship = collect($context->fields($this))->first(fn ($f) => $f->name === $relationName); + + if (! $relationship) { + throw new InvalidArgumentException("To use relation aggregates, the relationship field must be part of the resource. Missing field: $relationName for attribute $field->name."); + } + + EloquentBuffer::add($model, $relationName, $aggregate); + + return function () use ($model, $relationName, $relationship, $field, $context, $aggregate) { + EloquentBuffer::load($model, $relationName, $relationship, $context, $aggregate); + + return $model->getAttribute($this->property($field)); + }; + } + + return $model->getAttribute($this->property($field)); + } + + /** + * @param M $model + * @param FlarumContext $context + */ + protected function getRelationshipValue(Model $model, Relationship $field, Context $context) + { + $method = $this->method($field); + + if ($model->isRelation($method)) { + $relation = $model->$method(); + + // If this is a belongs-to relationship, and we only need to get the ID + // for linkage, then we don't have to actually load the relation because + // the ID is stored in a column directly on the model. We will mock up a + // related model with the value of the ID filled. + if ($relation instanceof BelongsTo && $context->include === null) { + if ($key = $model->getAttribute($relation->getForeignKeyName())) { + if ($relation instanceof MorphTo) { + $morphType = $model->{$relation->getMorphType()}; + $morphType = MorphTo::getMorphedModel($morphType) ?? $morphType; + $related = $relation->createModelByType($morphType); + } else { + $related = $relation->getRelated(); + } + + return $related->newInstance()->forceFill([$related->getKeyName() => $key]); + } + + return null; + } + + EloquentBuffer::add($model, $method); + + return function () use ($model, $method, $field, $context) { + EloquentBuffer::load($model, $method, $field, $context); + + $data = $model->getRelation($method); + + return $data instanceof Collection ? $data->all() : $data; + }; + } + + return $this->getAttributeValue($model, $field, $context); + } + + /** + * @param FlarumContext $context + */ + public function query(Context $context): object + { + $query = $this->newModel($context)->query(); + + $this->scope($query, $context); + + return $query; + } + + /** + * Hook to scope a query for this resource. + * + * @param Builder $query + * @param FlarumContext $context + */ + public function scope(Builder $query, Context $context): void + { + } + + /** + * @param Builder $query + * @param FlarumContext $context + */ + public function results(object $query, Context $context): iterable + { + if ($results = $context->getSearchResults()) { + return $results->getResults(); + } + + return $query->get(); + } + + /** + * @param Builder $query + */ + public function paginate(object $query, OffsetPagination $pagination): void + { + $query->take($pagination->limit)->skip($pagination->offset); + } + + /** + * @param Builder $query + * @param FlarumContext $context + */ + public function count(object $query, Context $context): ?int + { + if ($results = $context->getSearchResults()) { + return $results->getTotalResults(); + } + + return $query->toBase()->getCountForPagination(); + } + + /** + * @param FlarumContext $context + */ + public function find(string $id, Context $context): ?object + { + if ($id === null) { + return null; + } + + return $this->query($context)->find($id); + } + + /** + * @param M $model + * @param FlarumContext $context + * @throws \Exception + */ + public function setValue(object $model, Field $field, mixed $value, Context $context): void + { + if ($field instanceof Relationship) { + $method = $this->method($field); + $relation = $model->$method(); + + // If this is a belongs-to relationship, then the ID is stored on the + // model itself, so we can set it here. + if ($relation instanceof BelongsTo) { + $relation->associate($value); + } + + return; + } + + // Mind-blowingly, Laravel discards timezone information when storing + // dates in the database. Since the API can receive dates in any + // timezone, we will need to convert it to the app's configured + // timezone ourselves before storage. + if ( + $field instanceof Attribute && + $field->type instanceof DateTime && + $value instanceof \DateTimeInterface + ) { + $value = \DateTime::createFromInterface($value)->setTimezone( + new \DateTimeZone(config('app.timezone')), + ); + } + + $model->setAttribute($this->property($field), $value); + } + + /** + * @param M $model + * @param FlarumContext $context + */ + public function saveValue(object $model, Field $field, mixed $value, Context $context): void + { + if ($field instanceof ToMany) { + $method = $this->method($field); + $relation = $model->$method(); + + if ($relation instanceof BelongsToMany) { + $relation->sync(new Collection($value)); + } + } + } + + /** + * @param M $model + * @param FlarumContext $context + */ public function createAction(object $model, Context $context): object { - $model = parent::createAction($model, $context); + $model = $this->creating($model, $context) ?: $model; + + $model = $this->saving($model, $context) ?: $model; + + $model = $this->create($model, $context); + + $model = $this->saved($model, $context) ?: $model; + + $model = $this->created($model, $context) ?: $model; $this->dispatchEventsFor($model, $context->getActor()); return $model; } + /** + * @param M $model + * @param FlarumContext $context + */ + public function create(object $model, Context $context): object + { + $this->saveModel($model, $context); + + return $model; + } + + /** + * @param M $model + * @param FlarumContext $context + */ public function updateAction(object $model, Context $context): object { - $model = parent::updateAction($model, $context); + $model = $this->updating($model, $context) ?: $model; + + $model = $this->saving($model, $context) ?: $model; + + $this->update($model, $context); + + $model = $this->saved($model, $context) ?: $model; + + $model = $this->updated($model, $context) ?: $model; $this->dispatchEventsFor($model, $context->getActor()); return $model; } + /** + * @param M $model + * @param FlarumContext $context + */ + public function update(object $model, Context $context): object + { + $this->saveModel($model, $context); + + return $model; + } + + /** + * @param M $model + * @param FlarumContext $context + */ + protected function saveModel(Model $model, Context $context): void + { + $model->save(); + } + + /** + * @param M $model + * @param FlarumContext $context + */ public function deleteAction(object $model, Context $context): void { $this->deleting($model, $context); @@ -86,6 +370,42 @@ abstract class AbstractDatabaseResource extends BaseResource $this->dispatchEventsFor($model, $context->getActor()); } + /** + * @param M $model + * @param FlarumContext $context + */ + public function delete(object $model, Context $context): void + { + $model->delete(); + } + + /** + * Get the model property that a field represents. + */ + protected function property(Field $field): string + { + return $field->property ?: Str::snake($field->name); + } + + /** + * Get the model method that a field represents. + */ + protected function method(Field $field): string + { + return $field->property ?: $field->name; + } + + /** @inheritDoc */ + public function newModel(Context $context): object + { + return new ($this->model()); + } + + public function filters(): array + { + throw new RuntimeException('Not supported in Flarum, please use a model searcher instead https://docs.flarum.org/extend/search.'); + } + /** * @param M $model * @param FlarumContext $context @@ -178,28 +498,4 @@ abstract class AbstractDatabaseResource extends BaseResource { return $data; } - - /** - * @param FlarumContext $context - */ - public function results(object $query, Context $context): iterable - { - if ($results = $context->getSearchResults()) { - return $results->getResults(); - } - - return $query->get(); - } - - /** - * @param FlarumContext $context - */ - public function count(object $query, Context $context): ?int - { - if ($results = $context->getSearchResults()) { - return $results->getTotalResults(); - } - - return parent::count($query, $context); - } } diff --git a/framework/core/src/Api/Resource/AbstractResource.php b/framework/core/src/Api/Resource/AbstractResource.php index d203c4865..b37d441fc 100644 --- a/framework/core/src/Api/Resource/AbstractResource.php +++ b/framework/core/src/Api/Resource/AbstractResource.php @@ -24,4 +24,9 @@ abstract class AbstractResource extends BaseResource use Bootable; use Extendable; use HasSortMap; + + public function id(Context $context): ?string + { + return $context->extractIdFromPath($context); + } } diff --git a/framework/core/src/Api/Resource/Contracts/Attachable.php b/framework/core/src/Api/Resource/Contracts/Attachable.php new file mode 100644 index 000000000..85f72257a --- /dev/null +++ b/framework/core/src/Api/Resource/Contracts/Attachable.php @@ -0,0 +1,10 @@ +getQuery() : $relation; + + // When loading the relationship, we need to scope the query + // using the scopes defined in the related API resource – there + // may be multiple if this is a polymorphic relationship. We + // start by getting the resource types this relationship + // could possibly contain. + $resources = $context->api->resources; + + if ($type = $relationship->collections) { + $resources = array_intersect_key($resources, array_flip($type)); + } + + // Now, construct a map of model class names -> scoping + // functions. This will be provided to the MorphTo::constrain + // method in order to apply type-specific scoping. + $constrain = []; + + foreach ($resources as $resource) { + $modelClass = get_class($resource->newModel($context)); + + if ($resource instanceof AbstractDatabaseResource && !isset($constrain[$modelClass])) { + $constrain[$modelClass] = function (Builder $query) use ($resource, $context, $relationship, $relation, $aggregate) { + if (! $aggregate) { + $query + ->with($context->endpoint->getEagerLoadsFor($relationship->name, $context)) + ->with($context->endpoint->getWhereEagerLoadsFor($relationship->name, $context)); + } + + $resource->scope($query, $context); + + if ($aggregate && ! empty($aggregate['constrain'])) { + ($aggregate['constrain'])($query, $context); + } + + if (($relationship instanceof ToMany || $relationship instanceof ToOne) && $relationship->scope) { + ($relationship->scope)($query, $context); + } + }; + } + } + + if ($relation instanceof MorphTo) { + $relation->constrain($constrain); + } elseif ($constrain) { + reset($constrain)($query); + } + + return $query; + }; + + $collection = $model->newCollection($models); + + if (! $aggregate) { + $collection->load([$relationName => $loader]); + + // Set the inverse relation on the loaded relations. + $collection->each(function (Model $model) use ($relationName, $relationship) { + /** @var Model|Collection $related */ + if ($related = $model->getRelation($relationName)) { + $inverse = $relationship->inverse ?? str($model::class)->afterLast('\\')->camel()->toString(); + + $related = $related instanceof Collection ? $related : [$related]; + + foreach ($related as $rel) { + if ($rel->isRelation($inverse)) { + $rel->setRelation($inverse, $model); + } + } + } + }); + } else { + $collection->loadAggregate([$relationName => $loader], $aggregate['column'], $aggregate['function']); + } + + static::setBuffer($model, $relationName, $aggregate, []); + } +} diff --git a/framework/core/src/Api/Schema/Attribute.php b/framework/core/src/Api/Schema/Attribute.php index 4e63dcae9..91362541e 100644 --- a/framework/core/src/Api/Schema/Attribute.php +++ b/framework/core/src/Api/Schema/Attribute.php @@ -9,9 +9,10 @@ namespace Flarum\Api\Schema; +use Flarum\Api\Schema\Concerns\FlarumField; use Tobyz\JsonApiServer\Schema\Field\Attribute as BaseAttribute; class Attribute extends BaseAttribute { - // + use FlarumField; } diff --git a/framework/core/src/Api/Schema/Concerns/FlarumField.php b/framework/core/src/Api/Schema/Concerns/FlarumField.php new file mode 100644 index 000000000..af73b95f7 --- /dev/null +++ b/framework/core/src/Api/Schema/Concerns/FlarumField.php @@ -0,0 +1,37 @@ +writable = fn($model, Context $context) => $context->creating(); + + return $this; + } + + /** + * Allow this field to be written to when updating a model. + */ + public function writableOnUpdate(): static + { + $this->writable = fn($model, Context $context) => $context->updating(); + + return $this; + } + + public function nullable(bool $nullable = true): static + { + $this->nullable = $nullable; + + return $this->rule('nullable'); + } +} diff --git a/framework/core/src/Api/Schema/Concerns/FlarumRelationship.php b/framework/core/src/Api/Schema/Concerns/FlarumRelationship.php new file mode 100644 index 000000000..92c3c48d9 --- /dev/null +++ b/framework/core/src/Api/Schema/Concerns/FlarumRelationship.php @@ -0,0 +1,30 @@ +inverse = $inverse; + + return $this; + } + + /** + * Allow this relationship to be included. + */ + public function includable(bool $includable = true): static + { + $this->includable = $includable; + + return $this; + } +} diff --git a/framework/core/src/Api/Schema/Concerns/GetsRelationAggregates.php b/framework/core/src/Api/Schema/Concerns/GetsRelationAggregates.php new file mode 100644 index 000000000..853465c8e --- /dev/null +++ b/framework/core/src/Api/Schema/Concerns/GetsRelationAggregates.php @@ -0,0 +1,55 @@ +type instanceof Number) { + throw new \InvalidArgumentException('Relation aggregates can only be used with number attributes'); + } + + $this->relationAggregate = compact('relation', 'column', 'function', 'constrain'); + + return $this; + } + + public function countRelation(string $relation, ?Closure $constrain = null): static + { + return $this->relationAggregate($relation, '*', 'count', $constrain); + } + + public function sumRelation(string $relation, string $column, ?Closure $constrain = null): static + { + return $this->relationAggregate($relation, $column, 'sum', $constrain); + } + + public function avgRelation(string $relation, string $column, ?Closure $constrain = null): static + { + return $this->relationAggregate($relation, $column, 'avg', $constrain); + } + + public function minRelation(string $relation, string $column, ?Closure $constrain = null): static + { + return $this->relationAggregate($relation, $column, 'min', $constrain); + } + + public function maxRelation(string $relation, string $column, ?Closure $constrain = null): static + { + return $this->relationAggregate($relation, $column, 'max', $constrain); + } + + public function getRelationAggregate(): ?array + { + return $this->relationAggregate; + } +} diff --git a/framework/core/src/Api/Schema/Concerns/HasValidationRules.php b/framework/core/src/Api/Schema/Concerns/HasValidationRules.php new file mode 100644 index 000000000..38fef355e --- /dev/null +++ b/framework/core/src/Api/Schema/Concerns/HasValidationRules.php @@ -0,0 +1,158 @@ + + */ + protected array $rules = []; + + /** + * @var string[] + */ + protected array $validationMessages = []; + + /** + * @var string[] + */ + protected array $validationAttributes = []; + + public function rules(array|string $rules, bool|callable $condition, bool $override = true): static + { + if (is_string($rules)) { + $rules = explode('|', $rules); + } + + $rules = array_map(function ($rule) use ($condition) { + return compact('rule', 'condition'); + }, $rules); + + $this->rules = $override ? $rules : array_merge($this->rules, $rules); + + return $this; + } + + public function validationMessages(array $messages): static + { + $this->validationMessages = array_merge($this->validationMessages, $messages); + + return $this; + } + + public function validationAttributes(array $attributes): static + { + $this->validationAttributes = array_merge($this->validationAttributes, $attributes); + + return $this; + } + + public function rule(string|callable $rule, bool|callable $condition = true): static + { + $this->rules[] = compact('rule', 'condition'); + + return $this; + } + + public function getRules(): array + { + return $this->rules; + } + + public function getValidationRules(Context $context): array + { + $rules = array_map( + fn ($rule) => $this->evaluate($context, $rule['rule']), + array_filter( + $this->rules, + fn ($rule) => $this->evaluate($context, $rule['condition']) + ) + ); + + return [ + $this->name => $rules + ]; + } + + public function getValidationMessages(Context $context): array + { + return $this->validationMessages; + } + + public function getValidationAttributes(Context $context): array + { + return $this->validationAttributes; + } + + public function required(bool|callable $condition = true): static + { + return $this->rule('required', $condition); + } + + public function requiredOnCreate(): static + { + return $this->required(fn (Context $context) => $context->creating()); + } + + public function requiredOnUpdate(): static + { + return $this->required(fn (Context $context) => ! $context->updating()); + } + + public function requiredWith(array $fields, bool|callable $condition): static + { + return $this->rule('required_with:' . implode(',', $fields), $condition); + } + + public function requiredWithout(array $fields, bool|callable $condition): static + { + return $this->rule('required_without:' . implode(',', $fields), $condition); + } + + public function requiredOnCreateWith(array $fields): static + { + return $this->requiredWith($fields, fn (Context $context) => $context->creating()); + } + + public function requiredOnUpdateWith(array $fields): static + { + return $this->requiredWith($fields, fn (Context $context) => $context->updating()); + } + + public function requiredOnCreateWithout(array $fields): static + { + return $this->requiredWithout($fields, fn (Context $context) => $context->creating()); + } + + public function requiredOnUpdateWithout(array $fields): static + { + return $this->requiredWithout($fields, fn (Context $context) => $context->updating()); + } + + public function unique(string $table, string $column, bool $ignorable = false, bool|callable $condition = true): static + { + return $this->rule(function (Context $context) use ($table, $column, $ignorable) { + $rule = Rule::unique($table, $column); + + if ($ignorable && ($modelId = $context->model?->getKey())) { + $rule = $rule->ignore($modelId, $context->model->getKeyName()); + } + + return $rule; + }, $condition); + } + + protected function evaluate(Context $context, mixed $callback): mixed + { + if (is_string($callback) || ! is_callable($callback)) { + return $callback; + } + + return $callback($context, $context->model); + } +} diff --git a/framework/core/src/Api/Schema/Contracts/RelationAggregator.php b/framework/core/src/Api/Schema/Contracts/RelationAggregator.php new file mode 100644 index 000000000..a1450f09e --- /dev/null +++ b/framework/core/src/Api/Schema/Contracts/RelationAggregator.php @@ -0,0 +1,13 @@ +deserializer) { + return ($this->deserializer)($value, $context); + } + + if (!is_array($value) || !array_key_exists('data', $value)) { + throw new BadRequestException('relationship does not include data key'); + } + + if ($value['data'] === null) { + return null; + } + + if (count($this->collections) === 1) { + $value['data']['type'] ??= $this->collections[0]; + } + + try { + return $this->findResourceForIdentifier($value['data'], $context); + } catch (Sourceable $e) { + throw $e->prependSource(['pointer' => '/data']); + } + } } diff --git a/framework/core/src/Api/Sort/SortWithCount.php b/framework/core/src/Api/Sort/SortWithCount.php new file mode 100644 index 000000000..ebfce9801 --- /dev/null +++ b/framework/core/src/Api/Sort/SortWithCount.php @@ -0,0 +1,10 @@ + + * @var class-string<\Flarum\Api\Resource\AbstractResource> */ private readonly string $resourceClass ) { @@ -174,7 +174,7 @@ class ApiResource implements ExtenderInterface }); } - /** @var class-string<\Flarum\Api\Resource\AbstractResource|\Flarum\Api\Resource\AbstractDatabaseResource> $resourceClass */ + /** @var class-string<\Flarum\Api\Resource\AbstractResource> $resourceClass */ $resourceClass = $this->resourceClass; $resourceClass::mutateEndpoints( diff --git a/framework/core/tests/integration/extenders/ConditionalTest.php b/framework/core/tests/integration/extenders/ConditionalTest.php index efeadb182..666e3452f 100644 --- a/framework/core/tests/integration/extenders/ConditionalTest.php +++ b/framework/core/tests/integration/extenders/ConditionalTest.php @@ -191,8 +191,9 @@ class ConditionalTest extends TestCase ]) ); - $payload = json_decode($response->getBody()->getContents(), true); + $payload = json_decode($body = $response->getBody()->getContents(), true); + $this->assertArrayHasKey('data', $payload, $body); $this->assertArrayNotHasKey('customConditionalAttribute', $payload['data']['attributes']); } @@ -234,8 +235,9 @@ class ConditionalTest extends TestCase ]) ); - $payload = json_decode($response->getBody()->getContents(), true); + $payload = json_decode($body = $response->getBody()->getContents(), true); + $this->assertArrayHasKey('data', $payload, $body); $this->assertArrayHasKey('customConditionalAttribute', $payload['data']['attributes']); }