timacdonald/json-api

A Lightweight JSON:API Resource for Laravel

v1.0.0-beta.2 2023-01-28 02:27 UTC

This package is auto-updated.

Last update: 2023-01-28 02:30:09 UTC


README

JSON:API Resource: a Laravel package by Tim MacDonald

JSON:API Resource for Laravel

A lightweight JSON Resource for Laravel that helps you adhere to the JSON:API standard with support for sparse fieldsets, compound documents, and more.

Note These docs are not designed to introduce you to the JSON:API specification and the associated concepts, instead you should head over and read the specification if you are not yet familiar with it. The documentation that follows only contains information on how to implement the specification via the package.

Table of contents

Version support

  • PHP: 8.0, 8.1, 8.2
  • Laravel: ^8.73.2, ^9.0, 10.x-dev

Installation

You can install using composer from Packagist.

composer require timacdonald/json-api

Getting started

The JsonApiResource class provided by this package is a specialisation of Laravel's Eloquent API resources. All the public facing APIs are still accessible. In a controller, for example, you interact with JsonApiResource classes as you would with Laravel's standard JsonResource class.

<?php

namespace App\Http\Controllers;

use App\Http\Resources\UserResource;
use App\Models\User;

class UserController
{
    public function index()
    {
        $users = User::with([/* ... */])->paginate();

        return UserResource::collection($users);
    }

    public function show(User $user)
    {
        $user->load([/* ... */]);

        return UserResource::make($user);
    }
}

As we make our way through the examples you will notice that we have introduce new APIs for interacting with the class internally, e.g. you no longer implement the toArray() method.

Creating your first JSON:API resource

To get started, let's create a UserResource that includes a few attributes. We will assume the underlying resource, in this example an Eloquent user model, has $user->name, $user->website, and $user->twitter_handle attributes that we want to expose.

To achieve this, we will create an $attributes property on the resource.

<?php

namespace App\Http\Resources;

use TiMacDonald\JsonApi\JsonApiResource;

class UserResource extends JsonApiResource
{
    /**
     * The available attributes.
     *
     * @var array<int, string>
     */
    public $attributes = [
        'name',
        'website',
        'twitter_handle',
    ];
}

When making a request to an endpoint that returns the UserResource, for example:

GET /users/74812

the following JSON:API formatted data will be returned:

{
  "data": {
    "type": "users",
    "id": "74812",
    "attributes": {
      "name": "Tim",
      "website": "https://timacdonald.me",
      "twitter_handle": "@timacdonald87"
    },
    "relationships": {},
    "meta": {},
    "links": {}
  },
  "included": []
}

🎉 You have just created your first JSON:API resource 🎉

Congratulations...and what. a. rush!

We will now dive into returning relationships for your UserResource, but if you would like to explore more complex attribute features, you may like to jump ahead:

Adding relationships

Available relationships may be specified in a $relationships property, similar to the $attributes property. We will expose two relationships on our UserResource: a "toOne" relationship of $user->license and a "toMany" relationship of $user->posts. In this example, these are standard Eloquent relationships.

<?php

namespace App\Http\Resources;

use TiMacDonald\JsonApi\JsonApiResource;

class UserResource extends JsonApiResource
{
    /**
     * The available attributes.
     *
     * @var array<int, string>
     */
    public $attributes = [
        'name',
        'website',
        'twitter_handle',
    ];

    /**
     * The available relationships.
     *
     * @var array<string, class-string<JsonApiResource>>
     */
    public $relationships = [
        'license' => LicenseResource::class,
        'posts' => PostResource::class,
    ];
}
Example response

Request

GET /users/74812?include=posts,license

Note Relationships are not included in the response unless the calling client specifically requests them via the include query parameter. This is intended and is part of the JSON:API specification.

Response

