1
0
mirror of https://github.com/flarum/core.git synced 2025-08-07 08:56:38 +02:00

chore: drop the need for a json-api-server fork

This commit is contained in:
Sami Mazouz
2024-05-10 14:13:14 +01:00
parent a8777c6198
commit 51e2ab8502
44 changed files with 2015 additions and 88 deletions

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

@@ -53,7 +53,7 @@ class ApiServiceProvider extends AbstractServiceProvider
$api->container($container);
foreach ($resources as $resourceClass) {
/** @var \Flarum\Api\Resource\AbstractResource|\Flarum\Api\Resource\AbstractDatabaseResource $resource */
/** @var \Flarum\Api\Resource\AbstractResource $resource */
$resource = $container->make($resourceClass);
$api->resource($resource->boot($api));
}
@@ -189,7 +189,7 @@ class ApiServiceProvider extends AbstractServiceProvider
*
* We avoid dependency injection here to avoid early resolution.
*
* @var \Flarum\Api\Resource\AbstractResource|\Flarum\Api\Resource\AbstractDatabaseResource $resource
* @var \Flarum\Api\Resource\AbstractResource $resource
*/
$resource = (new ReflectionClass($resourceClass))->newInstanceWithoutConstructor();
@@ -199,7 +199,7 @@ class ApiServiceProvider extends AbstractServiceProvider
* None of the injected dependencies should be directly used within
* the `endpoints` method. Encourage using callbacks.
*
* @var array<Endpoint&EndpointInterface> $endpoints
* @var array<EndpointInterface> $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,33 @@ class Context extends BaseContext
*/
protected array $parameters = [];
public function __construct(\Tobyz\JsonApiServer\JsonApi $api, ServerRequestInterface $request)
{
$this->fields = new WeakMap();
parent::__construct($api, $request);
}
/**
* Get the fields for the given resource, keyed by name.
*
* @return array<string, Field>
*/
public function fields(Resource $resource): array
{
if (isset($this->fields[$resource])) {
return $this->fields[$resource];
}
$fields = [];
foreach ($resource->resolveFields() as $field) {
$fields[$field->name] = $field;
}
return $this->fields[$resource] = $fields;
}
public function withSearchResults(SearchResults $search): static
{
$new = clone $this;
@@ -96,4 +131,41 @@ class Context extends BaseContext
{
return $this->endpoint instanceof Endpoint\Index && (! $resource || is_a($this->collection, $resource));
}
public function withRequest(ServerRequestInterface $request): static
{
$new = parent::withRequest($request);
$new->requestIncludes = null;
return $new;
}
public function withModelId(int|string|null $id): static
{
$new = clone $this;
$new->modelId = $id;
return $new;
}
public function withRequestIncludes(array $requestIncludes): static
{
$new = clone $this;
$new->requestIncludes = $requestIncludes;
return $new;
}
public function extractIdFromPath(\Tobyz\JsonApiServer\Context $context): ?string
{
$currentPath = trim($context->path(), '/');
$path = trim($context->collection->name() . $this->endpoint->path, '/');
if (!str_contains($path, '{id}')) {
return null;
}
$segments = explode('/', $path);
$idSegmentIndex = array_search('{id}', $segments);
$currentPathSegments = explode('/', $currentPath);
return $currentPathSegments[$idSegmentIndex] ?? null;
}
}

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,207 @@
<?php
namespace Flarum\Api\Endpoint\Concerns;
use Flarum\Api\Resource\AbstractDatabaseResource;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Str;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Laravel\EloquentResource;
/**
* 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|callable $relations): static
{
if (! is_callable($relations)) {
$this->loadRelations = array_merge($this->loadRelations, array_map('strval', (array) $relations));
} else {
$this->loadRelations[] = $relations;
}
return $this;
}
/**
* Eager load relations when a relation is included in the serialized response.
*
* @param array<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,57 @@
<?php
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,22 @@
<?php
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,163 @@
<?php
namespace Flarum\Api\Endpoint\Concerns;
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.
*
* @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) {
continue;
}
$type = $field instanceof Attribute ? 'attributes' : 'relationships';
$rules[$type] = array_merge($rules[$type], $field->getValidationRules($context));
$messages = array_merge($messages, $field->getValidationMessages($context));
$attributes = array_merge($attributes, $field->getValidationAttributes($context));
}
if (method_exists($collection, 'validationFactory')) {
$factory = $collection->validationFactory();
} else {
$loader = new ArrayLoader();
$translator = new Translator($loader, 'en');
$factory = new Factory($translator);
}
$attributeValidator = $factory->make($data['attributes'], $rules['attributes'], $messages, $attributes);
$relationshipValidator = $factory->make($data['relationships'], $rules['relationships'], $messages, $attributes);
$this->validate('attributes', $attributeValidator);
$this->validate('relationships', $relationshipValidator);
}
/**
* @throws UnprocessableEntityException if any fields do not pass validation.
*/
protected function validate(string $type, Validator $validator): void
{
if ($validator->fails()) {
$errors = [];
foreach ($validator->errors()->messages() as $field => $messages) {
$errors[] = [
'source' => ['pointer' => "/data/$type/$field"],
'detail' => implode(' ', $messages),
];
}
throw new UnprocessableEntityException($errors);
}
}
protected function mutateDataBeforeValidation(Context $context, array $data): array
{
if (method_exists($context->resource, 'mutateDataBeforeValidation')) {
return $context->resource->mutateDataBeforeValidation($context, $data);
}
return $data;
}
/**
* Parse and validate a JSON:API document's `data` member.
*
* @throws BadRequestException if the `data` member is invalid.
*/
final protected function parseData(Context $context): array
{
$body = (array) $context->body();
if (!isset($body['data']) || !is_array($body['data'])) {
throw (new BadRequestException('data must be an object'))->setSource([
'pointer' => '/data',
]);
}
if (!isset($body['data']['type'])) {
if (isset($context->collection->resources()[0])) {
$body['data']['type'] = $context->collection->resources()[0];
} else {
throw (new BadRequestException('data.type must be present'))->setSource([
'pointer' => '/data/type',
]);
}
}
if (isset($context->model)) {
// commented out to reduce strictness.
// if (!isset($body['data']['id'])) {
// throw (new BadRequestException('data.id must be present'))->setSource([
// 'pointer' => '/data/id',
// ]);
// }
if (isset($body['data']['id']) && $body['data']['id'] !== $context->resource->getId($context->model, $context)) {
throw (new ConflictException('data.id does not match the resource ID'))->setSource([
'pointer' => '/data/id',
]);
}
} elseif (isset($body['data']['id'])) {
throw (new ForbiddenException('Client-generated IDs are not supported'))->setSource([
'pointer' => '/data/id',
]);
}
if (!in_array($body['data']['type'], $context->collection->resources())) {
throw (new ConflictException(
'collection does not support this resource type',
))->setSource(['pointer' => '/data/type']);
}
if (array_key_exists('attributes', $body['data']) && !is_array($body['data']['attributes'])) {
throw (new BadRequestException('data.attributes must be an object'))->setSource([
'pointer' => '/data/attributes',
]);
}
if (array_key_exists('relationships', $body['data']) && !is_array($body['data']['relationships'])) {
throw (new BadRequestException('data.relationships must be an object'))->setSource([
'pointer' => '/data/relationships',
]);
}
return array_merge(['attributes' => [], 'relationships' => []], $body['data']);
}
}

