mirror of
https://github.com/flarum/framework.git
synced 2024-11-22 10:21:28 +08:00
chore: drop the need for a json-api-server
fork (#3986)
* chore: drop the need for a json-api-server fork * chore: custom Serializer * chore * chore: adapt * fix * phpstan
This commit is contained in:
parent
d73cd0ecdd
commit
3dd2382ea0
|
@ -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": {
|
||||
|
|
|
@ -45,13 +45,13 @@ return [
|
|||
->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']);
|
||||
}),
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -63,7 +63,7 @@ return [
|
|||
|
||||
(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 @@ return [
|
|||
}),
|
||||
|
||||
(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']);
|
||||
}),
|
||||
]),
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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 @@ 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) {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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 Flarum\Http\UrlGenerator;
|
|||
use Illuminate\Contracts\Container\Container;
|
||||
use Laminas\Stratigility\MiddlewarePipe;
|
||||
use ReflectionClass;
|
||||
use Tobyz\JsonApiServer\Endpoint\Endpoint;
|
||||
|
||||
class ApiServiceProvider extends AbstractServiceProvider
|
||||
{
|
||||
|
@ -53,7 +52,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 +188,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 +198,7 @@ class ApiServiceProvider extends AbstractServiceProvider
|
|||
* None of the injected dependencies should be directly used within
|
||||
* the `endpoints` method. Encourage using callbacks.
|
||||
*
|
||||
* @var array<Endpoint&EndpointInterface> $endpoints
|
||||
* @var array<Endpoint> $endpoints
|
||||
*/
|
||||
$endpoints = $resource->resolveEndpoints(true);
|
||||
|
||||
|
|
|
@ -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,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<string, Field>
|
||||
*/
|
||||
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 @@ 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(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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 @@ trait ExtractsListingParams
|
|||
|
||||
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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)) {
|
||||
|
|
213
framework/core/src/Api/Endpoint/Concerns/HasEagerLoading.php
Normal file
213
framework/core/src/Api/Endpoint/Concerns/HasEagerLoading.php
Normal file
|
@ -0,0 +1,213 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Endpoint\Concerns;
|
||||
|
||||
use Closure;
|
||||
use Flarum\Api\Resource\AbstractDatabaseResource;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Str;
|
||||
use Tobyz\JsonApiServer\Context;
|
||||
|
||||
/**
|
||||
* This is directed at eager loading relationships apart from the request includes.
|
||||
*/
|
||||
trait HasEagerLoading
|
||||
{
|
||||
/**
|
||||
* @var array<string|callable>
|
||||
*/
|
||||
protected array $loadRelations = [];
|
||||
|
||||
/**
|
||||
* @var array<string, callable>
|
||||
*/
|
||||
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<string, array<string>> $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;
|
||||
}
|
||||
}
|
64
framework/core/src/Api/Endpoint/Concerns/HasHooks.php
Normal file
64
framework/core/src/Api/Endpoint/Concerns/HasHooks.php
Normal file
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Endpoint\Concerns;
|
||||
|
||||
use RuntimeException;
|
||||
use Tobyz\JsonApiServer\Context;
|
||||
|
||||
trait HasHooks
|
||||
{
|
||||
protected array $before = [];
|
||||
protected array $after = [];
|
||||
|
||||
public function before(callable|string $callback): static
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
29
framework/core/src/Api/Endpoint/Concerns/IncludesData.php
Normal file
29
framework/core/src/Api/Endpoint/Concerns/IncludesData.php
Normal file
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Endpoint\Concerns;
|
||||
|
||||
trait IncludesData
|
||||
{
|
||||
use \Tobyz\JsonApiServer\Endpoint\Concerns\IncludesData;
|
||||
|
||||
public function addDefaultInclude(array $include): static
|
||||
{
|
||||
$this->defaultInclude = array_merge($this->defaultInclude ?? [], $include);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeDefaultInclude(array $include): static
|
||||
{
|
||||
$this->defaultInclude = array_diff($this->defaultInclude ?? [], $include);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,175 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Endpoint\Concerns;
|
||||
|
||||
use Flarum\Api\Schema\Concerns\HasValidationRules;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Translation\ArrayLoader;
|
||||
use Illuminate\Translation\Translator;
|
||||
use Illuminate\Validation\Factory;
|
||||
use Tobyz\JsonApiServer\Context;
|
||||
use Tobyz\JsonApiServer\Endpoint\Concerns\SavesData;
|
||||
use Tobyz\JsonApiServer\Exception\BadRequestException;
|
||||
use Tobyz\JsonApiServer\Exception\ConflictException;
|
||||
use Tobyz\JsonApiServer\Exception\ForbiddenException;
|
||||
use Tobyz\JsonApiServer\Exception\UnprocessableEntityException;
|
||||
use Tobyz\JsonApiServer\Schema\Field\Attribute;
|
||||
|
||||
trait SavesAndValidatesData
|
||||
{
|
||||
use SavesData {
|
||||
parseData as protected parentParseData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the field values within a data object pass validation.
|
||||
*
|
||||
* @param \Flarum\Api\Context $context
|
||||
* @throws UnprocessableEntityException
|
||||
*/
|
||||
protected function assertDataValid(Context $context, array $data): void
|
||||
{
|
||||
$this->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']);
|
||||
}
|
||||
}
|
46
framework/core/src/Api/Endpoint/Concerns/ShowsResources.php
Normal file
46
framework/core/src/Api/Endpoint/Concerns/ShowsResources.php
Normal file
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Endpoint\Concerns;
|
||||
|
||||
use Flarum\Api\Serializer;
|
||||
use Tobyz\JsonApiServer\Context;
|
||||
use Tobyz\JsonApiServer\Endpoint\Concerns\IncludesData;
|
||||
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
|
||||
|
||||
trait ShowsResources
|
||||
{
|
||||
use HasMeta;
|
||||
use IncludesData;
|
||||
|
||||
protected function showResource(Context $context, mixed $model): array
|
||||
{
|
||||
$serializer = new Serializer($context);
|
||||
|
||||
$serializer->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;
|
||||
}
|
||||
}
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
{
|
||||
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.
|
||||
|
@ -63,13 +101,163 @@ class Index extends BaseIndex implements EndpointInterface
|
|||
$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;
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 @@ class JsonApi extends BaseJsonApi
|
|||
->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 @@ 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;
|
||||
|
@ -112,13 +158,19 @@ class JsonApi extends BaseJsonApi
|
|||
$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
|
||||
|
|
|
@ -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<M, FlarumContext>
|
||||
* @extends AbstractResource<M>
|
||||
*/
|
||||
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,134 +58,128 @@ abstract class AbstractDatabaseResource extends BaseResource
|
|||
return null;
|
||||
}
|
||||
|
||||
public function filters(): array
|
||||
{
|
||||
throw new RuntimeException('Not supported in Flarum, please use a model searcher instead https://docs.flarum.org/extend/search.');
|
||||
}
|
||||
|
||||
public function createAction(object $model, Context $context): object
|
||||
{
|
||||
$model = parent::createAction($model, $context);
|
||||
|
||||
$this->dispatchEventsFor($model, $context->getActor());
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
public function updateAction(object $model, Context $context): object
|
||||
{
|
||||
$model = parent::updateAction($model, $context);
|
||||
|
||||
$this->dispatchEventsFor($model, $context->getActor());
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
public function deleteAction(object $model, Context $context): void
|
||||
{
|
||||
$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
|
||||
public function getId(object $model, Context $context): string
|
||||
{
|
||||
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;
|
||||
return $model->getKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param M $model
|
||||
* @param FlarumContext $context
|
||||
*/
|
||||
public function deleting(object $model, Context $context): void
|
||||
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
|
||||
*/
|
||||
public function deleted(object $model, Context $context): void
|
||||
protected function getAttributeValue(Model $model, Field $field, Context $context): mixed
|
||||
{
|
||||
//
|
||||
if ($field instanceof RelationAggregator && ($aggregate = $field->getRelationAggregate())) {
|
||||
$relationName = $aggregate['relation'];
|
||||
|
||||
if (! $model->isRelation($relationName)) {
|
||||
return $model->getAttribute($this->property($field));
|
||||
}
|
||||
|
||||
public function dispatchEventsFor(mixed $entity, User $actor = null): void
|
||||
{
|
||||
if (method_exists($entity, 'releaseEvents')) {
|
||||
$this->traitDispatchEventsFor($entity, $actor);
|
||||
/** @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));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param M $model
|
||||
* @param FlarumContext $context
|
||||
*/
|
||||
protected function getRelationshipValue(Model $model, Relationship $field, Context $context): mixed
|
||||
{
|
||||
$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()};
|
||||
$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 mutateDataBeforeValidation(Context $context, array $data): array
|
||||
public function query(Context $context): object
|
||||
{
|
||||
return $data;
|
||||
$query = $this->newModel($context)->query();
|
||||
|
||||
$this->scope($query, $context);
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to scope a query for this resource.
|
||||
*
|
||||
* @param Builder<M> $query
|
||||
* @param FlarumContext $context
|
||||
*/
|
||||
public function scope(Builder $query, Context $context): void
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<M> $query
|
||||
* @param FlarumContext $context
|
||||
*/
|
||||
public function results(object $query, Context $context): iterable
|
||||
|
@ -192,6 +192,15 @@ abstract class AbstractDatabaseResource extends BaseResource
|
|||
}
|
||||
|
||||
/**
|
||||
* @param Builder<M> $query
|
||||
*/
|
||||
public function paginate(object $query, OffsetPagination $pagination): void
|
||||
{
|
||||
$query->take($pagination->limit)->skip($pagination->offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<M> $query
|
||||
* @param FlarumContext $context
|
||||
*/
|
||||
public function count(object $query, Context $context): ?int
|
||||
|
@ -200,6 +209,142 @@ abstract class AbstractDatabaseResource extends BaseResource
|
|||
return $results->getTotalResults();
|
||||
}
|
||||
|
||||
return parent::count($query, $context);
|
||||
return $query->toBase()->getCountForPagination();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param FlarumContext $context
|
||||
*/
|
||||
public function find(string $id, Context $context): ?object
|
||||
{
|
||||
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 create(object $model, Context $context): object
|
||||
{
|
||||
$this->saveModel($model, $context);
|
||||
|
||||
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 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 FlarumContext $context
|
||||
*/
|
||||
public function mutateDataBeforeValidation(Context $context, array $data): array
|
||||
{
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,16 +12,22 @@ namespace Flarum\Api\Resource;
|
|||
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<M, Context>
|
||||
*/
|
||||
abstract class AbstractResource extends BaseResource
|
||||
{
|
||||
use Bootable;
|
||||
use Extendable;
|
||||
use HasSortMap;
|
||||
use HasHooks;
|
||||
|
||||
public function id(Context $context): ?string
|
||||
{
|
||||
return $context->extractIdFromPath($context);
|
||||
}
|
||||
}
|
||||
|
|
189
framework/core/src/Api/Resource/Concerns/HasHooks.php
Normal file
189
framework/core/src/Api/Resource/Concerns/HasHooks.php
Normal file
|
@ -0,0 +1,189 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Resource\Concerns;
|
||||
|
||||
use Flarum\Api\Context as FlarumContext;
|
||||
use Flarum\Api\Resource\Contracts\Creatable;
|
||||
use Flarum\Api\Resource\Contracts\Deletable;
|
||||
use Flarum\Api\Resource\Contracts\Updatable;
|
||||
use Flarum\Foundation\DispatchEventsTrait;
|
||||
use Flarum\User\User;
|
||||
use RuntimeException;
|
||||
use Tobyz\JsonApiServer\Context;
|
||||
|
||||
/**
|
||||
* @template M of object
|
||||
*/
|
||||
trait HasHooks
|
||||
{
|
||||
use DispatchEventsTrait {
|
||||
dispatchEventsFor as traitDispatchEventsFor;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param M $model
|
||||
* @param FlarumContext $context
|
||||
*/
|
||||
public function createAction(object $model, Context $context): object
|
||||
{
|
||||
if (! $this instanceof Creatable) {
|
||||
throw new RuntimeException(
|
||||
sprintf('%s must implement %s', get_class($this), Creatable::class),
|
||||
);
|
||||
}
|
||||
|
||||
$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 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);
|
||||
}
|
||||
}
|
||||
}
|
17
framework/core/src/Api/Resource/Contracts/Attachable.php
Normal file
17
framework/core/src/Api/Resource/Contracts/Attachable.php
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Resource\Contracts;
|
||||
|
||||
use Tobyz\JsonApiServer\Resource\Attachable as AttachableContract;
|
||||
|
||||
interface Attachable extends AttachableContract
|
||||
{
|
||||
//
|
||||
}
|
20
framework/core/src/Api/Resource/Contracts/Countable.php
Normal file
20
framework/core/src/Api/Resource/Contracts/Countable.php
Normal file
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Resource\Contracts;
|
||||
|
||||
use Tobyz\JsonApiServer\Context;
|
||||
|
||||
interface Countable extends Listable
|
||||
{
|
||||
/**
|
||||
* Count the models for the given query.
|
||||
*/
|
||||
public function count(object $query, Context $context): ?int;
|
||||
}
|
17
framework/core/src/Api/Resource/Contracts/Creatable.php
Normal file
17
framework/core/src/Api/Resource/Contracts/Creatable.php
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Resource\Contracts;
|
||||
|
||||
use Tobyz\JsonApiServer\Resource\Creatable as CreatableContract;
|
||||
|
||||
interface Creatable extends CreatableContract
|
||||
{
|
||||
//
|
||||
}
|
17
framework/core/src/Api/Resource/Contracts/Deletable.php
Normal file
17
framework/core/src/Api/Resource/Contracts/Deletable.php
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Resource\Contracts;
|
||||
|
||||
use Tobyz\JsonApiServer\Resource\Deletable as DeletableContract;
|
||||
|
||||
interface Deletable extends DeletableContract
|
||||
{
|
||||
//
|
||||
}
|
17
framework/core/src/Api/Resource/Contracts/Findable.php
Normal file
17
framework/core/src/Api/Resource/Contracts/Findable.php
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Resource\Contracts;
|
||||
|
||||
use Tobyz\JsonApiServer\Resource\Findable as FindableContract;
|
||||
|
||||
interface Findable extends FindableContract
|
||||
{
|
||||
//
|
||||
}
|
54
framework/core/src/Api/Resource/Contracts/Listable.php
Normal file
54
framework/core/src/Api/Resource/Contracts/Listable.php
Normal file
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Resource\Contracts;
|
||||
|
||||
use Tobyz\JsonApiServer\Context;
|
||||
use Tobyz\JsonApiServer\Schema\Filter;
|
||||
use Tobyz\JsonApiServer\Schema\Sort;
|
||||
|
||||
/**
|
||||
* @template M of object
|
||||
* @template C of Context
|
||||
*/
|
||||
interface Listable
|
||||
{
|
||||
/**
|
||||
* Create a query object for the current request.
|
||||
*
|
||||
* @param Context $context
|
||||
*/
|
||||
public function query(Context $context): object;
|
||||
|
||||
/**
|
||||
* Get results from the given query.
|
||||
*
|
||||
* @param Context $context
|
||||
*/
|
||||
public function results(object $query, Context $context): iterable;
|
||||
|
||||
/**
|
||||
* Filters that can be applied to the resource list.
|
||||
*
|
||||
* @return Filter[]
|
||||
*/
|
||||
public function filters(): array;
|
||||
|
||||
/**
|
||||
* Sorts that can be applied to the resource list.
|
||||
*
|
||||
* @return Sort[]
|
||||
*/
|
||||
public function sorts(): array;
|
||||
|
||||
/**
|
||||
* Resolve the sorts for this resource.
|
||||
*/
|
||||
public function resolveSorts(): array;
|
||||
}
|
17
framework/core/src/Api/Resource/Contracts/Paginatable.php
Normal file
17
framework/core/src/Api/Resource/Contracts/Paginatable.php
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Resource\Contracts;
|
||||
|
||||
use Tobyz\JsonApiServer\Resource\Paginatable as PaginatableContract;
|
||||
|
||||
interface Paginatable extends PaginatableContract
|
||||
{
|
||||
//
|
||||
}
|
17
framework/core/src/Api/Resource/Contracts/Updatable.php
Normal file
17
framework/core/src/Api/Resource/Contracts/Updatable.php
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Resource\Contracts;
|
||||
|
||||
use Tobyz\JsonApiServer\Resource\Updatable as UpdatableContract;
|
||||
|
||||
interface Updatable extends UpdatableContract
|
||||
{
|
||||
//
|
||||
}
|
|
@ -209,6 +209,7 @@ class DiscussionResource extends AbstractDatabaseResource
|
|||
return $context->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 @@ class DiscussionResource extends AbstractDatabaseResource
|
|||
$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)
|
||||
|
|
144
framework/core/src/Api/Resource/EloquentBuffer.php
Normal file
144
framework/core/src/Api/Resource/EloquentBuffer.php
Normal file
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Resource;
|
||||
|
||||
use Flarum\Api\Context;
|
||||
use Flarum\Api\Endpoint\Endpoint;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Tobyz\JsonApiServer\Laravel\Field\ToMany;
|
||||
use Tobyz\JsonApiServer\Laravel\Field\ToOne;
|
||||
use Tobyz\JsonApiServer\Schema\Field\Relationship;
|
||||
|
||||
abstract class EloquentBuffer
|
||||
{
|
||||
private static array $buffer = [];
|
||||
|
||||
public static function add(Model $model, string $relationName, ?array $aggregate = null): void
|
||||
{
|
||||
self::$buffer[get_class($model)][$relationName][$aggregate ? $aggregate['column'].$aggregate['function'] : 'normal'][] = $model;
|
||||
}
|
||||
|
||||
public static function getBuffer(Model $model, string $relationName, ?array $aggregate = null): ?array
|
||||
{
|
||||
return self::$buffer[get_class($model)][$relationName][$aggregate ? $aggregate['column'].$aggregate['function'] : 'normal'] ?? null;
|
||||
}
|
||||
|
||||
public static function setBuffer(Model $model, string $relationName, ?array $aggregate, array $buffer): void
|
||||
{
|
||||
self::$buffer[get_class($model)][$relationName][$aggregate ? $aggregate['column'].$aggregate['function'] : 'normal'] = $buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{relation: string, column: string, function: string, constrain: callable|null}|null $aggregate
|
||||
*/
|
||||
public static function load(
|
||||
Model $model,
|
||||
string $relationName,
|
||||
Relationship $relationship,
|
||||
Context $context,
|
||||
?array $aggregate = null,
|
||||
): void {
|
||||
if (! ($models = self::getBuffer($model, $relationName, $aggregate))) {
|
||||
return;
|
||||
}
|
||||
|
||||
$loader = function ($relation) use (
|
||||
$relationship,
|
||||
$context,
|
||||
$aggregate,
|
||||
) {
|
||||
$query = $relation instanceof Relation ? $relation->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, []);
|
||||
}
|
||||
}
|
|
@ -58,6 +58,7 @@ class PostResource extends AbstractDatabaseResource
|
|||
$query->whereVisibleTo($context->getActor());
|
||||
}
|
||||
|
||||
/** @inheritDoc */
|
||||
public function newModel(\Tobyz\JsonApiServer\Context $context): object
|
||||
{
|
||||
if ($context->creating(self::class)) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
44
framework/core/src/Api/Schema/Concerns/FlarumField.php
Normal file
44
framework/core/src/Api/Schema/Concerns/FlarumField.php
Normal file
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Schema\Concerns;
|
||||
|
||||
use Flarum\Api\Context;
|
||||
|
||||
trait FlarumField
|
||||
{
|
||||
use HasValidationRules;
|
||||
|
||||
/**
|
||||
* Allow this field to be written to when creating a new model.
|
||||
*/
|
||||
public function writableOnCreate(): static
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Schema\Concerns;
|
||||
|
||||
trait FlarumRelationship
|
||||
{
|
||||
use FlarumField;
|
||||
|
||||
public ?string $inverse = null;
|
||||
|
||||
/**
|
||||
* Set the inverse relationship name, used for eager loading.
|
||||
*/
|
||||
public function inverse(string $inverse): static
|
||||
{
|
||||
$this->inverse = $inverse;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow this relationship to be included.
|
||||
*/
|
||||
public function includable(bool $includable = true): static
|
||||
{
|
||||
$this->includable = $includable;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Schema\Concerns;
|
||||
|
||||
use Closure;
|
||||
use Tobyz\JsonApiServer\Schema\Type\Number;
|
||||
|
||||
trait GetsRelationAggregates
|
||||
{
|
||||
/**
|
||||
* @var array{relation: string, column: string, function: string, constrain: Closure}|null
|
||||
*/
|
||||
public ?array $relationAggregate = null;
|
||||
|
||||
public function relationAggregate(string $relation, string $column, string $function, ?Closure $constrain = null): static
|
||||
{
|
||||
if (! $this->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;
|
||||
}
|
||||
}
|
164
framework/core/src/Api/Schema/Concerns/HasValidationRules.php
Normal file
164
framework/core/src/Api/Schema/Concerns/HasValidationRules.php
Normal file
|
@ -0,0 +1,164 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Schema\Concerns;
|
||||
|
||||
use Flarum\Api\Context;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
trait HasValidationRules
|
||||
{
|
||||
/**
|
||||
* @var array<array{rule: string|callable, condition: bool|callable}>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api\Schema\Contracts;
|
||||
|
||||
use Closure;
|
||||
|
||||
interface RelationAggregator
|
||||
{
|
||||
public function relationAggregate(string $relation, string $column, string $function): static;
|
||||
|
||||
/**
|
||||
* @return array{relation: string, column: string, function: string, constrain: Closure|null}|null
|
||||
*/
|
||||
public function getRelationAggregate(): ?array;
|
||||
}
|
|
@ -9,8 +9,8 @@
|
|||
|
||||
namespace Flarum\Api\Schema;
|
||||
|
||||
use Tobyz\JsonApiServer\Schema\Concerns\GetsRelationAggregates;
|
||||
use Tobyz\JsonApiServer\Schema\Contracts\RelationAggregator;
|
||||
use Flarum\Api\Schema\Concerns\GetsRelationAggregates;
|
||||
use Flarum\Api\Schema\Contracts\RelationAggregator;
|
||||
|
||||
class Number extends Attribute implements RelationAggregator
|
||||
{
|
||||
|
|
|
@ -9,9 +9,10 @@
|
|||
|
||||
namespace Flarum\Api\Schema\Relationship;
|
||||
|
||||
use Tobyz\JsonApiServer\Schema\Field\ToMany as BaseToMany;
|
||||
use Flarum\Api\Schema\Concerns\FlarumRelationship;
|
||||
use Tobyz\JsonApiServer\Laravel\Field\ToMany as BaseToMany;
|
||||
|
||||
class ToMany extends BaseToMany
|
||||
{
|
||||
//
|
||||
use FlarumRelationship;
|
||||
}
|
||||
|
|
|
@ -9,9 +9,38 @@
|
|||
|
||||
namespace Flarum\Api\Schema\Relationship;
|
||||
|
||||
use Tobyz\JsonApiServer\Schema\Field\ToOne as BaseToOne;
|
||||
use Flarum\Api\Schema\Concerns\FlarumRelationship;
|
||||
use Tobyz\JsonApiServer\Context;
|
||||
use Tobyz\JsonApiServer\Exception\BadRequestException;
|
||||
use Tobyz\JsonApiServer\Exception\Sourceable;
|
||||
use Tobyz\JsonApiServer\Laravel\Field\ToOne as BaseToOne;
|
||||
|
||||
class ToOne extends BaseToOne
|
||||
{
|
||||
//
|
||||
use FlarumRelationship;
|
||||
|
||||
public function deserializeValue(mixed $value, Context $context): mixed
|
||||
{
|
||||
if ($this->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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
204
framework/core/src/Api/Serializer.php
Normal file
204
framework/core/src/Api/Serializer.php
Normal file
|
@ -0,0 +1,204 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Flarum.
|
||||
*
|
||||
* For detailed copyright and license information, please view the
|
||||
* LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Flarum\Api;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Collection;
|
||||
use RuntimeException;
|
||||
use Tobyz\JsonApiServer\Context;
|
||||
use Tobyz\JsonApiServer\Resource\Resource;
|
||||
use Tobyz\JsonApiServer\Schema\Field\Relationship;
|
||||
|
||||
use function Tobyz\JsonApiServer\has_value;
|
||||
use function Tobyz\JsonApiServer\set_value;
|
||||
|
||||
class Serializer extends \Tobyz\JsonApiServer\Serializer
|
||||
{
|
||||
private Context $context;
|
||||
private array $map = [];
|
||||
private array $primary = [];
|
||||
private Collection $deferred;
|
||||
|
||||
public function __construct(Context $context)
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
//
|
||||
}
|
|
@ -26,6 +26,11 @@ interface MigrationRepositoryInterface
|
|||
*/
|
||||
public function delete(string $file, ?string $extension = null): void;
|
||||
|
||||
/**
|
||||
* Create the migration repository table.
|
||||
*/
|
||||
public function createRepository(): void;
|
||||
|
||||
/**
|
||||
* Determine if the migration repository exists.
|
||||
*/
|
||||
|
|
|
@ -260,10 +260,8 @@ class Migrator
|
|||
|
||||
/**
|
||||
* Get the migration repository instance.
|
||||
*
|
||||
* @return MigrationRepositoryInterface
|
||||
*/
|
||||
public function getRepository()
|
||||
public function getRepository(): MigrationRepositoryInterface
|
||||
{
|
||||
return $this->repository;
|
||||
}
|
||||
|
|
|
@ -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 @@ class ApiResource implements ExtenderInterface
|
|||
/**
|
||||
* 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 @@ 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(
|
||||
/**
|
||||
* @var EndpointInterface[] $endpoints
|
||||
* @var Endpoint[] $endpoints
|
||||
*/
|
||||
function (array $endpoints, Resource $resource) use ($container): array {
|
||||
foreach ($this->endpoints as $newEndpointsCallback) {
|
||||
|
@ -203,8 +202,8 @@ class ApiResource implements ExtenderInterface
|
|||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,9 @@ namespace Flarum\Frontend\Compiler;
|
|||
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 @@ class LessCompiler extends RevisionCompiler
|
|||
];
|
||||
})->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 @@ class LessCompiler extends RevisionCompiler
|
|||
return null;
|
||||
}
|
||||
|
||||
return [$overrideImport['newFilePath'], $evald->PathAndUri()[1]];
|
||||
return [$overrideImport['newFilePath'], $pathAndUri[1]];
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -209,7 +209,7 @@ class CreateTest extends TestCase
|
|||
*/
|
||||
public function discussion_creation_limited_by_throttler()
|
||||
{
|
||||
$this->send(
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/discussions', [
|
||||
'authenticatedAs' => 2,
|
||||
'json' => [
|
||||
|
@ -224,6 +224,8 @@ class CreateTest extends TestCase
|
|||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode());
|
||||
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/discussions', [
|
||||
'authenticatedAs' => 2,
|
||||
|
|
|
@ -104,7 +104,7 @@ class CreateTest extends TestCase
|
|||
*/
|
||||
public function limited_by_throttler()
|
||||
{
|
||||
$this->send(
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 2,
|
||||
'json' => [
|
||||
|
@ -121,6 +121,8 @@ class CreateTest extends TestCase
|
|||
])
|
||||
);
|
||||
|
||||
$this->assertEquals(201, $response->getStatusCode(), (string) $response->getBody());
|
||||
|
||||
$response = $this->send(
|
||||
$this->request('POST', '/api/posts', [
|
||||
'authenticatedAs' => 2,
|
||||
|
|
|
@ -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']);
|
||||
}
|
||||
|
||||
|
|
|
@ -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:
|
||||
-
|
||||
|
|
|
@ -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\.$#'
|
||||
|
|
11
php-packages/phpstan/stubs/Tobyz/JsonApiServer/Context.stub
Normal file
11
php-packages/phpstan/stubs/Tobyz/JsonApiServer/Context.stub
Normal file
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace Tobyz\JsonApiServer;
|
||||
|
||||
/**
|
||||
* @mixin \Flarum\Api\Context
|
||||
*/
|
||||
class Context
|
||||
{
|
||||
|
||||
}
|
|
@ -35,6 +35,9 @@ parameters:
|
|||
- extensions/tags/extend.php
|
||||
excludePaths:
|
||||
- *.blade.php
|
||||
checkMissingIterableValueType: false
|
||||
checkGenericClassInNonGenericObjectType: false
|
||||
databaseMigrationsPath: ['framework/core/migrations']
|
||||
ignoreErrors:
|
||||
-
|
||||
identifier: missingType.iterableValue
|
||||
-
|
||||
identifier: missingType.generics
|
||||
|
|
Loading…
Reference in New Issue
Block a user