{
  "data": {
    "id": "74812",
    "type": "users",
    "attributes": {
      "name": "Tim",
      "website": "https://timacdonald.me",
      "twitter_handle": "@timacdonald87"
    },
    "relationships": {
      "posts": {
        "data": [
          {
            "type": "posts",
            "id": "25240",
            "meta": {}
          },
          {
            "type": "posts",
            "id": "39974",
            "meta": {}
          }
        ],
        "meta": {},
        "links": {}
      },
      "license": {
        "data": {
          "type": "licenses",
          "id": "18986",
          "meta": {}
        },
        "meta": {},
        "links": {}
      }
    },
    "meta": {},
    "links": {}
  },
  "included": [
    {
      "id": "25240",
      "type": "posts",
      "attributes": {
        "title": "So what is JSON:API all about anyway?",
        "content": "...",
        "excerpt": "..."
      },
      "relationships": {},
      "meta": {},
      "links": {}
    },
    {
      "id": "39974",
      "type": "posts",
      "attributes": {
        "title": "Building an API with Laravel, using the JSON:API specification.",
        "content": "...",
        "excerpt": "..."
      },
      "relationships": {},
      "meta": {},
      "links": {}
    },
    {
      "id": "18986",
      "type": "licenses",
      "attributes": {
        "key": "lic_CNlpZVVrsLlChLBSgS1GK7zJR8EFdupW"
      },
      "relationships": {},
      "meta": {},
      "links": {}
    }
  ]
}

Note Whether to return a toOne or toMany relationship is be handled automatically based on the resolved relationship type 🤖

To learn about more complex relationship features, you may like to jump ahead:

A note on eager loading

This package does not handle eager loading your Eloquent relationships. If a relationship is not eagerly loaded, the package will lazy load the relationship on the fly. I highly recommend using Spatie's query builder which is built for eager loading against the JSON:API query parameter standards. Spatie provide comprehensive documentation on how to use the package, but I will briefly give an example of how you might use this in a controller.

<?php

namespace App\Http\Controllers;

use App\Http\Resources\UserResource;
use App\Models\User;
use Spatie\QueryBuilder\QueryBuilder;

class UserController
{
    public function index()
    {
        $users = QueryBuilder::for(User::class)
            ->allowedIncludes(['license', 'posts'])
            ->paginate();

        return UserResource::collection($users);
    }

    public function show($id)
    {
        $user = QueryBuilder::for(User::class)
            ->allowedIncludes(['license', 'posts'])
            ->findOrFail($id);

        return UserResource::make($user);
    }
}

Digging deeper

We have now covered the basics of exposing attributes and relationships, so we will dive into some more advanced topics to give you even more control over your API responses.

Attributes

As we saw in the Creating your first JSON:API resource section, the $attributes property is the fastest way to expose resource attributes. However, in some scenarios more complex configurations are required.

Remapping $attributes

You may remap the response key of an attribute by creating a key / value pair in the $attributes array. The key should be the attribute on the underlying resource, such as the user model, and the value is what will be used for the response.

<?php

namespace App\Http\Resources;

use TiMacDonald\JsonApi\JsonApiResource;

class UserResource extends JsonApiResource
{
    /**
     * The available attributes.
     *
     * @var array<array-key, string>
     */
    public $attributes = [
        'name',
        'website',
        'twitter_handle' => 'twitterHandle',
    ];
}

The twitter_handle attribute will now be exposed as camel case, i.e. twitterHandle, instead of snake case.

{
  "data": {
    "type": "users",
    "id": "74812",
    "attributes": {
      "name": "Tim",
      "website": "https://timacdonald.me",
      "twitterHandle": "@timacdonald87"
    },
    "relationships": {},
    "meta": {},
    "links": {}
  },
  "included": []
}

toAttributes()

In some scenarios you may need complete control over the attributes you are exposing or access to the current request. If that is the case, you may implement the toAttributes() method.

<?php

namespace App\Http\Resources;

use TiMacDonald\JsonApi\JsonApiResource;

class UserResource extends JsonApiResource
{
    /**
     * The available attributes.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array<string, mixed>
     */
    public function toAttributes($request)
    {
        return [
            'name' => $this->name,
            'website' => $this->website,
            'twitterHandle' => $this->twitter_handle,
            'email' => $this->when($this->email_is_public, $this->email, '<private>'),
            'address' => [
                'city' => $this->address('city'),
                'country' => $this->address('country'),
            ],
        ];
    }
}
Example response
Request

GET /users/74812

Response
{
  "data": {
    "id": "74812",
    "type": "users",
    "attributes": {
      "name": "Tim",
      "website": "https://timacdonald.me",
      "twitterHandle": "@timacdonald87",
      "email": "<private>",
      "address": {
        "city": "Melbourne",
        "country": "Australia"
      }
    },
    "relationships": {},
    "meta": {},
    "links": {}
  },
  "included": []
}

Sparse fieldsets