View File

@@ -9,17 +9,84 @@
namespace Flarum\Api\Endpoint;
use Flarum\Api\Context;
use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Tobyz\JsonApiServer\Endpoint\Create as BaseCreate;
use Flarum\Api\Endpoint\Concerns\IncludesData;
use Flarum\Api\Endpoint\Concerns\SavesAndValidatesData;
use Flarum\Database\Eloquent\Collection;
use RuntimeException;
use Tobyz\JsonApiServer\Endpoint\Concerns\ShowsResources;
use Tobyz\JsonApiServer\Resource\Creatable;
use function Tobyz\JsonApiServer\has_value;
use function Tobyz\JsonApiServer\json_api_response;
use function Tobyz\JsonApiServer\set_value;
class Create extends BaseCreate implements EndpointInterface
class Create extends Endpoint
{
use SavesAndValidatesData;
use ShowsResources;
use IncludesData;
use HasAuthorization;
use HasCustomHooks;
public static function make(?string $name = null): static
{
return parent::make($name ?? 'create');
}
public function setUp(): void
{
parent::setUp();
$this->route('POST', '/')
->action(function (Context $context): ?object {
if (str_contains($context->path(), '/')) {
return null;
}
$collection = $context->collection;
if (!$collection instanceof Creatable) {
throw new RuntimeException(
sprintf('%s must implement %s', get_class($collection), Creatable::class),
);
}
$this->callBeforeHook($context);
$data = $this->parseData($context);
$context = $context
->withResource($resource = $context->resource($data['type']))
->withModel($model = $collection->newModel($context));
$this->assertFieldsValid($context, $data);
$this->fillDefaultValues($context, $data);
$this->deserializeValues($context, $data);
$this->assertDataValid($context, $data);
$this->setValues($context, $data);
$context = $context->withModel($model = $resource->createAction($model, $context));
$this->saveFields($context, $data);
return $this->callAfterHook($context, $model);
})
->beforeSerialization(function (Context $context, object $model) {
$this->loadRelations(Collection::make([$model]), $context, $this->getInclude($context));
})
->response(function (Context $context, object $model) {
return json_api_response($document = $this->showResource($context, $model))
->withStatus(201)
->withHeader('Location', $document['data']['links']['self']);
});
}
final protected function fillDefaultValues(Context $context, array &$data): void
{
foreach ($context->fields($context->resource) as $field) {
if (!has_value($data, $field) && ($default = $field->default)) {
set_value($data, $field, $default($context->withField($field)));
}
}
}
}

