Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

### Testing
- `vendor/bin/phpunit` - Run all tests
- `vendor/bin/phpunit --coverage-clover coverage.xml` - Run tests with coverage
- `vendor/bin/phpunit --configuration phpunit.php8.1.xml.dist` - Run tests for specific PHP version
- `vendor/bin/phpunit tests/Unit` - Run only unit tests
- `vendor/bin/phpunit tests/Feature` - Run only feature tests

### Static Analysis
- `vendor/bin/phpstan analyze` - Run PHPStan static analysis (level 6)

### Dependencies
- `composer install` - Install dependencies
- `composer require laravel/framework ^9.0` - Install specific Laravel version for testing

## Architecture

This is a Laravel package that provides JSON:API compliant resource serialization. The core architecture follows these patterns:

### Resource System
- **JsonApiResource**: Main abstract class extending Laravel's JsonResource, implements JSON:API specification
- **JsonApiCollection**: Handles collections of resources with proper JSON:API formatting
- **Resourceable**: Interface defining resource contracts

### Key Components

#### Descriptors
- **Values** (`src/Descriptors/Values`): Type descriptors for attributes (string, integer, float, date, enum, etc.)
- **Relations** (`src/Descriptors/Relations`): Relationship descriptors (one, many)

#### Resource Concerns
- **Attributes**: Handles attribute serialization with field filtering
- **Relationships**: Manages relationship loading and serialization with include support
- **ConditionallyLoadsAttributes**: Laravel-style conditional attribute support
- **Identifier**: Resource ID and type handling
- **Links**: JSON:API links support
- **Meta**: Meta information handling
- **Schema**: Resource schema generation for validation
- **ToResponse**: Response formatting

#### Request Validation
- **Rules/Includes**: Validates `include` parameter against resource schema
- **Rules/Fields**: Validates `fields` parameter for sparse fieldsets

### Key Features
- **Include Support**: Dynamic relationship loading via `?include=` parameter
- **Sparse Fieldsets**: Attribute filtering via `?fields[type]=` parameter
- **Described Notation**: Fluent API for defining attributes and relationships with type casting
- **Laravel Compatibility**: Supports Laravel 9-12 and PHP 8.1-8.4

### Configuration
The package includes a config file (`config/jsonapi.php`) with settings for:
- Nullable value handling
- Date formatting
- Float precision
- Automatic whenHas conditions
- Relationship loading behavior

### Testing Structure
- **Unit Tests**: Test individual components in isolation
- **Feature Tests**: Test complete JSON:API response formatting
- Uses Orchestra Testbench for Laravel package testing
- SQLite in-memory database for testing
38 changes: 38 additions & 0 deletions src/Descriptors/Relations/Relation.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
namespace Ark4ne\JsonApi\Descriptors\Relations;

use Ark4ne\JsonApi\Descriptors\Describer;
use Ark4ne\JsonApi\Filters\Filters;
use Ark4ne\JsonApi\Resources\Relationship;
use Ark4ne\JsonApi\Support\Includes;
use Ark4ne\JsonApi\Traits\HasRelationLoad;
use Closure;
use Illuminate\Contracts\Auth\Access\Gate;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\MissingValue;
Expand All @@ -23,6 +25,7 @@ abstract class Relation extends Describer
protected ?Closure $links = null;
protected ?Closure $meta = null;
protected ?bool $whenIncluded = null;
protected ?Filters $filters = null;

/**
* @param class-string<\Ark4ne\JsonApi\Resources\JsonApiResource|\Ark4ne\JsonApi\Resources\JsonApiCollection> $related
Expand Down Expand Up @@ -63,6 +66,37 @@ public function meta(Closure $meta): static
return $this;
}

/**
* Set filters for the relationship
*
* @param Closure(Filters): Filters $filters Callback that receives (Filters $filters) and configures the filters
* @return static
*/
public function filters(Closure $filters): static
{
$this->filters = $filters(new Filters);
return $this;
}

/**
* @param iterable<mixed>|string $abilities Abilities to check
* @param array<mixed> $arguments Arguments to pass to the policy method, the model is always the first argument
* @param string $gateClass Gate class to use, defaults to the default Gate implementation
* @param string|null $guard Guard to use, defaults to the default guard
* @return static
*/
public function can(iterable|string $abilities, array $arguments = [], string $gateClass = Gate::class, ?string $guard = null): static
{
return $this->when(fn(
Request $request,
Model $model,
string $attribute
) => app($gateClass)
->forUser($request->user($guard))
->allows($abilities, [$model, ...$arguments])
);
}

/**
* @param bool|null $whenIncluded
* @return static
Expand Down Expand Up @@ -139,6 +173,10 @@ public function resolveFor(Request $request, mixed $model, string $attribute): R
$relation->whenIncluded($this->whenIncluded);
}

if ($this->filters !== null) {
$relation->withFilters($this->filters);
}

return $relation;
}

Expand Down
32 changes: 32 additions & 0 deletions src/Filters/CallbackFilterRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace Ark4ne\JsonApi\Filters;

use Closure;
use Illuminate\Http\Request;

/**
* @template Resource
*
* @implements FilterRule<Resource>
*/
class CallbackFilterRule implements FilterRule
{
/**
* @param Closure(Request, Resource): bool $callback
*/
public function __construct(
protected Closure $callback
) {
}

/**
* @param Request $request
* @param Resource $model
* @return bool
*/
public function passes(Request $request, mixed $model): bool
{
return (bool) ($this->callback)($request, $model);
}
}
20 changes: 20 additions & 0 deletions src/Filters/FilterRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace Ark4ne\JsonApi\Filters;

