diff --git a/framework/core/src/Api/Endpoint/Concerns/ShowsResources.php b/framework/core/src/Api/Endpoint/Concerns/ShowsResources.php new file mode 100644 index 000000000..637c5d7cd --- /dev/null +++ b/framework/core/src/Api/Endpoint/Concerns/ShowsResources.php @@ -0,0 +1,39 @@ +addPrimary( + $context->resource($context->collection->resource($model, $context)), + $model, + $this->getInclude($context), + ); + + [$primary, $included] = $serializer->serialize(); + + $document = ['data' => $primary[0]]; + + if (count($included)) { + $document['included'] = $included; + } + + if ($meta = $this->serializeMeta($context)) { + $document['meta'] = $meta; + } + + return $document; + } +} diff --git a/framework/core/src/Api/Endpoint/Create.php b/framework/core/src/Api/Endpoint/Create.php index 44ef17b01..37fb67ea6 100644 --- a/framework/core/src/Api/Endpoint/Create.php +++ b/framework/core/src/Api/Endpoint/Create.php @@ -14,9 +14,9 @@ use Flarum\Api\Endpoint\Concerns\HasAuthorization; use Flarum\Api\Endpoint\Concerns\HasCustomHooks; use Flarum\Api\Endpoint\Concerns\IncludesData; use Flarum\Api\Endpoint\Concerns\SavesAndValidatesData; +use Flarum\Api\Endpoint\Concerns\ShowsResources; use Flarum\Database\Eloquent\Collection; use RuntimeException; -use Tobyz\JsonApiServer\Endpoint\Concerns\ShowsResources; use Tobyz\JsonApiServer\Resource\Creatable; use function Tobyz\JsonApiServer\has_value; diff --git a/framework/core/src/Api/Endpoint/Endpoint.php b/framework/core/src/Api/Endpoint/Endpoint.php index ba93e913e..31e21c9c9 100644 --- a/framework/core/src/Api/Endpoint/Endpoint.php +++ b/framework/core/src/Api/Endpoint/Endpoint.php @@ -15,10 +15,10 @@ use Flarum\Api\Endpoint\Concerns\ExtractsListingParams; use Flarum\Api\Endpoint\Concerns\HasAuthorization; use Flarum\Api\Endpoint\Concerns\HasCustomHooks; use Flarum\Api\Endpoint\Concerns\HasEagerLoading; +use Flarum\Api\Endpoint\Concerns\ShowsResources; 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; diff --git a/framework/core/src/Api/Endpoint/Index.php b/framework/core/src/Api/Endpoint/Index.php index 3820d1ca6..294ed34dc 100644 --- a/framework/core/src/Api/Endpoint/Index.php +++ b/framework/core/src/Api/Endpoint/Index.php @@ -17,6 +17,7 @@ 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\Api\Serializer; use Flarum\Database\Eloquent\Collection; use Flarum\Search\SearchCriteria; use Flarum\Search\SearchManager; @@ -28,7 +29,6 @@ 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; diff --git a/framework/core/src/Api/Endpoint/Show.php b/framework/core/src/Api/Endpoint/Show.php index c8d655b03..7c957ea6a 100644 --- a/framework/core/src/Api/Endpoint/Show.php +++ b/framework/core/src/Api/Endpoint/Show.php @@ -14,8 +14,8 @@ 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\Endpoint\Concerns\ShowsResources; use Flarum\Database\Eloquent\Collection; -use Tobyz\JsonApiServer\Endpoint\Concerns\ShowsResources; class Show extends Endpoint { diff --git a/framework/core/src/Api/Endpoint/Update.php b/framework/core/src/Api/Endpoint/Update.php index b37cf110e..d78660a75 100644 --- a/framework/core/src/Api/Endpoint/Update.php +++ b/framework/core/src/Api/Endpoint/Update.php @@ -14,9 +14,9 @@ use Flarum\Api\Endpoint\Concerns\HasAuthorization; use Flarum\Api\Endpoint\Concerns\HasCustomHooks; use Flarum\Api\Endpoint\Concerns\IncludesData; use Flarum\Api\Endpoint\Concerns\SavesAndValidatesData; +use Flarum\Api\Endpoint\Concerns\ShowsResources; use Flarum\Database\Eloquent\Collection; use RuntimeException; -use Tobyz\JsonApiServer\Endpoint\Concerns\ShowsResources; use Tobyz\JsonApiServer\Resource\Updatable; class Update extends Endpoint diff --git a/framework/core/src/Api/Serializer.php b/framework/core/src/Api/Serializer.php new file mode 100644 index 000000000..33d6f6332 --- /dev/null +++ b/framework/core/src/Api/Serializer.php @@ -0,0 +1,196 @@ +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()) { + $resolve(); + } + + if ($i++ > 10) { + throw new RuntimeException('Too many levels of deferred values'); + } + } + } +}