Setting Up API Call Limits In Laravel - Part 2

Setting Up API Call Limits In Laravel - Part 2

Building a custom middleware to limit requests over a certain amount of time

In this second part of my API request limiting series, I'll dive into building a custom middleware for tracking user's requests to our API endpoints and setting limits according to the user's privileges. As a reminder to the previous part, we're building a public API for our users to fetch data, these users are split into various subscription tiers and for some tiers, we need to limit their access to the API over time. Now that we're all caught up, let's get started.

How does this work

For this to work properly, we're going to need to take into account three parts of our application - the API, the database and the middleware. This method is going to be slightly different than the one used in the previous article, as we are going to store information about the user's request to the database, rather than doing the limiting under the hood using a rate limiter. This way, we'll have more information about the request for future use, as you'll see at the end of the article.

Setting up the database

First of all, we need to set up a database table and Laravel model for our requests. We'll achieve this with one artisan command.

php artisan make:model Request --migration

This will create the Request model and the create_requests_table migration. In my database, I have columns for request id, API token via which a given endpoint was called, the id of the user that called that endpoint, date and time of the request and the URI the user called. The token and the user id reference the same row in the users table, so the second one is kinda redundant, I didn't want to address this problem when I was creating the API though, so we'll go with what we've got. The migration function looks like this:

    public function up()
    {
        Schema::create('requests', function (Blueprint $table) {
            $table->id();
            $table->string('token');
            $table->integer('user_id');
            $table->datetime('date');
            $table->string('content');
            $table->foreign('user_id')
                       ->references('id')
                       ->on('users');
            $table->foreign('token')
                       ->references('api_token')
                       ->on('users');
        });
    }

For the model, it is pretty straightforward. We're disabling timestamps because the rows in the table will not be updated and we've already supplied the request datetime in the date column. We're also making every column except id fillable.

// app/Models/Request.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Request extends Model
{

    public $timestamps = false;

    protected $fillable = [
        'token',
        'user_id',
        'date',
        'content'
    ];
}

Setting up the middleware

Now that we have the database layer set up, we can go ahead and create the middleware. We're going to be referencing the User and Request models as the request is tied to a user who is calling the API via their API token. As in the previous part, we are using bearer tokens to authenticate users. To continue the subscription-based public API model we've discussed in the last article, we'll set request limits to certain groups of users. This time, free-tier users are limited to one request to the API per day, subscribed users can make unlimited number of requests. Let's see the middleware first:

// app/Http/Middleware/AccessLevel.php

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use App\Models\User;
use Illuminate\Support\Carbon;
use App\Models\Request as ApiRequest;

class AccessLevel
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle(Request $request, Closure $next)
    {

        $token = $request->bearerToken();
        if (User::where('api_token', $token)->exists()) {
            $user = User::where('api_token', $token)->first();
        } else {
            return response()
                ->json(['error' => 'Bad token'], 401);
        }

        if ($user->access == 0) {
            if ($this->checkLimit($token, today())) {
                return response()
                ->json(['error' => 'Daily call limit exceeded'], 429);
            }
        }
        ApiRequest::create([
            'token' => $token,
            'user_id' => $user->id,
            'date' => Carbon::now(),
            'content' => $request->url()
        ]);
        return $next($request);

    }

    private function checkLimit($token, $date)
    {

        if (ApiRequest::where('token', $token)
            ->where('date', 'like', $date->format('Y-m-d') . '%')
            ->exists()) {
                return true;
            } else {
                return false;
        }
    }
}

There is much going on in this middleware, let's digest it a little bit. The request goes to a given API route and the middleware fires up. From the request, a bearer token is extracted and checked against the users table. If a user with given token exists, the middleware registers the user into a variable and continues to do other checks, however if a user with this token doesn't exist, the middleware returns a 401. When this check is complete, the middleware checks the user's Access Level. If the user is a free-tier user, the checkLimit() method fires up and checks if a user has made an API call today. The check is of course done via the requests table. If the user has made a request today, the middleware returns a Too many requests error.

If no error was triggered, the request is stored in the database for future reference (either for the middleware or for statistical reasons).

As usual, we need to register this new middleware in app/Http/Kernel. You should register it in both the api middleware group and $routeMiddleware. If you do not want to have this restriction on all of your API routes, register it only in the $routeMiddleware array. I have mine registered like this:

// app/Http/Kernel.php

<?php

...
protected $middlewareGroups = [
    'web' => [
        ...
    ],
    'api' => [
        \App\Http\Middleware\ForceJson::class,
        \App\Http\Middleware\AccessLevel::class,
        'throttle:api',
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],
];

protected $routeMiddleware = [
    ...
    'access.level' => \App\Http\Middleware\AccessLevel::class,
];

If you choose not to register the middleware in the api middleware group, you will also need to register it in your routes/api.php file. Let's test the middleware to make sure it works...

Test for a free-tier user

As you can see in the picture above, we've only managed to get one request through before getting the Too many requests error when we supplied a non-subscribed user's token, thus fulfilling our goal.

Using the collected data

As I've used this technique to limit API requests for users on my own site - OnlyResultz, I wanted to display relevant API usage metrics to my users via the user's dashboard. For that use case, I displayed only daily, monthly and lifetime requests, but more ways to use the collected data exist, for your users and your application's administrators alike. Here is a simple example of implementing these metrics:

And the end result looks like this:

Example metrics

In the blade file above, we are supplying the $requests variable, which uses the Request model to fetch relevant requests for the user. The individual metrics are processed inside the blade file and served to the user in a readable form.

Wrapping up

This has been it for the Setting Up API Call Limits In Laravel series. Check out the previous article in the series if you haven't yet. In the other article, I explain how to use Laravel's built-in Rate Limiter to achieve similar result, as we've seen in this article.

Until next time.

Cover image by Mohamed Hassan from Pixabay

Did you find this article valuable?

Support Dominik Zarsky by becoming a sponsor. Any amount is appreciated!