use Illuminate\Http\Request;

/**
* @template Resource
*/
interface FilterRule
{
/**
* Determine if the filter rule passes for the given model
*
* @param Request $request
* @param Resource $model The model being filtered
* @return bool
*/
public function passes(Request $request, mixed $model): bool;
}
96 changes: 96 additions & 0 deletions src/Filters/Filters.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

namespace Ark4ne\JsonApi\Filters;

use Closure;
use Illuminate\Contracts\Auth\Access\Gate;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\MissingValue;
use Illuminate\Support\Collection;

/**
* @template Resource
*/
class Filters
{
/** @var array<FilterRule<Resource>> */
protected array $rules = [];

/**
* Add a policy-based filter
*
* @param iterable<string>|string $abilities Abilities to check
* @param array<mixed> $arguments Arguments to pass to the policy method, the model is always the first argument
* @param string $gateClass Gate class to use, defaults to the default Gate implementation
* @param string|null $guard Guard to use, defaults to the default guard
* @return static
*/
public function can(iterable|string $abilities, array $arguments = [], string $gateClass = Gate::class, ?string $guard = null): static
{
$this->rules[] = new PolicyFilterRule($abilities, $arguments, $gateClass, $guard);
return $this;
}

/**
* Add a custom filter rule
*
* @param Closure $callback Callback that receives (Request $request, Model $model) and returns bool
* @return static
*/
public function when(Closure $callback): static
{
$this->rules[] = new CallbackFilterRule($callback);
return $this;
}

/**
* Apply all filters to the given data
*
* @param mixed $data
* @return mixed
*/
public function apply(Request $request, mixed $data): mixed
{
if ($data instanceof MissingValue || $data === null) {
return $data;
}

// If it's a collection/array, filter each item
if (is_iterable($data)) {
$filtered = [];
foreach ($data as $key => $item) {
if ($this->shouldInclude($request, $item)) {
$filtered[$key] = $item;
}
}

// Preserve the original collection type
if ($data instanceof Collection) {
return new Collection($filtered);
}

return $filtered;
}

// Single model - check if it should be included
return $this->shouldInclude($request, $data) ? $data : new MissingValue();
}

/**
* Check if a model should be included based on all filter rules
*
* @param mixed $model
* @return bool
*/
protected function shouldInclude(Request $request, mixed $model): bool
{
// All rules must pass
foreach ($this->rules as $rule) {
if (!$rule->passes($request, $model)) {
return false;
}
}

return true;
}
}
38 changes: 38 additions & 0 deletions src/Filters/PolicyFilterRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace Ark4ne\JsonApi\Filters;

use Illuminate\Contracts\Auth\Access\Gate;
use Illuminate\Http\Request;

/**
* @template Resource
*
* @implements FilterRule<Resource>
*/
class PolicyFilterRule implements FilterRule
{
/**
* @param iterable<string>|string $abilities
* @param array<mixed> $arguments
*/
public function __construct(
protected iterable|string $abilities,
protected array $arguments = [],
protected string $gateClass = Gate::class,
protected ?string $guard = null
) {
}

/**
* @param Request $request
* @param Resource $model
* @return bool
*/
public function passes(Request $request, mixed $model): bool
{
return app($this->gateClass)
->forUser($request->user($this->guard))
->allows($this->abilities, [$model, ...$this->arguments]);
}
}
21 changes: 21 additions & 0 deletions src/Resources/Relationship.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Ark4ne\JsonApi\Resources;

use Ark4ne\JsonApi\Filters\Filters;
use Ark4ne\JsonApi\Support\Values;
use Ark4ne\JsonApi\Traits\HasRelationLoad;
use Closure;
Expand All @@ -25,6 +26,8 @@ class Relationship implements Resourceable

protected ?bool $whenIncluded = null;

protected ?Filters $filters = null;

/**
* @param class-string<T> $resource
* @param Closure $value
Expand Down Expand Up @@ -111,6 +114,19 @@ public function whenIncluded(null|bool $whenIncluded = null): static
return $this;
}

/**
* Set filters for the relationship
*
* @param Filters $filters
* @return $this
*/
public function withFilters(Filters $filters): static
{
$this->filters = $filters;

return $this;
}

/**
* Return class-string of resource
*
Expand Down Expand Up @@ -148,6 +164,11 @@ public function toArray(mixed $request, bool $included = true): array
: value($this->value);
$value ??= new MissingValue;

// Apply filters if they are defined and we have data
if ($this->filters !== null && !Values::isMissing($value)) {
$value = $this->filters->apply($request, $value);
}

if ($this->asCollection && !is_subclass_of($this->resource, ResourceCollection::class)) {
$resource = $this->resource::collection($value);
} else {
Expand Down
Loading