diff --git a/composer.json b/composer.json index 1f17448cb4..17431a63b0 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/extend.php b/extensions/likes/extend.php index 56c147dfe0..e84f0c5a1b 100644 --- a/extensions/likes/extend.php +++ b/extensions/likes/extend.php @@ -45,13 +45,13 @@ ->fields(PostResourceFields::class) ->endpoint( [Endpoint\Index::class, Endpoint\Show::class, Endpoint\Create::class, Endpoint\Update::class], - function (Endpoint\Index|Endpoint\Show|Endpoint\Create|Endpoint\Update $endpoint): Endpoint\EndpointInterface { + function (Endpoint\Index|Endpoint\Show|Endpoint\Create|Endpoint\Update $endpoint): Endpoint\Endpoint { return $endpoint->addDefaultInclude(['likes']); } ), (new Extend\ApiResource(Resource\DiscussionResource::class)) - ->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\EndpointInterface { + ->endpoint(Endpoint\Show::class, function (Endpoint\Show $endpoint): Endpoint\Endpoint { return $endpoint->addDefaultInclude(['posts.likes']); }), diff --git a/extensions/likes/src/Api/PostResourceFields.php b/extensions/likes/src/Api/PostResourceFields.php index 1bf5d89003..d40e9f628b 100644 --- a/extensions/likes/src/Api/PostResourceFields.php +++ b/extensions/likes/src/Api/PostResourceFields.php @@ -51,7 +51,7 @@ public function __invoke(): array 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/extend.php b/extensions/mentions/extend.php index 4ef314e484..6960bebfee 100644 --- a/extensions/mentions/extend.php +++ b/extensions/mentions/extend.php @@ -63,7 +63,7 @@ (new Extend\ApiResource(Resource\PostResource::class)) ->fields(PostResourceFields::class) - ->endpoint([Endpoint\Index::class, Endpoint\Show::class], function (Endpoint\Index|Endpoint\Show $endpoint): Endpoint\EndpointInterface { + ->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 { @@ -137,7 +137,7 @@ }), (new Extend\ApiResource(Resource\PostResource::class)) - ->endpoint([Endpoint\Index::class, Endpoint\Show::class], function (Endpoint\Index|Endpoint\Show $endpoint): Endpoint\EndpointInterface { + ->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/PostResourceFields.php b/extensions/mentions/src/Api/PostResourceFields.php index e27376ac22..64b800eaf3 100644 --- a/extensions/mentions/src/Api/PostResourceFields.php +++ b/extensions/mentions/src/Api/PostResourceFields.php @@ -25,7 +25,7 @@ public function __invoke(): array 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 ca4987d90a..437155a8d7 100644 --- a/extensions/sticky/src/Api/DiscussionResourceFields.php +++ b/extensions/sticky/src/Api/DiscussionResourceFields.php @@ -10,7 +10,6 @@ namespace Flarum\Sticky\Api; use Flarum\Api\Context; -use Flarum\Api\Endpoint\Update; use Flarum\Api\Schema; use Flarum\Discussion\Discussion; use Flarum\Sticky\Event\DiscussionWasStickied; @@ -23,7 +22,7 @@ public function __invoke(): array 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 aeba064479..99847daf72 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 4fda34143e..f6fd1eab9a 100644 --- a/framework/core/src/Api/ApiServiceProvider.php +++ b/framework/core/src/Api/ApiServiceProvider.php @@ -9,7 +9,7 @@ namespace Flarum\Api; -use Flarum\Api\Endpoint\EndpointInterface; +use Flarum\Api\Endpoint\Endpoint; use Flarum\Foundation\AbstractServiceProvider; use Flarum\Foundation\ErrorHandling\JsonApiFormatter; use Flarum\Foundation\ErrorHandling\Registry; @@ -22,7 +22,6 @@ use Illuminate\Contracts\Container\Container; use Laminas\Stratigility\MiddlewarePipe; use ReflectionClass; -use Tobyz\JsonApiServer\Endpoint\Endpoint; class ApiServiceProvider extends AbstractServiceProvider { @@ -53,7 +52,7 @@ public function register(): void $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 +188,7 @@ protected function populateRoutes(RouteCollection $routes, Container $container) * * 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 +198,7 @@ protected function populateRoutes(RouteCollection $routes, Container $container) * 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 2d48124d6d..75b45cc6e5 100644 --- a/framework/core/src/Api/Context.php +++ b/framework/core/src/Api/Context.php @@ -12,10 +12,18 @@ 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,34 @@ 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 = []; + + // @phpstan-ignore-next-line + 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 +132,47 @@ public function listing(string|null $resource = null): bool { 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(BaseContext $context): ?string + { + /** @var Endpoint\Endpoint $endpoint */ + $endpoint = $context->endpoint; + + $currentPath = trim($context->path(), '/'); + $path = trim($context->collection->name().$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/ExtractsListingParams.php b/framework/core/src/Api/Endpoint/Concerns/ExtractsListingParams.php index 1b7a44b83a..046d010461 100644 --- a/framework/core/src/Api/Endpoint/Concerns/ExtractsListingParams.php +++ b/framework/core/src/Api/Endpoint/Concerns/ExtractsListingParams.php @@ -10,9 +10,9 @@ namespace Flarum\Api\Endpoint\Concerns; use Closure; +use Flarum\Api\Resource\AbstractResource; use Flarum\Http\RequestUtil; use Tobyz\JsonApiServer\Context; -use Tobyz\JsonApiServer\Resource\AbstractResource; use Tobyz\JsonApiServer\Schema\Sort; trait ExtractsListingParams @@ -110,11 +110,13 @@ public function defaultExtracts(Context $context): array public function getAvailableSorts(Context $context): array { - if (! $context->collection instanceof AbstractResource) { + $collection = $context->collection; + + if (! $collection instanceof AbstractResource) { return []; } - $asc = collect($context->collection->resolveSorts()) + $asc = collect($collection->resolveSorts()) ->filter(fn (Sort $field) => $field->isVisible($context)) ->pluck('name') ->toArray(); diff --git a/framework/core/src/Api/Endpoint/Concerns/HasAuthorization.php b/framework/core/src/Api/Endpoint/Concerns/HasAuthorization.php index aa4abfa0b4..e61dcc6000 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\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 @@ public function isVisible(Context $context): bool $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 e866f612a6..254b989448 100644 --- a/framework/core/src/Api/Endpoint/Concerns/HasCustomHooks.php +++ b/framework/core/src/Api/Endpoint/Concerns/HasCustomHooks.php @@ -14,6 +14,10 @@ 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 0000000000..19c3bb4f82 --- /dev/null +++ b/framework/core/src/Api/Endpoint/Concerns/HasEagerLoading.php @@ -0,0 +1,213 @@ + + */ + protected array $loadRelations = []; + + /** + * @var array + */ + protected array $loadRelationWhere = []; + + /** + * Eager loads relationships needed for serializer logic. + * + * @param string|string[] $relations + */ + public function eagerLoad(array|string|Closure $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 0000000000..a31cf4d890 --- /dev/null +++ b/framework/core/src/Api/Endpoint/Concerns/HasHooks.php @@ -0,0 +1,64 @@ +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 0000000000..a2e4822353 --- /dev/null +++ b/framework/core/src/Api/Endpoint/Concerns/IncludesData.php @@ -0,0 +1,29 @@ +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 0000000000..dd8767b819 --- /dev/null +++ b/framework/core/src/Api/Endpoint/Concerns/SavesAndValidatesData.php @@ -0,0 +1,175 @@ +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 || ! in_array(HasValidationRules::class, class_uses_recursive($field))) { + continue; + } + + $type = $field instanceof Attribute ? 'attributes' : 'relationships'; + + // @phpstan-ignore-next-line + $rules[$type] = array_merge($rules[$type], $field->getValidationRules($context)); + // @phpstan-ignore-next-line + $messages = array_merge($messages, $field->getValidationMessages($context)); + // @phpstan-ignore-next-line + $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/Concerns/ShowsResources.php b/framework/core/src/Api/Endpoint/Concerns/ShowsResources.php new file mode 100644 index 0000000000..1ed9695f8b --- /dev/null +++ b/framework/core/src/Api/Endpoint/Concerns/ShowsResources.php @@ -0,0 +1,46 @@ +addPrimary( + $context->resource($context->collection->resource($model, $context)), + $model, + $this->getInclude($context), + ); + + [$primary, $included] = $serializer->serialize(); + + $document = ['data' => $primary[0]]; + + if (count($included)) { + $document['included'] = $included; + } + + if ($meta = $this->serializeMeta($context)) { + $document['meta'] = $meta; + } + + return $document; + } +} diff --git a/framework/core/src/Api/Endpoint/Create.php b/framework/core/src/Api/Endpoint/Create.php index 443546a728..cdc3061fa4 100644 --- a/framework/core/src/Api/Endpoint/Create.php +++ b/framework/core/src/Api/Endpoint/Create.php @@ -9,17 +9,89 @@ 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\Api\Endpoint\Concerns\ShowsResources; +use Flarum\Api\Resource\AbstractResource; +use Flarum\Database\Eloquent\Collection; +use RuntimeException; +use Tobyz\JsonApiServer\Resource\Creatable; -class Create extends BaseCreate implements EndpointInterface +use function Tobyz\JsonApiServer\has_value; +use function Tobyz\JsonApiServer\json_api_response; +use function Tobyz\JsonApiServer\set_value; + +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); + + /** @var AbstractResource $resource */ + $resource = $context->resource($data['type']); + + $context = $context + ->withResource($resource) + ->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 30a661a4b5..e2c0b5b15d 100644 --- a/framework/core/src/Api/Endpoint/Delete.php +++ b/framework/core/src/Api/Endpoint/Delete.php @@ -9,12 +9,59 @@ 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 Flarum\Api\Resource\AbstractResource; +use Nyholm\Psr7\Response; +use RuntimeException; +use Tobyz\JsonApiServer\Resource\Deletable; +use Tobyz\JsonApiServer\Schema\Concerns\HasMeta; -class Delete extends BaseDelete implements EndpointInterface +use function Tobyz\JsonApiServer\json_api_response; + +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; + + /** @var AbstractResource $resource */ + $resource = $context->resource($context->collection->resource($model, $context)); + + $context = $context->withResource($resource); + + 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 958b4b34c7..9250f02d61 100644 --- a/framework/core/src/Api/Endpoint/Endpoint.php +++ b/framework/core/src/Api/Endpoint/Endpoint.php @@ -9,14 +9,145 @@ 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 Flarum\Api\Endpoint\Concerns\ShowsResources; +use Flarum\Api\Resource\AbstractResource; +use Psr\Http\Message\ResponseInterface as Response; +use RuntimeException; +use Tobyz\JsonApiServer\Endpoint\Concerns\FindsResources; +use Tobyz\JsonApiServer\Exception\ForbiddenException; +use Tobyz\JsonApiServer\Exception\MethodNotAllowedException; -class Endpoint extends BaseEndpoint implements EndpointInterface +use function Tobyz\JsonApiServer\json_api_response; + +class Endpoint implements \Tobyz\JsonApiServer\Endpoint\Endpoint { + 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(); + } + + /** @var AbstractResource $collection */ + $collection = $context->collection; + + $context = $context->withModelId( + $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/Index.php b/framework/core/src/Api/Endpoint/Index.php index d362958cc3..92b31972a5 100644 --- a/framework/core/src/Api/Endpoint/Index.php +++ b/framework/core/src/Api/Endpoint/Index.php @@ -9,28 +9,66 @@ 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\AbstractResource; +use Flarum\Api\Resource\Contracts\Countable; +use Flarum\Api\Resource\Contracts\Listable; +use Flarum\Api\Serializer; +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; -class Index extends BaseIndex implements EndpointInterface +use function Tobyz\JsonApiServer\apply_filters; +use function Tobyz\JsonApiServer\json_api_response; +use function Tobyz\JsonApiServer\parse_sort_string; + +class Index extends Endpoint { + use HasMeta; + use IncludesData; use HasAuthorization; use ExtractsListingParams; use HasCustomHooks; - public function setUp(): void + public Closure $paginationResolver; + public ?string $defaultSort = null; + protected ?Closure $query = null; + + 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 { - parent::setUp(); + $this->query = $query; - $this + 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. @@ -63,13 +101,163 @@ public function setUp(): void $this->applySorts($query, $context); $this->applyFilters($query, $context); - $pagination?->apply($query); + if ($pagination && method_exists($pagination, 'apply')) { + $pagination->apply($query); + } } 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 { + /** @var Context $context */ + $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) { + // @phpstan-ignore-next-line + $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; + } + + $collection = $context->collection; + + if (! $collection instanceof AbstractResource) { + throw new RuntimeException('The collection '.$collection::class.' must extend '.AbstractResource::class); + } + + $sorts = $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', + ]); + } + + $collection = $context->collection; + + if (! $collection instanceof \Tobyz\JsonApiServer\Resource\Listable) { + throw new RuntimeException( + sprintf('%s must implement %s', $collection::class, \Tobyz\JsonApiServer\Resource\Listable::class), + ); + } + + try { + apply_filters($query, $filters, $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 a124ee5f5b..7c957ea6a1 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\Api\Endpoint\Concerns\ShowsResources; +use Flarum\Database\Eloquent\Collection; -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 f5985ba5a3..9b36795e15 100644 --- a/framework/core/src/Api/Endpoint/Update.php +++ b/framework/core/src/Api/Endpoint/Update.php @@ -9,17 +9,64 @@ 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\Api\Endpoint\Concerns\ShowsResources; +use Flarum\Api\Resource\AbstractResource; +use Flarum\Database\Eloquent\Collection; +use RuntimeException; +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; + + /** @var AbstractResource $resource */ + $resource = $context->resource($context->collection->resource($model, $context)); + + $context = $context->withResource($resource); + + 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 dd192dc930..f54e8638a2 100644 --- a/framework/core/src/Api/JsonApi.php +++ b/framework/core/src/Api/JsonApi.php @@ -9,16 +9,18 @@ namespace Flarum\Api; -use Flarum\Api\Endpoint\EndpointInterface; +use Flarum\Api\Endpoint\Endpoint; use Flarum\Api\Resource\AbstractDatabaseResource; +use Flarum\Api\Resource\AbstractResource; use Flarum\Http\RequestUtil; use Illuminate\Contracts\Container\Container; use Laminas\Diactoros\ServerRequestFactory; use Laminas\Diactoros\Uri; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; -use Tobyz\JsonApiServer\Endpoint\Endpoint; +use RuntimeException; 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; @@ -57,9 +59,13 @@ protected function makeContext(Request $request): Context ->withEndpoint($this->findEndpoint($collection)); } - protected function findEndpoint(?Collection $collection): Endpoint&EndpointInterface + protected function findEndpoint(?Collection $collection): Endpoint { - /** @var Endpoint&EndpointInterface $endpoint */ + if (! $collection instanceof AbstractResource) { + throw new RuntimeException('Resource '.$collection::class.' must extend '.AbstractResource::class); + } + + /** @var Endpoint $endpoint */ foreach ($collection->resolveEndpoints() as $endpoint) { if ($endpoint->name === $this->endpointName) { return $endpoint; @@ -69,6 +75,46 @@ protected function findEndpoint(?Collection $collection): Endpoint&EndpointInter 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; @@ -112,13 +158,19 @@ public function process(array $body, array $internal = [], array $options = []): $context = $context->withInternal($key, $value); } + $endpoint = $context->endpoint; + + if (! $endpoint instanceof Endpoint) { + throw new RuntimeException('The endpoint '.$endpoint::class.' must extend '.Endpoint::class); + } + $context = $context->withRequest( $request - ->withMethod($context->endpoint->method) - ->withUri(new Uri($context->endpoint->path)) + ->withMethod($endpoint->method) + ->withUri(new Uri($endpoint->path)) ); - return $context->endpoint->process($context); + return $endpoint->process($context); } public function validateQueryParameters(Request $request): void diff --git a/framework/core/src/Api/Resource/AbstractDatabaseResource.php b/framework/core/src/Api/Resource/AbstractDatabaseResource.php index c65cf4a583..24cb2f2127 100644 --- a/framework/core/src/Api/Resource/AbstractDatabaseResource.php +++ b/framework/core/src/Api/Resource/AbstractDatabaseResource.php @@ -10,37 +10,43 @@ 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\Foundation\DispatchEventsTrait; -use Flarum\User\User; +use Flarum\Api\Schema\Contracts\RelationAggregator; +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\BelongsToMany; +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,97 +58,229 @@ public function resource(object $model, Context $context): ?string 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(); } - public function createAction(object $model, Context $context): object + /** + * @param M $model + * @param FlarumContext $context + */ + public function getValue(object $model, Field $field, Context $context): mixed { - $model = parent::createAction($model, $context); + if ($field instanceof Relationship) { + return $this->getRelationshipValue($model, $field, $context); + } else { + return $this->getAttributeValue($model, $field, $context); + } + } - $this->dispatchEventsFor($model, $context->getActor()); + /** + * @param M $model + * @param FlarumContext $context + */ + protected function getAttributeValue(Model $model, Field $field, Context $context): mixed + { + if ($field instanceof RelationAggregator && ($aggregate = $field->getRelationAggregate())) { + $relationName = $aggregate['relation']; - return $model; + if (! $model->isRelation($relationName)) { + return $model->getAttribute($this->property($field)); + } + + /** @var Relationship|null $relationship */ + $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)); } - public function updateAction(object $model, Context $context): object + /** + * @param M $model + * @param FlarumContext $context + */ + protected function getRelationshipValue(Model $model, Relationship $field, Context $context): mixed { - $model = parent::updateAction($model, $context); + $method = $this->method($field); - $this->dispatchEventsFor($model, $context->getActor()); + if ($model->isRelation($method)) { + $relation = $model->$method(); - return $model; + // 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()}; + $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); } - public function deleteAction(object $model, Context $context): void + /** + * @param FlarumContext $context + */ + public function query(Context $context): object { - $this->deleting($model, $context); + $query = $this->newModel($context)->query(); - $this->delete($model, $context); + $this->scope($query, $context); - $this->deleted($model, $context); + return $query; + } - $this->dispatchEventsFor($model, $context->getActor()); + /** + * Hook to scope a query for this resource. + * + * @param Builder $query + * @param FlarumContext $context + */ + public function scope(Builder $query, Context $context): void + { } /** - * @param M $model + * @param Builder $query * @param FlarumContext $context - * @return M|null */ - public function creating(object $model, Context $context): ?object + public function results(object $query, Context $context): iterable { - return $model; + if ($results = $context->getSearchResults()) { + return $results->getResults(); + } + + return $query->get(); } /** - * @param M $model + * @param Builder $query + */ + public function paginate(object $query, OffsetPagination $pagination): void + { + $query->take($pagination->limit)->skip($pagination->offset); + } + + /** + * @param Builder $query * @param FlarumContext $context - * @return M|null */ - public function updating(object $model, Context $context): ?object + public function count(object $query, Context $context): ?int { - return $model; + if ($results = $context->getSearchResults()) { + return $results->getTotalResults(); + } + + return $query->toBase()->getCountForPagination(); } /** - * @param M $model * @param FlarumContext $context - * @return M|null */ - public function saving(object $model, Context $context): ?object + public function find(string $id, Context $context): ?object { - return $model; + return $this->query($context)->find($id); } /** * @param M $model * @param FlarumContext $context - * @return M|null + * @throws \Exception */ - public function saved(object $model, Context $context): ?object + public function setValue(object $model, Field $field, mixed $value, Context $context): void { - return $model; + 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 - * @return M|null */ - public function created(object $model, Context $context): ?object + public function saveValue(object $model, Field $field, mixed $value, Context $context): void { - return $model; + 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 - * @return M|null */ - public function updated(object $model, Context $context): ?object + public function create(object $model, Context $context): object { + $this->saveModel($model, $context); + return $model; } @@ -150,56 +288,63 @@ public function updated(object $model, Context $context): ?object * @param M $model * @param FlarumContext $context */ - public function deleting(object $model, Context $context): void + public function update(object $model, Context $context): object { - // + $this->saveModel($model, $context); + + return $model; } /** * @param M $model * @param FlarumContext $context */ - public function deleted(object $model, Context $context): void + protected function saveModel(Model $model, Context $context): void { - // + $model->save(); } - public function dispatchEventsFor(mixed $entity, User $actor = null): void + /** + * @param M $model + * @param FlarumContext $context + */ + public function delete(object $model, Context $context): void { - if (method_exists($entity, 'releaseEvents')) { - $this->traitDispatchEventsFor($entity, $actor); - } + $model->delete(); } /** - * @param FlarumContext $context + * Get the model property that a field represents. */ - public function mutateDataBeforeValidation(Context $context, array $data): array + protected function property(Field $field): string { - return $data; + return $field->property ?: Str::snake($field->name); } /** - * @param FlarumContext $context + * Get the model method that a field represents. */ - public function results(object $query, Context $context): iterable + protected function method(Field $field): string { - if ($results = $context->getSearchResults()) { - return $results->getResults(); - } + return $field->property ?: $field->name; + } - return $query->get(); + /** @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 FlarumContext $context */ - public function count(object $query, Context $context): ?int + public function mutateDataBeforeValidation(Context $context, array $data): array { - if ($results = $context->getSearchResults()) { - return $results->getTotalResults(); - } - - return parent::count($query, $context); + return $data; } } diff --git a/framework/core/src/Api/Resource/AbstractResource.php b/framework/core/src/Api/Resource/AbstractResource.php index d203c48652..aaf611e069 100644 --- a/framework/core/src/Api/Resource/AbstractResource.php +++ b/framework/core/src/Api/Resource/AbstractResource.php @@ -12,16 +12,22 @@ use Flarum\Api\Context; use Flarum\Api\Resource\Concerns\Bootable; use Flarum\Api\Resource\Concerns\Extendable; +use Flarum\Api\Resource\Concerns\HasHooks; use Flarum\Api\Resource\Concerns\HasSortMap; use Tobyz\JsonApiServer\Resource\AbstractResource as BaseResource; /** * @template M of object - * @extends BaseResource */ abstract class AbstractResource extends BaseResource { use Bootable; use Extendable; use HasSortMap; + use HasHooks; + + public function id(Context $context): ?string + { + return $context->extractIdFromPath($context); + } } diff --git a/framework/core/src/Api/Resource/Concerns/HasHooks.php b/framework/core/src/Api/Resource/Concerns/HasHooks.php new file mode 100644 index 0000000000..056eea1dc0 --- /dev/null +++ b/framework/core/src/Api/Resource/Concerns/HasHooks.php @@ -0,0 +1,189 @@ +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 updateAction(object $model, Context $context): object + { + if (! $this instanceof Updatable) { + throw new RuntimeException( + sprintf('%s must implement %s', get_class($this), Updatable::class), + ); + } + + $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 deleteAction(object $model, Context $context): void + { + if (! $this instanceof Deletable) { + throw new RuntimeException( + sprintf('%s must implement %s', get_class($this), Deletable::class), + ); + } + + $this->deleting($model, $context); + + $this->delete($model, $context); + + $this->deleted($model, $context); + + $this->dispatchEventsFor($model, $context->getActor()); + } + + /** + * @param M $model + * @param FlarumContext $context + * @return M|null + */ + public function creating(object $model, Context $context): ?object + { + return $model; + } + + /** + * @param M $model + * @param FlarumContext $context + * @return M|null + */ + public function updating(object $model, Context $context): ?object + { + return $model; + } + + /** + * @param M $model + * @param FlarumContext $context + * @return M|null + */ + public function saving(object $model, Context $context): ?object + { + return $model; + } + + /** + * @param M $model + * @param FlarumContext $context + * @return M|null + */ + public function saved(object $model, Context $context): ?object + { + return $model; + } + + /** + * @param M $model + * @param FlarumContext $context + * @return M|null + */ + public function created(object $model, Context $context): ?object + { + return $model; + } + + /** + * @param M $model + * @param FlarumContext $context + * @return M|null + */ + public function updated(object $model, Context $context): ?object + { + return $model; + } + + /** + * @param M $model + * @param FlarumContext $context + */ + public function deleting(object $model, Context $context): void + { + // + } + + /** + * @param M $model + * @param FlarumContext $context + */ + public function deleted(object $model, Context $context): void + { + // + } + + public function dispatchEventsFor(mixed $entity, User $actor = null): void + { + if (method_exists($entity, 'releaseEvents')) { + $this->traitDispatchEventsFor($entity, $actor); + } + } +} 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 0000000000..e2efb8f40d --- /dev/null +++ b/framework/core/src/Api/Resource/Contracts/Attachable.php @@ -0,0 +1,17 @@ +showing(self::class); }) ->includable() + // @todo: remove this, and send a second request from the frontend to /posts instead. Revert Serializer::addIncluded while you're at it. ->get(function (Discussion $discussion, Context $context) { $showingDiscussion = $context->showing(self::class); @@ -230,10 +231,13 @@ public function fields(): array $offset = $endpoint->extractOffsetValue($context, $endpoint->defaultExtracts($context)); } + /** @var Endpoint\Endpoint $endpoint */ + $endpoint = $context->endpoint; + $posts = $discussion->posts() ->whereVisibleTo($actor) - ->with($context->endpoint->getEagerLoadsFor('posts', $context)) - ->with($context->endpoint->getWhereEagerLoadsFor('posts', $context)) + ->with($endpoint->getEagerLoadsFor('posts', $context)) + ->with($endpoint->getWhereEagerLoadsFor('posts', $context)) ->orderBy('number') ->skip($offset) ->take($limit) diff --git a/framework/core/src/Api/Resource/EloquentBuffer.php b/framework/core/src/Api/Resource/EloquentBuffer.php new file mode 100644 index 0000000000..09557acccf --- /dev/null +++ b/framework/core/src/Api/Resource/EloquentBuffer.php @@ -0,0 +1,144 @@ +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. + /** @var AbstractDatabaseResource[] $resources */ + $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, $aggregate) { + if (! $aggregate) { + /** @var Endpoint $endpoint */ + $endpoint = $context->endpoint; + + $query + ->with($endpoint->getEagerLoadsFor($relationship->name, $context)) + ->with($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|null $related */ + $related = $model->getRelation($relationName); + + if ($related) { + $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']); + } + + self::setBuffer($model, $relationName, $aggregate, []); + } +} diff --git a/framework/core/src/Api/Resource/PostResource.php b/framework/core/src/Api/Resource/PostResource.php index 6fc513450b..c02cbd8db9 100644 --- a/framework/core/src/Api/Resource/PostResource.php +++ b/framework/core/src/Api/Resource/PostResource.php @@ -58,6 +58,7 @@ public function scope(Builder $query, \Tobyz\JsonApiServer\Context $context): vo $query->whereVisibleTo($context->getActor()); } + /** @inheritDoc */ public function newModel(\Tobyz\JsonApiServer\Context $context): object { if ($context->creating(self::class)) { diff --git a/framework/core/src/Api/Schema/Attribute.php b/framework/core/src/Api/Schema/Attribute.php index 4e63dcae91..91362541ec 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 0000000000..06f9656c6c --- /dev/null +++ b/framework/core/src/Api/Schema/Concerns/FlarumField.php @@ -0,0 +1,44 @@ +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 0000000000..1962773f93 --- /dev/null +++ b/framework/core/src/Api/Schema/Concerns/FlarumRelationship.php @@ -0,0 +1,37 @@ +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 0000000000..4efcbfef40 --- /dev/null +++ b/framework/core/src/Api/Schema/Concerns/GetsRelationAggregates.php @@ -0,0 +1,62 @@ +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 0000000000..25fc0a4431 --- /dev/null +++ b/framework/core/src/Api/Schema/Concerns/HasValidationRules.php @@ -0,0 +1,164 @@ + + */ + 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 0000000000..8077e5539c --- /dev/null +++ b/framework/core/src/Api/Schema/Contracts/RelationAggregator.php @@ -0,0 +1,22 @@ +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/Serializer.php b/framework/core/src/Api/Serializer.php new file mode 100644 index 0000000000..96308d9ae4 --- /dev/null +++ b/framework/core/src/Api/Serializer.php @@ -0,0 +1,204 @@ +context = $context->withSerializer($this); + $this->deferred = new Collection(); + + parent::__construct($context); + } + + /** + * Add a primary resource to the document. + */ + public function addPrimary(Resource $resource, mixed $model, array $include): void + { + $data = $this->addToMap($resource, $model, $include); + + $this->primary[] = $this->key($data['type'], $data['id']); + } + + /** + * Serialize the primary and included resources into a JSON:API resource objects. + * + * @return array{array[], array[]} A tuple with primary resources and included resources. + */ + public function serialize(): array + { + $this->resolveDeferred(); + + $keys = array_flip($this->primary); + $primary = array_values(array_intersect_key($this->map, $keys)); + $included = array_values(array_diff_key($this->map, $keys)); + + return [$primary, $included]; + } + + private function addToMap(Resource $resource, mixed $model, array $include): array + { + $context = $this->context->withResource($resource)->withModel($model); + + $key = $this->key($type = $resource->type(), $id = $resource->getId($model, $context)); + + $url = "{$context->api->basePath}/$type/$id"; + + if (! isset($this->map[$key])) { + $this->map[$key] = [ + 'type' => $type, + 'id' => $id, + 'links' => [ + 'self' => $url, + ], + ]; + } + + foreach ($this->context->sparseFields($resource) as $field) { + if (has_value($this->map[$key], $field)) { + continue; + } + + $context = $context->withField($field)->withInclude($include[$field->name] ?? null); + + if (! $field->isVisible($context)) { + continue; + } + + $value = $field->getValue($context); + + $this->whenResolved($value, function (mixed $value) use ($key, $field, $context) { + if ( + ($value = $field->serializeValue($value, $context)) || + ! $field instanceof Relationship + ) { + set_value($this->map[$key], $field, $value); + } + }, $field instanceof Relationship); + } + + // TODO: cache + foreach ($resource->meta() as $field) { + if (! $field->isVisible($context)) { + continue; + } + + $value = $field->getValue($context); + + $this->whenResolved($value, function (mixed $value) use ($key, $field, $context) { + $this->map[$key]['meta'][$field->name] = $field->serializeValue($value, $context); + }); + } + + return $this->map[$key]; + } + + private function key(string $type, string $id): string + { + return "$type:$id"; + } + + private function whenResolved($value, $callback, bool $prepend = false): void + { + if ($value instanceof Closure) { + $callable = fn () => $this->whenResolved($value(), $callback); + + if ($prepend) { + $this->deferred->prepend($callable); + } else { + $this->deferred->push($callable); + } + + return; + } + + $callback($value); + } + + /** + * Add an included resource to the document. + * + * @return array The resource identifier which can be used for linkage. + */ + public function addIncluded(Relationship $field, $model, ?array $include): array + { + if (is_object($model)) { + $relatedResource = $this->resourceForModel($field, $model); + + if ($include === null) { + return [ + 'type' => $relatedResource->type(), + 'id' => $relatedResource->getId($model, $this->context), + ]; + } + + $data = $this->addToMap($relatedResource, $model, $include); + } else { + $data = [ + 'type' => $field->collections[0], + 'id' => (string) $model, + ]; + } + + return [ + 'type' => $data['type'], + 'id' => $data['id'], + ]; + } + + private function resourceForModel(Relationship $field, $model): Resource + { + foreach ($field->collections as $name) { + $collection = $this->context->api->getCollection($name); + + if ($type = $collection->resource($model, $this->context)) { + return $this->context->api->getResource($type); + } + } + + throw new RuntimeException( + 'No resource type defined to represent model '.get_class($model), + ); + } + + private function resolveDeferred(): void + { + $i = 0; + while ($this->deferred->count()) { + $deferred = $this->deferred; + + /** @var Closure $resolve */ + while (($resolve = $deferred->shift()) && is_callable($resolve)) { + $resolve(); + } + + if ($i++ > 10) { + throw new RuntimeException('Too many levels of deferred values'); + } + } + } +} diff --git a/framework/core/src/Api/Endpoint/EndpointInterface.php b/framework/core/src/Api/Sort/SortWithCount.php similarity index 56% rename from framework/core/src/Api/Endpoint/EndpointInterface.php rename to framework/core/src/Api/Sort/SortWithCount.php index 56ea81c24a..71874eff88 100644 --- a/framework/core/src/Api/Endpoint/EndpointInterface.php +++ b/framework/core/src/Api/Sort/SortWithCount.php @@ -7,12 +7,11 @@ * LICENSE file that was distributed with this source code. */ -namespace Flarum\Api\Endpoint; +namespace Flarum\Api\Sort; -/** - * @mixin \Tobyz\JsonApiServer\Endpoint\Endpoint - */ -interface EndpointInterface +use Tobyz\JsonApiServer\Laravel\Sort\SortWithCount as BaseSortWithCount; + +class SortWithCount extends BaseSortWithCount { // } diff --git a/framework/core/src/Database/MigrationRepositoryInterface.php b/framework/core/src/Database/MigrationRepositoryInterface.php index e4820a4566..2145165bc1 100644 --- a/framework/core/src/Database/MigrationRepositoryInterface.php +++ b/framework/core/src/Database/MigrationRepositoryInterface.php @@ -26,6 +26,11 @@ public function log(string $file, ?string $extension = null): void; */ public function delete(string $file, ?string $extension = null): void; + /** + * Create the migration repository table. + */ + public function createRepository(): void; + /** * Determine if the migration repository exists. */ diff --git a/framework/core/src/Database/Migrator.php b/framework/core/src/Database/Migrator.php index a132a80370..37be1e5258 100644 --- a/framework/core/src/Database/Migrator.php +++ b/framework/core/src/Database/Migrator.php @@ -260,10 +260,8 @@ protected function note(string $message): void /** * Get the migration repository instance. - * - * @return MigrationRepositoryInterface */ - public function getRepository() + public function getRepository(): MigrationRepositoryInterface { return $this->repository; } diff --git a/framework/core/src/Extend/ApiResource.php b/framework/core/src/Extend/ApiResource.php index ec079e0ae4..66e6e9f2ec 100644 --- a/framework/core/src/Extend/ApiResource.php +++ b/framework/core/src/Extend/ApiResource.php @@ -9,13 +9,12 @@ namespace Flarum\Extend; -use Flarum\Api\Endpoint\EndpointInterface; +use Flarum\Api\Endpoint\Endpoint; use Flarum\Extension\Extension; use Flarum\Foundation\ContainerUtil; use Illuminate\Contracts\Container\Container; use ReflectionClass; use RuntimeException; -use Tobyz\JsonApiServer\Endpoint\Endpoint; use Tobyz\JsonApiServer\Resource\Resource; use Tobyz\JsonApiServer\Schema\Field\Field; use Tobyz\JsonApiServer\Schema\Sort; @@ -36,7 +35,7 @@ public function __construct( /** * Must be a class-string of a class that extends \Flarum\Api\Resource\AbstractResource or \Flarum\Api\Resource\AbstractDatabaseResource. * - * @var class-string<\Flarum\Api\Resource\AbstractResource|\Flarum\Api\Resource\AbstractDatabaseResource> + * @var class-string<\Flarum\Api\Resource\AbstractResource> */ private readonly string $resourceClass ) { @@ -174,12 +173,12 @@ public function extend(Container $container, Extension $extension = null): void }); } - /** @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( /** - * @var EndpointInterface[] $endpoints + * @var Endpoint[] $endpoints */ function (array $endpoints, Resource $resource) use ($container): array { foreach ($this->endpoints as $newEndpointsCallback) { @@ -203,8 +202,8 @@ function (array $endpoints, Resource $resource) use ($container): array { $mutateEndpoint = ContainerUtil::wrapCallback($mutator, $container); $endpoint = $mutateEndpoint($endpoint, $resource); - if (! $endpoint instanceof EndpointInterface) { - throw new RuntimeException('The endpoint mutator must return an instance of '.EndpointInterface::class); + if (! $endpoint instanceof Endpoint) { + throw new RuntimeException('The endpoint mutator must return an instance of '.Endpoint::class); } } } diff --git a/framework/core/src/Frontend/Compiler/LessCompiler.php b/framework/core/src/Frontend/Compiler/LessCompiler.php index 381fc2b5e8..10e8750c57 100644 --- a/framework/core/src/Frontend/Compiler/LessCompiler.php +++ b/framework/core/src/Frontend/Compiler/LessCompiler.php @@ -12,7 +12,9 @@ use Flarum\Frontend\Compiler\Source\FileSource; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use Less_FileManager; use Less_Parser; +use Less_Tree_Import; /** * @internal @@ -129,8 +131,10 @@ protected function overrideImports(array $sources): callable ]; })->unique('path'); - return function ($evald) use ($baseSources): ?array { - $relativeImportPath = Str::of($evald->PathAndUri()[0])->split('/\/less\//'); + return function (Less_Tree_Import $evald) use ($baseSources): ?array { + $pathAndUri = Less_FileManager::getFilePath($evald->getPath(), $evald->currentFileInfo); + + $relativeImportPath = Str::of($pathAndUri[0])->split('/\/less\//'); $extensionId = $baseSources->where('path', $relativeImportPath->first())->pluck('extensionId')->first(); $overrideImport = $this->lessImportOverrides @@ -141,7 +145,7 @@ protected function overrideImports(array $sources): callable return null; } - return [$overrideImport['newFilePath'], $evald->PathAndUri()[1]]; + return [$overrideImport['newFilePath'], $pathAndUri[1]]; }; } diff --git a/framework/core/tests/integration/api/discussions/CreateTest.php b/framework/core/tests/integration/api/discussions/CreateTest.php index e9d8c5f140..06cbc4e64e 100644 --- a/framework/core/tests/integration/api/discussions/CreateTest.php +++ b/framework/core/tests/integration/api/discussions/CreateTest.php @@ -209,7 +209,7 @@ public function can_create_discussion_with_forum_locale_transliteration() */ public function discussion_creation_limited_by_throttler() { - $this->send( + $response = $this->send( $this->request('POST', '/api/discussions', [ 'authenticatedAs' => 2, 'json' => [ @@ -224,6 +224,8 @@ public function discussion_creation_limited_by_throttler() ]) ); + $this->assertEquals(201, $response->getStatusCode()); + $response = $this->send( $this->request('POST', '/api/discussions', [ 'authenticatedAs' => 2, diff --git a/framework/core/tests/integration/api/posts/CreateTest.php b/framework/core/tests/integration/api/posts/CreateTest.php index d5db5b9953..75079074ac 100644 --- a/framework/core/tests/integration/api/posts/CreateTest.php +++ b/framework/core/tests/integration/api/posts/CreateTest.php @@ -104,7 +104,7 @@ public function discussionRepliesPrvider(): array */ public function limited_by_throttler() { - $this->send( + $response = $this->send( $this->request('POST', '/api/posts', [ 'authenticatedAs' => 2, 'json' => [ @@ -121,6 +121,8 @@ public function limited_by_throttler() ]) ); + $this->assertEquals(201, $response->getStatusCode(), (string) $response->getBody()); + $response = $this->send( $this->request('POST', '/api/posts', [ 'authenticatedAs' => 2, diff --git a/framework/core/tests/integration/extenders/ConditionalTest.php b/framework/core/tests/integration/extenders/ConditionalTest.php index efeadb182e..666e3452f4 100644 --- a/framework/core/tests/integration/extenders/ConditionalTest.php +++ b/framework/core/tests/integration/extenders/ConditionalTest.php @@ -191,8 +191,9 @@ public function conditional_disabled_extension_enabled_does_not_apply_invokable_ ]) ); - $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 @@ public function conditional_enabled_extension_enabled_applies_invokable_class() ]) ); - $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']); } diff --git a/php-packages/phpstan/extension.neon b/php-packages/phpstan/extension.neon index 92b95be289..f9ee1d08e0 100644 --- a/php-packages/phpstan/extension.neon +++ b/php-packages/phpstan/extension.neon @@ -18,6 +18,7 @@ parameters: # We know for a fact the JsonApi object used internally is always the Flarum one. - stubs/Tobyz/JsonApiServer/JsonApi.stub + - stubs/Tobyz/JsonApiServer/Context.stub services: - diff --git a/php-packages/phpstan/phpstan-baseline.neon b/php-packages/phpstan/phpstan-baseline.neon index 19c3148b35..3f70ccbbb5 100644 --- a/php-packages/phpstan/phpstan-baseline.neon +++ b/php-packages/phpstan/phpstan-baseline.neon @@ -40,3 +40,7 @@ parameters: # This assumes that the phpdoc telling it it's not nullable is correct, that's not the case for internal Laravel typings. - message: '#^Property [A-z0-9-_:$,\\]+ \([A-z]+\) on left side of \?\? is not nullable\.$#' + + # Ignore overriden classes from packages so that it's always easier to keep track of what's being overriden. + - message: '#^Method Flarum\\Api\\Serializer\:\:[A-z0-9_]+\(\) has parameter \$[A-z0-9_]+ with no type specified\.$#' + - message: '#^Method Flarum\\Api\\Endpoint\\[A-z0-9_]+\:\:[A-z0-9_]+\(\) has parameter \$[A-z0-9_]+ with no type specified\.$#' diff --git a/php-packages/phpstan/stubs/Tobyz/JsonApiServer/Context.stub b/php-packages/phpstan/stubs/Tobyz/JsonApiServer/Context.stub new file mode 100644 index 0000000000..a50fab5970 --- /dev/null +++ b/php-packages/phpstan/stubs/Tobyz/JsonApiServer/Context.stub @@ -0,0 +1,11 @@ +