# Custom Actions

# Introduction

As well as registering routes defined by the JSON:API specification, our implementation also allows you to register custom resource actions. This allows you to implement capabilities that are not defined by the specification.

In this chapter, we will demonstrate how to do this using two actions:

  • A purge action, that allows an adminstrator to delete all posts resources.
  • A publish action, that allows the author of a specific posts resource to publish it.

# Routing

# Registering Actions

To register custom actions, use the actions() method when registering resource routes. For example, we can add our purge and publish actions to our posts resource as follows:

$server->resource('posts')->actions('-actions', function ($actions) {
    $actions->delete('purge');
    $actions->withId()->post('publish');
});

This would register the following routes:

Verb URI Action Route Name
DELETE /posts/-actions/purge purge posts.purge
POST /posts/{post}/-actions/publish publish posts.publish

TIP

Notice that we used the withId() method to indicate that the publish action relates to a specific resource.

The $actions helper supports all the typical HTTP verbs:

  • get
  • post
  • patch
  • put
  • delete
  • options

# Actions without a URL Prefix

WARNING

When adding custom actions, we recommend thinking carefully about the naming of the URLs for the actions. As additions may be made to the JSON:API specification in the future, it is important to choose a URL that will avoid any potential future conflicts with the specification.

For this reason, we recommend using a prefix. We also recommend choosing one like /-actions instead of /actions. This will help prevent any future conflicts with additions to the specification.

As the above example shows, the actions() method was provided with a URL prefix and a callback. The prefix is optional, for example:

$server->resource('posts')->actions(function ($actions) {
    $actions->delete('purge');
    $actions->withId()->post('publish');
});

Would register the following routes:

Verb URI Action Route Name
DELETE /posts/purge purge posts.purge
POST /posts/{post}/publish publish posts.publish

When adding actions without a URL prefix, you need to ensure you avoid colliding with any of the JSON:API routes that are registered for a resource. Typically this means ensuring your action does not match the ID pattern for a resource.

This is best illustrated with an example. Say for example we wanted to register a me action for the users resource. This would return the current signed-in user, and the routing would look like this:

$server->resource('users')->actions(function ($actions) {
    $actions->get('me');
});

In this example, the following routes will exist:

  • GET /users/{user}
  • GET /users/me

This will work if the users resource id pattern does not match for me, e.g. if the pattern was \d+. However, if me did match the pattern - for example if it were [a-zA-Z0-9]+, then the custom me action will not match. Instead the implementation will attempt to find a users resource with an id of me.

Registering actions that conflict with the default JSON:API routes are not supported. If you are registering actions without a URL prefix, you must ensure that you avoid registering actions that collide.

# Controller Action

By default we camel-case the action URI to get the controller method name. For example:

$actions->post('bulk-import');

Will expect the controller method name to be bulkImport. If you need to use a different controller method name, pass it as the second argument:

$actions->post('bulk-import', 'import');

# Route Name

By default we use the controller method name as the route name. So as shown in the examples above, the route names were posts.purge and posts.publish.

All the action methods return the Laravel route object, so if you want to use a different route name, use the name() method:

// route name will be posts.deleteAll
$actions->delete('purge')->name('deleteAll');

# Authorization

Typically when writing custom actions, you can authorize the request using Controller authorization helpers. (opens new window) This is shown in both the examples below.

There is however an alternative. If you are injecting a query parameter class into your controller action, you can put the authorization logic in the query class.

The reason you might do this is if you want the action to support JSON:API query parameters - e.g. enabling the client to send an include parameter when using your custom action.

For example, if we were injecting the PostQuery class into our custom controller action, we could add our authorization logic to that class:

namespace App\JsonApi\V1\Posts;

use LaravelJsonApi\Laravel\Http\Requests\ResourceQuery;
use LaravelJsonApi\Validation\Rule as JsonApiRule;

class PostQuery extends ResourceQuery
{

    /**
     * Authorize the request.
     *
     * @return bool|null
     */
    public function authorize(): ?bool
    {
        if ($this->is('*-actions*')) {
            return (bool) optional($this->user())->can(
                'update',
                $this->model()
            );
        }

        return null;
    }

    // ...other methods
}

TIP

As described in Form Request Authorization, returning null from the authorize() method tells the form request to run the default JSON:API authorization.

# Resources Example

In this example, we want to add a route that will allow an administrator to delete all posts resources.

Firstly we will register the action in our routing:

JsonApiRoute::server('v1')
    ->prefix('v1')
    ->resources(function ($server) {
        $server->resource('posts', PostController::class)
            ->relationships(function ($relationships) {
                $relationships->hasOne('author')->readOnly();
                $relationships->hasMany('tags');
            })->actions('-actions', function ($actions) {
                $actions->delete('purge');
            });

        // ...other resources
    });