Sparse fieldsets allows clients to receive deterministic responses while also improving server-side performance and reducing payload sizes. They do this by enabling the client to limit the attributes returned for a given resource type. Sparse fieldsets are part of the JSON:API specification and work out of the box for your resources. We will cover them briefly here, but we recommend reading the specification to learn more.

As an example, say we are building out an index page for our blog posts where we show the post title, excerpt, and the authors name. If the client wishes, they may limit the response to only include these attributes for the returned resources.

To achieve this we will send the following request.

Note The include query parameter is author while the sparse fieldset parameter is users. This is because authors are users, e.g. the Eloquent author() relationship returns a User model.

GET /posts?include=author&fields[posts]=title,excerpt&fields[users]=name
Example response
{
  "data": [
    {
      "id": "25240",
      "type": "posts",
      "attributes": {
        "title": "So what is JSON:API all about anyway?",
        "excerpt": "..."
      },
      "relationships": {
        "author": {
          "data": {
            "type": "users",
            "id": "74812",
            "meta": {}
          },
          "meta": {},
          "links": {}
        }
      },
      "meta": {},
      "links": {}
    },
    {
      "id": "39974",
      "type": "posts",
      "attributes": {
        "title": "Building an API with Laravel, using the JSON:API specification.",
        "excerpt": "..."
      },
      "relationships": {
        "author": {
          "data": {
            "type": "users",
            "id": "74812",
            "meta": {}
          },
          "meta": {},
          "links": {}
        }
      },
      "meta": {},
      "links": {}
    }
  ],
  "included": [
    {
      "type": "users",
      "id": "74812",
      "attributes": {
        "name": "Tim"
      },
      "relationships": {},
      "meta": {},
      "links": {}
    }
  ]
}

Lazy attribute evaluation

To help improve performance for attributes that are expensive to calculate, it is possible to specify attributes that should be lazily evaluated. This is useful if you are making requests to the database or making HTTP requests in your resource.

As an example, let's imagine that we expose a base64 encoded avatar for each user. Our implementation downloads the avatar from our avatar microservice.

<?php

namespace App\Http\Resources;

use Illuminate\Support\Facades\Http;
use TiMacDonald\JsonApi\JsonApiResource;

class UserResource extends JsonApiResource
{
    /**
     * The available attributes.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array<string, mixed>
     */
    public function toAttributes($request)
    {
        return [
            /* ... */
            'avatar' => Http::get('https://avatar.example.com', [
                'email' => $this->email,
            ])->body(),
        ];
    }
}

This implementation would make a HTTP request to our microservice even when the client is excluding the avatar attribute via sparse fieldsets, however if we wrap this attribute in a Closure it will only be evaluated when the avatar is to be returned in the response. This means we can remove the need for a HTTP request and improve performance.

<?php

namespace App\Http\Resources;

use Illuminate\Support\Facades\Http;
use TiMacDonald\JsonApi\JsonApiResource;

class UserResource extends JsonApiResource
{
    /**
     * The available attributes.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array<string, mixed>
     */
    public function toAttributes($request)
    {
        return [
            /* ... */
            'avatar' => fn () => Http::get('https://avatar.example.com', [
                'email' => $this->email,
            ])->body(),
        ];
    }
}

Minimal attributes

Out of the box resources expose a maximal attribute payload when sparse fieldsets are not used i.e. all declared attributes in the resource are returned. If you prefer to instead make it that sparse fieldsets are required in order to retrieve any attributes, you may call the minimalAttributes() method in a service provider.

<?php

namespace App\Providers;

use TiMacDonald\JsonApi\JsonApiResource;

class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        JsonApiResource::minimalAttributes();

        // ...
    }
}

Relationships

//----- Everything that follows is WIP and should be ignored ------- //

Resource Identification

JSON:API docs: Identification

We have defined a sensible default for you so you can hit the ground running without having to fiddle with the small stuff.

The "id" and "type" of a resource is automatically resolved for you under-the-hood if you are using resources solely with Eloquent models.

"id" is resolved by calling the $model->getKey() method and the "type" is resolved by using a camel case of the model's table name, e.g. blog_posts becomes blogPosts.

You can customise how this works to support other types of objects and behaviours, but that will follow in the advanced usage section.

Nice. Well that was easy, so let's move onto...

Resource Relationships

JSON:API docs: Relationships

Just like we saw with attributes above, we can specify relationships that should be available on the resource by using the toRelationships(Request $request) method, however with relationships you should always wrap the values in a Closure.

<?php