View File

@@ -9,12 +9,56 @@
namespace Flarum\Api\Endpoint;
use Flarum\Api\Context;
use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Tobyz\JsonApiServer\Endpoint\Delete as BaseDelete;
use Nyholm\Psr7\Response;
use RuntimeException;
use Tobyz\JsonApiServer\Resource\Deletable;
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
use function Tobyz\JsonApiServer\json_api_response;
class Delete extends BaseDelete implements EndpointInterface
class Delete extends Endpoint
{
use HasMeta;
use HasAuthorization;
use HasCustomHooks;
public static function make(?string $name = null): static
{
return parent::make($name ?? 'delete');
}
public function setUp(): void
{
$this->route('DELETE', '/{id}')
->action(function (Context $context) {
$model = $context->model;
$context = $context->withResource(
$resource = $context->resource($context->collection->resource($model, $context)),
);
if (!$resource instanceof Deletable) {
throw new RuntimeException(
sprintf('%s must implement %s', get_class($resource), Deletable::class),
);
}
$this->callBeforeHook($context);
$resource->deleteAction($model, $context);
$this->callAfterHook($context, $model);
return null;
})
->response(function (Context $context) {
if ($meta = $this->serializeMeta($context)) {
return json_api_response(['meta' => $meta]);
}
return new Response(204);
});
}
}

View File

@@ -9,14 +9,140 @@
namespace Flarum\Api\Endpoint;
use Closure;
use Flarum\Api\Context;
use Flarum\Api\Endpoint\Concerns\ExtractsListingParams;
use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Tobyz\JsonApiServer\Endpoint\Endpoint as BaseEndpoint;
use Flarum\Api\Endpoint\Concerns\HasEagerLoading;
use Psr\Http\Message\ResponseInterface as Response;
use RuntimeException;
use Tobyz\JsonApiServer\Endpoint\Concerns\FindsResources;
use Tobyz\JsonApiServer\Endpoint\Concerns\ShowsResources;
use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\Exception\MethodNotAllowedException;
use function Tobyz\JsonApiServer\json_api_response;
class Endpoint extends BaseEndpoint implements EndpointInterface
class Endpoint implements EndpointInterface
{
use ShowsResources;
use FindsResources;
use HasEagerLoading;
use HasAuthorization;
use HasCustomHooks;
use ExtractsListingParams;
public string $method;
public string $path;
protected ?Closure $action = null;
protected ?Closure $response = null;
protected array $beforeSerialization = [];
public function __construct(
public string $name
) {
}
public static function make(?string $name): static
{
$endpoint = new static($name);
$endpoint->setUp();
return $endpoint;
}
protected function setUp(): void
{
}
public function name(string $name): static
{
$this->name = $name;
return $this;
}
public function action(Closure $action): static
{
$this->action = $action;
return $this;
}
public function response(Closure $response): static
{
$this->response = $response;
return $this;
}
public function route(string $method, string $path): static
{
$this->method = $method;
$this->path = '/' . ltrim(rtrim($path, '/'), '/');
return $this;
}
public function beforeSerialization(Closure $callback): static
{
$this->beforeSerialization[] = $callback;
return $this;
}
public function process(Context $context): mixed
{
if (! $this->action) {
throw new RuntimeException("No action defined for endpoint [".static::class."]");
}
return ($this->action)($context);
}
/**
* @param Context $context
*/
public function handle(\Tobyz\JsonApiServer\Context $context): ?Response
{
if (! isset($this->method, $this->path)) {
throw new RuntimeException("No route defined for endpoint [".static::class."]");
}
if (strtolower($context->method()) !== strtolower($this->method)) {
throw new MethodNotAllowedException();
}
$context = $context->withModelId(
$context->collection->id($context)
);
if ($context->modelId) {
$context = $context->withModel(
$this->findResource($context, $context->modelId)
);
}
if (!$this->isVisible($context)) {
throw new ForbiddenException();
}
$data = $this->process($context);
foreach ($this->beforeSerialization as $callback) {
$callback($context, $data);
}
if ($this->response) {
return ($this->response)($context, $data);
}
if ($context->model && $data instanceof $context->model) {
return json_api_response($this->showResource($context, $data));
}
return null;
}
}

View File

@@ -9,10 +9,7 @@
namespace Flarum\Api\Endpoint;
/**
* @mixin \Tobyz\JsonApiServer\Endpoint\Endpoint
*/
interface EndpointInterface
interface EndpointInterface extends \Tobyz\JsonApiServer\Endpoint\Endpoint
{
//
}

View File