Next we need to write our controller action. This is expected to be on the resource's PostController:

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use LaravelJsonApi\Laravel\Http\Controllers\Actions;

class PostController extends Controller
{

    // ...

    /**
     * @return \Illuminate\Http\Response
     * @throws \Illuminate\Auth\Access\AuthorizationException
     */
    public function purge(): \Illuminate\Http\Response
    {
        $this->authorize('deleteAll', Post::class);

        Post::query()->delete();

        return response('', 204);
    }

}

As we are authorizing the request using the deleteAll ability, we will also need to add that to our PostPolicy:

namespace App\Policies;

use App\Models\User;

class PostPolicy
{

    // ... other methods

    /**
     * @param User|null $user
     * @return bool
     */
    public function deleteAll(?User $user): bool
    {
        return $user && $user->isAdmin();
    }

}

Once this is done, the following HTTP request by an administrator will delete all the posts resources (bearing in mind that how you authenticate the request will depend on your authentication implementation):

DELETE /api/v1/posts/-actions/purge HTTP/1.1
Accept: application/vnd.api+json
Authorization: Bearer <API_TOKEN>

Which will receive the following response if successful:

HTTP/1.1 204 No Content

# Resource Example

In this example, we want to add a route that must be hit for an author to publish a specific posts resource.

Firstly we will register the action in our routing, using the withId() method to indicate that the URL relates to a specific resource:

JsonApiRoute::server('v1')
    ->prefix('v1')
    ->resources(function ($server) {
        $server->resource('posts', PostController::class)
            ->relationships(function ($relationships) {
                $relationships->hasOne('author')->readOnly();
                $relationships->hasMany('tags');
            })->actions('-actions', function ($actions) {
                $actions->withId()->post('publish');
            });

        // ...other resources
    });

Next we need to write our controller action. This is expected to be on the resource's PostController:

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Events\PostPublished;
use App\JsonApi\V1\Posts\PostQuery;
use App\JsonApi\V1\Posts\PostSchema;
use App\Models\Post;
use LaravelJsonApi\Laravel\Http\Controllers\Actions;

class PostController extends Controller
{

    // ...

    /**
     * Publish a post.
     *
     * @param PostSchema $schema
     * @param PostQuery $query
     * @param Post $post
     * @return Responsable
     */
    public function publish(
        PostSchema $schema,
        PostQuery $query,
        Post $post
    ): Responsable
    {
        $this->authorize('update', $post);

        abort_if($post->published_at, 403, 'Post is already published.');

        $post->update(['published_at' => now()]);

        PostPublished::dispatch($post);

        $model = $schema
            ->repository()
            ->queryOne($post)
            ->withRequest($query)
            ->first();

        return new DataResponse($model);
    }
}

As we want to support JSON:API query parameters on this action, we inject both the PostSchema and the PostQuery classes. The schema allows us to ensure the model is configured correctly if the client has used any JSON:API query parameters, while the PostQuery validates any parameters provided by the client.

As we are authorizing the request using the update ability, we will also need to add that to our PostPolicy:

namespace App\Policies;

use App\Models\Post;
use App\Models\User;

class PostPolicy
{

    // ... other methods

    /**
     * @param User|null $user
     * @return bool
     */
    public function update(?User $user, Post $post): bool
    {
        return $user && $user->is($post->author);
    }

}

Once this is done, the following HTTP request by the post's author will publish the post (bearing in mind that how you authenticate the request will depend on your authentication implementation):

POST /api/v1/posts/1/-actions/publish?include=tags HTTP/1.1
Accept: application/vnd.api+json
Authorization: Bearer <API_TOKEN>

Which will receive the following response if successful:

HTTP/1.1 200 OK
Content-Type: application/vnd.api+json

{
  "data": {
    "type": "posts",
    "id": "1",
    "attributes": {
      "content": "...",
      "publishedAt": "2021-01-04T15:37:00.000000Z",
      "title": "Hello World!"
    },
    "relationships": {
      "author": {
        "links": {
          "related": "http://localhost/api/v1/posts/123/author",
          "self": "http://localhost/api/v1/posts/123/relationships/author"
        }
      },
      "tags": {
        "data": [
          {
            "type": "tags",
            "id": "4"
          }
        ],
        "links": {
          "related": "http://localhost/api/v1/posts/123/tags",
          "self": "http://localhost/api/v1/posts/123/relationships/tags"
        }
      }
    },
    "links": {
      "self": "http://localhost/api/v1/posts/123"
    }
  },
  "included": [
    {
      "type": "tags",
      "id": "4",
      "attributes": {
        "displayName": "Laravel"
      }
    }
  ]
}
Last Updated: 7/20/2023, 6:47:24 PM