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:
Sami Mazouz 2024-06-21 10:46:24 +01:00 committed by GitHub
parent d73cd0ecdd
commit 3dd2382ea0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 2637 additions and 208 deletions

View File

@ -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": {

View File

@ -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']);
}),

View File

@ -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();

View File

@ -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']);
}),
]),

View File

@ -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')

View File

@ -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) {

View File

@ -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": {

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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();

View File

@ -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);
}
}

View File

@ -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)) {

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

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

View 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;
}
}

View File

@ -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)));
}
}
}
}

View File

@ -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);
});
}
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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));
});
}
}

View File

@ -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));
});
}
}

View File

@ -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

View File

@ -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
{
//
}
/**
* @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);
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 mutateDataBeforeValidation(Context $context, array $data): array
protected function getAttributeValue(Model $model, Field $field, Context $context): mixed
{
return $data;
if ($field instanceof RelationAggregator && ($aggregate = $field->getRelationAggregate())) {
$relationName = $aggregate['relation'];
if (! $model->isRelation($relationName)) {
return $model->getAttribute($this->property($field));
}
/** @var Relationship|null $relationship */
$relationship = collect($context->fields($this))->first(fn ($f) => $f->name === $relationName);
if (! $relationship) {
throw new InvalidArgumentException("To use relation aggregates, the relationship field must be part of the resource. Missing field: $relationName for attribute $field->name.");
}
EloquentBuffer::add($model, $relationName, $aggregate);
return function () use ($model, $relationName, $relationship, $field, $context, $aggregate) {
EloquentBuffer::load($model, $relationName, $relationship, $context, $aggregate);
return $model->getAttribute($this->property($field));
};
}
return $model->getAttribute($this->property($field));
}
/**
* @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 query(Context $context): object
{
$query = $this->newModel($context)->query();
$this->scope($query, $context);
return $query;
}
/**
* Hook to scope a query for this resource.
*
* @param Builder<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;
}
}

View File

@ -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);
}
}

View 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);
}
}
}

View 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
{
//
}

View 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;
}

View 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
{
//
}

View 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
{
//
}

View 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
{
//
}

View 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;
}

View 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
{
//
}

View 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
{
//
}

View File

@ -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)

View 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, []);
}
}

View File

@ -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)) {

View File

@ -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;
}

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

View File

@ -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;
}
}

View File

@ -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;
}
}

View 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);
}
}

View File

@ -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;
}

View File

@ -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
{

View File

@ -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;
}

View File

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

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

View File

@ -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
{
//
}

View File

@ -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.
*/

View File

@ -260,10 +260,8 @@ class Migrator
/**
* Get the migration repository instance.
*
* @return MigrationRepositoryInterface
*/
public function getRepository()
public function getRepository(): MigrationRepositoryInterface
{
return $this->repository;
}

View File

@ -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);
}
}
}

View File

@ -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]];
};
}

View File

@ -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,

View File

@ -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,

View File

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

View File

@ -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:
-

View File

@ -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\.$#'

View File

@ -0,0 +1,11 @@
<?php
namespace Tobyz\JsonApiServer;
/**
* @mixin \Flarum\Api\Context
*/
class Context
{
}

View File

@ -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