class UserResource extends JsonApiResource
{
    public function toRelationships($request): array
    {
        return [
            'posts' => fn () => PostResource::collection($this->posts),
            'subscription' => fn () => SubscriptionResource::make($this->subscription),
            'profileImage' => fn () => optional($this->profileImage, fn (ProfileImage $profileImage) => ProfileImageResource::make($profileImage)),
            // if the relationship has been loaded and is null, can we not just return the resource still and have a nice default? That way you never have to handle any of this
            // optional noise?
            // also is there a usecase for returning a resource linkage right from here and not a full resource?
        ];
    }
}

Note: "links" and "meta" are not yet supported for relationships, but they are WIP. Resource linkage "meta" is not yet implemented. Let me know if you have a use-case you'd like to use it for!

Each Closure is only resolved when the relationship has been included by the client...

Including relationships

JSON:API docs: Inclusion of Related Resources

As previously mentioned, relationships are not included in the response unless the calling client requests them. To do this, the calling client needs to "include" them by utilising the include query parameter.

# Include the posts...
/api/users/8?include=posts

# Include the subscription...
/api/users/8?include=subscription

# Include both...
/api/users/8?include=posts,subscription

Resource Links

JSON:API docs: Links

To provide links for a resource, you can implement the toLinks($request) method...

<?php

use TiMacDonald\JsonApi\Link;

class UserResource extends JsonApiResource
{
    public function toLinks($request): array
    {
        return [
            Link::self(route('users.show', $this->resource)),
            'related' => 'https://example.com/related'
        ];
    }
}

Resource Meta

JSON:API docs: Meta

To provide meta information for a resource, you can implement the toMeta($request) method...

<?php

class UserResource extends JsonApiResource
{
    public function toMeta($request): array
    {
        return [
            'resourceDeprecated' => true,
        ];
    }
}

Refactoring to the JSON:API standard

If you have an existing API that utilises Laravel's JsonApiResource or other values that you would like to migrate over to the JSON:API standard via this package, it might be a big job. For this reason, we've enabled you to migrate piece by piece so you can slowly refactor your API.

From a relationship Closure you can return anything. If what you return is not a JsonApiResource or JsonApiResourceCollection, then the value will be "inlined" in the relationships object.

<?php

class UserResource extends JsonApiResource
{
    public function toRelationships($request): array
    {
        return [
            'nonJsonApiResource' => fn (): JsonResource => LicenseResource::make($this->license),
        ];
    }
}

Here is what that response might look like. Notice that the resource is "inlined" and is not moved out to the "included" section of the payload.

{
    "data": {
        "id": "1",
        "type": "users",
        "attributes": {},
        "relationships": {
            "nonJsonApiResource": {
                "id": "5",
                "key": "4h29kaKlWja)99ja72kafj&&jalkfh",
                "created_at": "2020-01-04 12:44:12"
            }
        },
        "meta": {},
        "links": {}
    },
    "included": []
}

Advanced usage

Resource Identification

Customising the resource "id"

You can customise the resolution of the id by specifying an id resolver in your service provider.

<?php

class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        JsonApiResource::resolveIdUsing(function (mixed $resource, Request $request): string {
            // your custom resolution logic...
        });
    }
}

Although it is not recommended, you can also override the toId(Request $request): string method on a resource by resource basis.

Customising the resource "type"

You can customise the resolution of the type by specifying a type resolver in your service provider.

<?php

class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        JsonApiResource::resolveTypeUsing(function (mixed $resource, Request $request): string {
            // your custom resolution logic...
        });
    }
}

Although it is not recommended, you can also override the toType(Request $request): string method on a resource by resource basis.

Resource Relationships

JSON:API docs: Inclusion of Related Resources

Relationships can be resolved deeply and also multiple relationship paths can be included. Of course you should be careful about n+1 issues, which is why we recommend using this package in conjunction with Spatie's Query Builder.

# Including deeply nested relationships
/api/posts/8?include=author.comments

# Including multiple relationship paths
/api/posts/8?include=comments,author.comments
  • Using "whenLoaded is an anti-pattern"

Credits

And a special (vegi) thanks to Caneco for the logo ✨

v1 todo

  • Server implementation rethink.
  • Rethink naming of objects and properties
  • Guess relationship class for relationships.
  • Support mapping $attributes values to different keys.
  • Support dot notation of both the key and value of $attributes.
  • Camel case everything
  • Allow resources to specify their JsonResource class.