shabushabu / abseil
Taking the pain out of creating a JSON:API in your Laravel app
Fund package maintenance!
boris-glumpler
Scustom
Requires
- php: ^7.4
- illuminate/database: ^7.0
- illuminate/http: ^7.0
- illuminate/pagination: ^7.0
- illuminate/queue: ^7.0
- illuminate/routing: ^7.0
- illuminate/support: ^7.0
- nesbot/carbon: ^2.0
- ramsey/uuid: ^4.0
- shabushabu/harness: ^0.2.0
- spatie/laravel-query-builder: ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^2.16
- nunomaduro/collision: ^4.2
- orchestra/testbench: ^5.0
- phpunit/phpunit: ^9.0
- symfony/var-dumper: ^5.0
This package is auto-updated.
Last update: 2022-12-29 03:40:21 UTC
README
Abseil
Taking some of the pain out of creating a JSON:API in your Laravel app
ToDo
- Allow ModelQuery to query by fields other than uuid, eg by slug
- Enjoy rock star status and live the good life
Installation
You will eventually be able to install the package via composer (‼️ once it's been published to Packagist...):
$ composer require shabushabu/abseil
Morph Map
If you don't use a morph map yet, now's the time to get your foot in the door. Just chuck all your models in there so Abseil can do it's magic.
Keep the keys the same as your route parameters, btw. Strictly speaking Abseil only requires the MORPH_MAP
constant, but you might as well go all in. Bet on that rope to hold your weight...
namespace App\Providers; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\ServiceProvider; use [...]; class AppServiceProvider extends ServiceProvider { public const MORPH_MAP = [ 'category' => Category::class, 'page' => Page::class, 'user' => User::class, ]; public function boot(): void { Relation::morphMap(self::MORPH_MAP); } }
The MORPH_MAP
constant will then be used to add uuid patterns for your route model binding (disable in config if you use auto-incrementing ids).
It'll also allow you to use the fantastic query builder package from Spatie for your single models and not just for collections.
Class Constants
Abseil expects certain class constants to be present on your models. We've already talked about MORPH_MAP
, but there are also JSON_TYPE
and ROUTE_PARAM
. These should be set on each model.
use ShabuShabu\Abseil\Model; class Category extends Model { public const JSON_TYPE = 'categories'; public const ROUTE_PARAM = 'category'; }
ShabuShabu\Abseil\Model
is there to make things easier for you, but if you use auto-incrementing ids then you will have to create your own base model using the Abseil model as a guide.
Usage
Eloquent Resources and the JSON:API standard have a bit of a rocky relationship. "We're just way too different", JSON:API might say. "That upstart of a spec is just too opinionated", Laravel might retort. They tolerate each other and get along for the most part but won't ever be best mates, they feel. Abseil is here to make them rethink their relationship.
Controllers
Most Abseil controllers will look something like this. Just make sure that your controller extends the Abseil controller and you're all set. Please note, that Abseil controllers expect ShabuShabu Harness requests for any save operation.
namespace App\Http\Controllers; use App\Http\Requests\PageRequest; use App\Http\Resources\Page as PageResponse; use App\Page; use Illuminate\Http\{Request, Response}; use ShabuShabu\Abseil\Http\Resources\Collection; use ShabuShabu\Abseil\Http\Controller; class PageController extends Controller { public function index(Request $request): Collection { return $this->resourceCollection(Page::class, $request); } public function store(PageRequest $request): Response { return $this->createResource($request, Page::class); } public function show(Request $request, Page $page): PageResponse { return $this->showResource($request, $page); } public function update(PageRequest $request, Page $page): Response { return $this->updateResource($request, $page); } public function destroy(Page $page): Response { return $this->deleteResource($page); } public function restore(Page $page): Response { return $this->restoreResource($page); } }
Events
Abseil throws a number of events that you can hook into. Here's a full list:
- ResourceCreated
- ResourceUpdated
- ResourceDeleted
- ResourceRestored
- ResourceRelationshipSaved
The names are kinda self-explanatory. The payload for each event is always the model in question.
ResourceRelationshipSaved
is fired once for each relationship model that has been saved.
Relationships
Abseil makes it easier to save any relationships via a JSON:API POST
or PUT
request.
It does this by looping through the data.relationships
array, figuring out which relation it needs and then calling a sync{relationship-name}
method on the model.
Here's an example request payload:
{ "data": { "id": "904754f0-7faa-4872-b7b8-2e2556d7a7bc", "type": "pages", "attributes": { "title": "Some title", "content": "Lorem what?" }, "relationships": { "category": { "data": { "type": "categories", "id": "9041eabb-932a-4d47-a767-6c799873354a" } } } } }
Abseil will then call the following method so you can save the category as you see fit:
$result = $page->syncCategory( Category::find('9041eabb-932a-4d47-a767-6c799873354a') );
Abseil will throw an error if that method does not exist, so it's your responsibility to make sure it's there when you allow saving relationships via ShabuShabu Harness requests.
Staying with this example, the Page::syncCategory
method could be as easy as the following:
public function syncCategory(Category $category): self { $this->category()->associate($category)->save(); return $this; }
Resources
Resources have always been my biggest stumbling block when trying to create a valid JSON:API. With Abseil, though, the following is possible:
Note that we're only specifying the data.attributes
here. Anything else, like relationships, will be handled for you.
namespace App\Http\Resources; use ShabuShabu\Abseil\Http\Resources\Resource; class Page extends Resource { public function resourceAttributes($request): array { return [ 'title' => (string)$this->title, 'content' => (string)$this->content, 'createdAt' => $this->date($this->created_at), 'updatedAt' => $this->date($this->updated_at), 'deletedAt' => $this->date($this->deleted_at), ]; } }
If, for example, there is a user relationship and it was specified via the include
query parameter, then Abseil will load that relationship automatically for you and attach it to the response.
ShabuShabu Belay is the perfect counter part for Abseil and will handle the JS side of things. Great if you want to create a client app for your API using Vue or Nuxt.
Resource Collections
Collection resources do not need to be specifically created, although you can if you want to. Abseil will use its own collection class by default for any index
responses.
One thing to note here is that the default Laravel pagination data is being transformed to camel-case:
{ [...] "meta": { "pagination": { "currentPage": 1, "from": 1, "lastPage": 1, "path": "https:\/\/awesome-api.com\/pages", "perPage": 20, "to": 7, "total": 7 } }, [...] }
All models should also implement the Queryable
interface. It only requires a single method: modifyPagedQuery
.
/** * Modify the query * * @param \Spatie\QueryBuilder\QueryBuilder $query * @param \Illuminate\Http\Request $request * @return \Spatie\QueryBuilder\QueryBuilder */ public static function modifyPagedQuery(QueryBuilder $query, Request $request): QueryBuilder;
Here you can then configure the query builder, add sorts, includes, filters, etc.
Routing
The only thing Abseil expects form your application as far as routing is concerned is that you name your single GET
routes according to a certain convention:
'{JSON_TYPE}.show'
That way, Abseil can automatically create the links section for you.
Exceptions
Abseil ships with a couple exceptions to transform errors into valid JSON:API format. To use them, just add something like the following to your exception handler:
public function render($request, Throwable $exception): Response { // ... if ($exception instanceof \Illuminate\Validation\ValidationException) { $exception = \ShabuShabu\Abseil\Exceptions\ValidationException::withMessages($exception->errors()); } if ($exception instanceof \Spatie\QueryBuilder\Exceptions\InvalidQuery) { $exception = \ShabuShabu\Abseil\Exceptions\InvalidQueryException::from($exception); } // ... return parent::render($request, $exception); }
Middleware
There's a middleware that you can use for your JSON:API enabled routes. It's already registered for you and aliased to media.type
.
It accepts an optional parameter for the header name. The default is conrtent-type
, but can also be used for accept
.
If using the class directly is more your cup of tea, you can find it here: \ShabuShabu\Abseil\Http\Middleware\JsonApiMediaType
This middleware checks if the Content-Type
header matches application/vnd.api+json
and throws an UnsupportedMediaTypeHttpException
if it doesn't.
Policies
Abseil will guess the policy names for you. By default it will assume that your policies are located here: App\Policies
.
It is also assumed that policy naming follows this convention: {className}Policy
, so for example PagePolicy
.
This functionality can be completely disabled.
Testing
Abseil actually comes with a tiny and fairly useless test app. Have a look at it to see how all the pieces come together!
$ composer test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security
If you discover any security related issues, please email boris@shabushabu.eu instead of using the issue tracker.
‼️ Caveats
Abseil is still young and while it is tested, there will probs be bugs. I will try to iron them out as I find them, but until there's a v1 release, expect things to go 💥.
Credits
- All Contributors
- BTT, aka Boris Travelled Today, where Abseil was extracted from
- Ivan Boyko [cc] for the abseil icon
License
The MIT License (MIT). Please see License File for more information.