@@ -9,28 +9,64 @@
namespace Flarum\Api\Endpoint;
use Closure;
use Flarum\Api\Context;
use Flarum\Api\Endpoint\Concerns\ExtractsListingParams;
use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Flarum\Api\Endpoint\Concerns\IncludesData;
use Flarum\Api\Resource\Contracts\Countable;
use Flarum\Api\Resource\Contracts\Listable;
use Flarum\Database\Eloquent\Collection;
use Flarum\Search\SearchCriteria;
use Flarum\Search\SearchManager;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Tobyz\JsonApiServer\Endpoint\Index as BaseIndex;
use Psr\Http\Message\ResponseInterface as Response;
use RuntimeException;
use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\Sourceable;
use Tobyz\JsonApiServer\Pagination\OffsetPagination;
use Tobyz\JsonApiServer\Pagination\Pagination;
use Tobyz\JsonApiServer\Schema\Concerns\HasMeta;
use Tobyz\JsonApiServer\Serializer;
use function Tobyz\JsonApiServer\apply_filters;
use function Tobyz\JsonApiServer\json_api_response;
use function Tobyz\JsonApiServer\parse_sort_string;
class Index extends BaseIndex implements EndpointInterface
class Index extends Endpoint
{
use HasMeta;
use IncludesData;
use HasAuthorization;
use ExtractsListingParams;
use HasCustomHooks;
public function setUp(): void
{
parent::setUp();
public Closure $paginationResolver;
public ?string $defaultSort = null;
protected ?Closure $query = null;
$this
public function __construct(string $name)
{
parent::__construct($name);
$this->paginationResolver = fn() => null;
}
public static function make(?string $name = null): static
{
return parent::make($name ?? 'index');
}
public function query(?Closure $query): static
{
$this->query = $query;
return $this;
}
protected function setUp(): void
{
$this->route('GET', '/')
->query(function ($query, ?Pagination $pagination, Context $context): Context {
// This model has a searcher API, so we'll use that instead of the default.
// The searcher API allows swapping the default search engine for a custom one.
@@ -67,9 +103,141 @@ class Index extends BaseIndex implements EndpointInterface
}
return $context;
})
->action(function (\Tobyz\JsonApiServer\Context $context) {
if (str_contains($context->path(), '/')) {
return null;
}
$collection = $context->collection;
if (!$collection instanceof Listable) {
throw new RuntimeException(
sprintf('%s must implement %s', get_class($collection), Listable::class),
);
}
$this->callBeforeHook($context);
$query = $collection->query($context);
$pagination = ($this->paginationResolver)($context);
if ($this->query) {
$context = ($this->query)($query, $pagination, $context);
if (! $context instanceof Context) {
throw new RuntimeException('The Index endpoint query closure must return a Context instance.');
}
} else {
$context = $context->withQuery($query);
$this->applySorts($query, $context);
$this->applyFilters($query, $context);
if ($pagination) {
$pagination->apply($query);
}
}
$meta = $this->serializeMeta($context);
if (
$collection instanceof Countable &&
!is_null($total = $collection->count($query, $context))
) {
$meta['page']['total'] = $total;
}
$models = $collection->results($query, $context);
$models = $this->callAfterHook($context, $models);
$total ??= null;
return compact('models', 'meta', 'pagination', 'total');
})
->beforeSerialization(function (Context $context, array $results) {
$this->loadRelations(Collection::make($results['models']), $context, $this->getInclude($context));
})
->response(function (Context $context, array $results): Response {
$collection = $context->collection;
['models' => $models, 'meta' => $meta, 'pagination' => $pagination, 'total' => $total] = $results;
$serializer = new Serializer($context);
$include = $this->getInclude($context);
foreach ($models as $model) {
$serializer->addPrimary(
$context->resource($collection->resource($model, $context)),
$model,
$include,
);
}
[$data, $included] = $serializer->serialize();
$links = [];
if ($pagination) {
$meta['page'] = array_merge($meta['page'] ?? [], $pagination->meta());
$links = array_merge($links, $pagination->links(count($data), $total));
}
return json_api_response(compact('data', 'included', 'meta', 'links'));
});
}
public function defaultSort(?string $defaultSort): static
{
$this->defaultSort = $defaultSort;
return $this;
}
final protected function applySorts($query, Context $context): void
{
if (!($sortString = $context->queryParam('sort', $this->defaultSort))) {
return;
}
$sorts = $context->collection->resolveSorts();
foreach (parse_sort_string($sortString) as [$name, $direction]) {
foreach ($sorts as $field) {
if ($field->name === $name && $field->isVisible($context)) {
$field->apply($query, $direction, $context);
continue 2;
}
}
throw (new BadRequestException("Invalid sort: $name"))->setSource([
'parameter' => 'sort',
]);
}
}
final protected function applyFilters($query, Context $context): void
{
if (!($filters = $context->queryParam('filter'))) {
return;
}
if (!is_array($filters)) {
throw (new BadRequestException('filter must be an array'))->setSource([
'parameter' => 'filter',
]);
}
try {
apply_filters($query, $filters, $context->collection, $context);
} catch (Sourceable $e) {
throw $e->prependSource(['parameter' => 'filter']);
}
}
public function paginate(int $defaultLimit = 20, int $maxLimit = 50): static
{
$this->limit = $defaultLimit;

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\Database\Eloquent\Collection;
use Tobyz\JsonApiServer\Endpoint\Concerns\ShowsResources;
class Show extends BaseShow implements EndpointInterface
class Show extends Endpoint
{
use ShowsResources;
use IncludesData;
use HasAuthorization;
use ExtractsListingParams;
use HasCustomHooks;
public static function make(?string $name = null): static
{
return parent::make($name ?? 'show');
}
public function setUp(): void
{
parent::setUp();
$this->route('GET', '/{id}')
->action(function (Context $context): ?object {
$this->callBeforeHook($context);
return $this->callAfterHook($context, $context->model);
})
->beforeSerialization(function (Context $context, object $model) {
$this->loadRelations(Collection::make([$model]), $context, $this->getInclude($context));
});
}
}

View File

@@ -9,17 +9,62 @@
namespace Flarum\Api\Endpoint;
use Flarum\Api\Context;
use Flarum\Api\Endpoint\Concerns\HasAuthorization;
use Flarum\Api\Endpoint\Concerns\HasCustomHooks;
use Tobyz\JsonApiServer\Endpoint\Update as BaseUpdate;
use Flarum\Api\Endpoint\Concerns\IncludesData;
use Flarum\Api\Endpoint\Concerns\SavesAndValidatesData;
use Flarum\Database\Eloquent\Collection;
use RuntimeException;
use Tobyz\JsonApiServer\Endpoint\Concerns\ShowsResources;
use Tobyz\JsonApiServer\Resource\Updatable;
class Update extends BaseUpdate implements EndpointInterface
class Update extends Endpoint
{
use SavesAndValidatesData;
use ShowsResources;
use IncludesData;
use HasAuthorization;
use HasCustomHooks;
public static function make(?string $name = null): static
{
return parent::make($name ?? 'update');
}
public function setUp(): void
{
parent::setUp();
$this->route('PATCH', '/{id}')
->action(function (Context $context): object {
$model = $context->model;
$context = $context->withResource(
$resource = $context->resource($context->collection->resource($model, $context)),
);
if (!$resource instanceof Updatable) {
throw new RuntimeException(
sprintf('%s must implement %s', get_class($resource), Updatable::class),
);
}
$this->callBeforeHook($context);
$data = $this->parseData($context);
$this->assertFieldsValid($context, $data);
$this->deserializeValues($context, $data);
$this->assertDataValid($context, $data);
$this->setValues($context, $data);
$context = $context->withModel($model = $resource->updateAction($model, $context));
$this->saveFields($context, $data);
return $this->callAfterHook($context, $model);
})
->beforeSerialization(function (Context $context, object $model) {
$this->loadRelations(Collection::make([$model]), $context, $this->getInclude($context));
});
}
}

View File

@@ -19,9 +19,11 @@ use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Tobyz\JsonApiServer\Endpoint\Endpoint;
use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
use Tobyz\JsonApiServer\JsonApi as BaseJsonApi;
use Tobyz\JsonApiServer\Resource\Collection;
use Tobyz\JsonApiServer\Resource\Resource;
use Tobyz\JsonApiServer\Schema\Field\Field;
class JsonApi extends BaseJsonApi
{
@@ -57,9 +59,9 @@ class JsonApi extends BaseJsonApi
->withEndpoint($this->findEndpoint($collection));
}
protected function findEndpoint(?Collection $collection): Endpoint&EndpointInterface
protected function findEndpoint(?Collection $collection): EndpointInterface
{
/** @var Endpoint&EndpointInterface $endpoint */
/** @var EndpointInterface $endpoint */
foreach ($collection->resolveEndpoints() as $endpoint) {
if ($endpoint->name === $this->endpointName) {
return $endpoint;
@@ -69,6 +71,46 @@ class JsonApi extends BaseJsonApi
throw new BadRequestException('Invalid endpoint specified');
}
/**
* Get a collection by name or class.
*
* @throws ResourceNotFoundException if the collection has not been defined.
*/
public function getCollection(string $type): Collection
{
if (isset($this->collections[$type])) {
return $this->collections[$type];
}
foreach ($this->collections as $instance) {
if ($instance instanceof $type) {
return $instance;
}
}
throw new ResourceNotFoundException($type);
}
/**
* Get a resource by type or class.
*
* @throws ResourceNotFoundException if the resource has not been defined.
*/
public function getResource(string $type): Resource
{
if (isset($this->resources[$type])) {
return $this->resources[$type];
}
foreach ($this->resources as $instance) {
if ($instance instanceof $type) {
return $instance;
}
}
throw new ResourceNotFoundException($type);
}
public function withRequest(Request $request): self
{
$this->baseRequest = $request;

View File

@@ -10,37 +10,48 @@
namespace Flarum\Api\Resource;
use Flarum\Api\Context as FlarumContext;
use Flarum\Api\Resource\Concerns\Bootable;
use Flarum\Api\Resource\Concerns\Extendable;
use Flarum\Api\Resource\Concerns\HasSortMap;
use Flarum\Api\Schema\Contracts\RelationAggregator;
use Flarum\Foundation\DispatchEventsTrait;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Str;
use InvalidArgumentException;
use RuntimeException;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Laravel\EloquentResource as BaseResource;
use Tobyz\JsonApiServer\Pagination\OffsetPagination;
use Tobyz\JsonApiServer\Schema\Field\Attribute;
use Tobyz\JsonApiServer\Schema\Field\Field;
use Tobyz\JsonApiServer\Schema\Field\Relationship;
use Tobyz\JsonApiServer\Schema\Field\ToMany;
use Tobyz\JsonApiServer\Schema\Type\DateTime;
/**
* @template M of Model
* @extends BaseResource<M, FlarumContext>
* @extends AbstractResource<M, FlarumContext>
*/
abstract class AbstractDatabaseResource extends BaseResource
abstract class AbstractDatabaseResource extends AbstractResource implements
Contracts\Findable,
Contracts\Listable,
Contracts\Countable,
Contracts\Paginatable,
Contracts\Creatable,
Contracts\Updatable,
Contracts\Deletable
{
use Bootable;
use Extendable;
use HasSortMap;
use DispatchEventsTrait {
dispatchEventsFor as traitDispatchEventsFor;
}
abstract public function model(): string;
/** @inheritDoc */
public function newModel(Context $context): object
{
return new ($this->model());
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function resource(object $model, Context $context): ?string
{
$baseModel = $this->model();
@@ -52,29 +63,302 @@ abstract class AbstractDatabaseResource extends BaseResource
return null;
}
public function filters(): array
/**
* @param M $model
* @param FlarumContext $context
*/
public function getId(object $model, Context $context): string
{
throw new RuntimeException('Not supported in Flarum, please use a model searcher instead https://docs.flarum.org/extend/search.');
return $model->getKey();
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function getValue(object $model, Field $field, Context $context): mixed
{
if ($field instanceof Relationship) {
return $this->getRelationshipValue($model, $field, $context);
} else {
return $this->getAttributeValue($model, $field, $context);
}
}
/**
* @param M $model
* @param FlarumContext $context
*/
protected function getAttributeValue(Model $model, Field $field, Context $context)
{
if ($field instanceof RelationAggregator && ($aggregate = $field->getRelationAggregate())) {
$relationName = $aggregate['relation'];
if (! $model->isRelation($relationName)) {
return $model->getAttribute($this->property($field));
}
$relationship = collect($context->fields($this))->first(fn ($f) => $f->name === $relationName);
if (! $relationship) {
throw new InvalidArgumentException("To use relation aggregates, the relationship field must be part of the resource. Missing field: $relationName for attribute $field->name.");
}
EloquentBuffer::add($model, $relationName, $aggregate);
return function () use ($model, $relationName, $relationship, $field, $context, $aggregate) {
EloquentBuffer::load($model, $relationName, $relationship, $context, $aggregate);
return $model->getAttribute($this->property($field));
};
}
return $model->getAttribute($this->property($field));
}
/**
* @param M $model
* @param FlarumContext $context
*/
protected function getRelationshipValue(Model $model, Relationship $field, Context $context)
{
$method = $this->method($field);
if ($model->isRelation($method)) {
$relation = $model->$method();
// If this is a belongs-to relationship, and we only need to get the ID
// for linkage, then we don't have to actually load the relation because
// the ID is stored in a column directly on the model. We will mock up a
// related model with the value of the ID filled.
if ($relation instanceof BelongsTo && $context->include === null) {
if ($key = $model->getAttribute($relation->getForeignKeyName())) {
if ($relation instanceof MorphTo) {
$morphType = $model->{$relation->getMorphType()};
$morphType = MorphTo::getMorphedModel($morphType) ?? $morphType;
$related = $relation->createModelByType($morphType);
} else {
$related = $relation->getRelated();
}
return $related->newInstance()->forceFill([$related->getKeyName() => $key]);
}
return null;
}
EloquentBuffer::add($model, $method);
return function () use ($model, $method, $field, $context) {
EloquentBuffer::load($model, $method, $field, $context);
$data = $model->getRelation($method);
return $data instanceof Collection ? $data->all() : $data;
};
}
return $this->getAttributeValue($model, $field, $context);
}
/**
* @param FlarumContext $context
*/
public function query(Context $context): object
{
$query = $this->newModel($context)->query();
$this->scope($query, $context);
return $query;
}
/**
* Hook to scope a query for this resource.
*
* @param Builder<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
{
if ($results = $context->getSearchResults()) {
return $results->getResults();
}
return $query->get();
}
/**
* @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
{
if ($results = $context->getSearchResults()) {
return $results->getTotalResults();
}
return $query->toBase()->getCountForPagination();
}
/**
* @param FlarumContext $context
*/
public function find(string $id, Context $context): ?object
{
if ($id === null) {
return null;
}
return $this->query($context)->find($id);
}
/**
* @param M $model
* @param FlarumContext $context
* @throws \Exception
*/
public function setValue(object $model, Field $field, mixed $value, Context $context): void
{
if ($field instanceof Relationship) {
$method = $this->method($field);
$relation = $model->$method();
// If this is a belongs-to relationship, then the ID is stored on the
// model itself, so we can set it here.
if ($relation instanceof BelongsTo) {
$relation->associate($value);
}
return;
}
// Mind-blowingly, Laravel discards timezone information when storing
// dates in the database. Since the API can receive dates in any
// timezone, we will need to convert it to the app's configured
// timezone ourselves before storage.
if (
$field instanceof Attribute &&
$field->type instanceof DateTime &&
$value instanceof \DateTimeInterface
) {
$value = \DateTime::createFromInterface($value)->setTimezone(
new \DateTimeZone(config('app.timezone')),
);
}
$model->setAttribute($this->property($field), $value);
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function saveValue(object $model, Field $field, mixed $value, Context $context): void
{
if ($field instanceof ToMany) {
$method = $this->method($field);
$relation = $model->$method();
if ($relation instanceof BelongsToMany) {
$relation->sync(new Collection($value));
}
}
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function createAction(object $model, Context $context): object
{
$model = parent::createAction($model, $context);
$model = $this->creating($model, $context) ?: $model;
$model = $this->saving($model, $context) ?: $model;
$model = $this->create($model, $context);
$model = $this->saved($model, $context) ?: $model;
$model = $this->created($model, $context) ?: $model;
$this->dispatchEventsFor($model, $context->getActor());
return $model;
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function create(object $model, Context $context): object
{
$this->saveModel($model, $context);
return $model;
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function updateAction(object $model, Context $context): object
{
$model = parent::updateAction($model, $context);
$model = $this->updating($model, $context) ?: $model;
$model = $this->saving($model, $context) ?: $model;
$this->update($model, $context);
$model = $this->saved($model, $context) ?: $model;
$model = $this->updated($model, $context) ?: $model;
$this->dispatchEventsFor($model, $context->getActor());
return $model;
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function update(object $model, Context $context): object
{
$this->saveModel($model, $context);
return $model;
}
/**
* @param M $model
* @param FlarumContext $context
*/
protected function saveModel(Model $model, Context $context): void
{
$model->save();
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function deleteAction(object $model, Context $context): void
{
$this->deleting($model, $context);
@@ -86,6 +370,42 @@ abstract class AbstractDatabaseResource extends BaseResource
$this->dispatchEventsFor($model, $context->getActor());
}
/**
* @param M $model
* @param FlarumContext $context
*/
public function delete(object $model, Context $context): void
{
$model->delete();
}
/**
* Get the model property that a field represents.
*/
protected function property(Field $field): string
{
return $field->property ?: Str::snake($field->name);
}
/**
* Get the model method that a field represents.
*/
protected function method(Field $field): string
{
return $field->property ?: $field->name;
}
/** @inheritDoc */
public function newModel(Context $context): object
{
return new ($this->model());
}
public function filters(): array
{
throw new RuntimeException('Not supported in Flarum, please use a model searcher instead https://docs.flarum.org/extend/search.');
}
/**
* @param M $model
* @param FlarumContext $context
@@ -178,28 +498,4 @@ abstract class AbstractDatabaseResource extends BaseResource
{
return $data;
}
/**
* @param FlarumContext $context
*/
public function results(object $query, Context $context): iterable
{
if ($results = $context->getSearchResults()) {
return $results->getResults();
}
return $query->get();
}
/**
* @param FlarumContext $context
*/
public function count(object $query, Context $context): ?int
{
if ($results = $context->getSearchResults()) {
return $results->getTotalResults();
}
return parent::count($query, $context);
}
}

View File

@@ -24,4 +24,9 @@ abstract class AbstractResource extends BaseResource
use Bootable;
use Extendable;
use HasSortMap;
public function id(Context $context): ?string
{
return $context->extractIdFromPath($context);
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Flarum\Api\Resource\Contracts;
use Tobyz\JsonApiServer\Resource\Attachable as AttachableContract;
interface Attachable extends AttachableContract
{
//
}

View File

@@ -0,0 +1,13 @@
<?php
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,10 @@
<?php
namespace Flarum\Api\Resource\Contracts;
use Tobyz\JsonApiServer\Resource\Creatable as CreatableContract;
interface Creatable extends CreatableContract
{
//
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Flarum\Api\Resource\Contracts;
use Tobyz\JsonApiServer\Resource\Deletable as DeletableContract;
interface Deletable extends DeletableContract
{
//
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Flarum\Api\Resource\Contracts;
use Tobyz\JsonApiServer\Resource\Findable as FindableContract;
interface Findable extends FindableContract
{
//
}

View File

@@ -0,0 +1,47 @@
<?php
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,10 @@
<?php
namespace Flarum\Api\Resource\Contracts;
use Tobyz\JsonApiServer\Resource\Paginatable as PaginatableContract;
interface Paginatable extends PaginatableContract
{
//
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Flarum\Api\Resource\Contracts;
use Tobyz\JsonApiServer\Resource\Updatable as UpdatableContract;
interface Updatable extends UpdatableContract
{
//
}

View File

@@ -0,0 +1,133 @@
<?php
namespace Flarum\Api\Resource;
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\Context;
use Tobyz\JsonApiServer\Laravel\EloquentResource;
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
{
static::$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 static::$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
{
static::$buffer[get_class($model)][$relationName][$aggregate ? $aggregate['column'].$aggregate['function'] : 'normal'] = $buffer;
}
/**
* @param array{relation: string, column: string, function: string, constrain: Closure}|null $aggregate
*/
public static function load(
Model $model,
string $relationName,
Relationship $relationship,
Context $context,
?array $aggregate = null,
): void {
if (!($models = static::getBuffer($model, $relationName, $aggregate))) {
return;
}
$loader = function ($relation) use (
$model,
$relationName,
$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.
$resources = $context->api->resources;
if ($type = $relationship->collections) {
$resources = array_intersect_key($resources, array_flip($type));
}
// Now, construct a map of model class names -> scoping
// functions. This will be provided to the MorphTo::constrain
// method in order to apply type-specific scoping.
$constrain = [];
foreach ($resources as $resource) {
$modelClass = get_class($resource->newModel($context));
if ($resource instanceof AbstractDatabaseResource && !isset($constrain[$modelClass])) {
$constrain[$modelClass] = function (Builder $query) use ($resource, $context, $relationship, $relation, $aggregate) {
if (! $aggregate) {
$query
->with($context->endpoint->getEagerLoadsFor($relationship->name, $context))
->with($context->endpoint->getWhereEagerLoadsFor($relationship->name, $context));
}
$resource->scope($query, $context);
if ($aggregate && ! empty($aggregate['constrain'])) {
($aggregate['constrain'])($query, $context);
}
if (($relationship instanceof ToMany || $relationship instanceof ToOne) && $relationship->scope) {
($relationship->scope)($query, $context);
}
};
}
}
if ($relation instanceof MorphTo) {
$relation->constrain($constrain);
} elseif ($constrain) {
reset($constrain)($query);
}
return $query;
};
$collection = $model->newCollection($models);
if (! $aggregate) {
$collection->load([$relationName => $loader]);
// Set the inverse relation on the loaded relations.
$collection->each(function (Model $model) use ($relationName, $relationship) {
/** @var Model|Collection $related */
if ($related = $model->getRelation($relationName)) {
$inverse = $relationship->inverse ?? str($model::class)->afterLast('\\')->camel()->toString();
$related = $related instanceof Collection ? $related : [$related];
foreach ($related as $rel) {
if ($rel->isRelation($inverse)) {
$rel->setRelation($inverse, $model);
}
}
}
});
} else {
$collection->loadAggregate([$relationName => $loader], $aggregate['column'], $aggregate['function']);
}
static::setBuffer($model, $relationName, $aggregate, []);
}
}

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,37 @@
<?php
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,30 @@
<?php
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,55 @@
<?php
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,158 @@
<?php
namespace Flarum\Api\Schema\Concerns;
use Flarum\Api\Context;
use Tobyz\JsonApiServer\Endpoint\Update;
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,13 @@
<?php
namespace Flarum\Api\Schema\Contracts;
interface RelationAggregator
{
public function relationAggregate(string $relation, string $column, string $function): static;
/**
* @return array{relation: string, column: string, function: string}|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,10 @@
<?php
namespace Flarum\Api\Sort;
use Tobyz\JsonApiServer\Laravel\Sort\SortWithCount as BaseSortWithCount;
class SortWithCount extends BaseSortWithCount
{
//
}

View File

@@ -36,7 +36,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,7 +174,7 @@ class ApiResource implements ExtenderInterface
});
}
/** @var class-string<\Flarum\Api\Resource\AbstractResource|\Flarum\Api\Resource\AbstractDatabaseResource> $resourceClass */
/** @var class-string<\Flarum\Api\Resource\AbstractResource> $resourceClass */
$resourceClass = $this->resourceClass;
$resourceClass::mutateEndpoints(

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