SDKs, The Laravel Way: Part II
With some of the basics from part I out of the way, now we can move on to extended topics like pagination, relationships, rate limiting, etc.
You know that little toolkit that comes with your new Swedish-made furniture? Some come in plastic bags. Others with just one tool. There's no One Toolkit To Rule Them All when it comes to the task of building furniture. The same is true for SDKs.
Creating, updating and deleting records
Part I began laying a CRUD foundation. Maybe you've thought about what the rest might look like? It's probably the easiest part of this endeavor.
The ConnectWise API (that's what my SDK was for) follows this pattern for all CRUD operations.
POST
/tickets - Create a new ticketGET
/tickets - List of all ticketsGET
/tickets/:id - A specific ticketPUT
/tickets/:id - Update a ticketDELETE
/tickets/:id - Delete a ticket
With these endpoint patterns in mind, finishing the rest of the CRUD methods should be pretty straight forward.
class RequestBuilder
{
public function create(array $attributes)
{
return tap(new $model($attributes), function ($instance) {
$instance->save();
});
}
public function get(): Collection
{
return new Collection(
$this->request->get($this->model->url())->json()
);
}
public function find(int|string $id): ?Model
{
return new static(
$this->request->get("{$this->url()}/{$id}")->json()
);
}
public function update(array $attributes): bool
{
$response = $this->request->put(
"{$this->url()}/{$id}",
$attributes
)->json();
return $response->successful();
}
public function insert(array $attributes): bool
{
$response = $this->request->post(
$this->url(),
$attributes
)->json();
return $response->successful();
}
}
abstract class Model
{
public function __construct(
public array $attributes = [],
) {
}
public function save(): bool
{
return $this->exists
? $this->update($this->attributes)
: $this->insert($this->attributes);
}
}
The create()
and save()
methods are just convenience methods, just as they are in Eloquent.
Ticket::create([
'summary' => 'Printer not working',
]);
// or
(new Ticket([
'summary' => 'Printer not working',
]))->save();
Common functions
Brick by brick, each new method builds on what has come before.
class RequestBuilder
{
public function first(): ?Model
{
return $this->get()->first();
}
public function exists(): bool
{
return $this->count() > 0;
}
public function missing(): bool
{
return ! $this->exists();
}
}
Ticket::open()->first();
if (Ticket::escalated()->exists()) {
// send email
}
if (Ticket::open()->missing()) {
// take the day off?
}
Pagination
Laravel's core classes are designed to be modular and extensible, allowing you to extend or override their behavior as needed. Here the possibility exists to reuse a couple of these battle-hardened classes. We're going to grab Laravel's Illuminate\Pagination\Paginator
and Illuminate\Pagination\LengthAwarePaginator
classes and wire them up to our RequestBuilder
.
Paginator
needs a list of things, the number of things to show, and the current page. Not too bad in the way of dependencies.
use Illuminate\Pagination\Paginator;
class RequestBuilder
{
public function page($page): static
{
$this->request->withOptions([
'query' => [
'page' => $page,
],
]);
return $this;
}
public function pageSize($size): static
{
$this->request->withOptions([
'query' => [
'pageSize' => $size,
],
]);
return $this;
}
public function simplePaginate($pageSize = 25, $page = null): Paginator
{
$this->page($page)->pageSize($pageSize);
return new Paginator($this->get(), $pageSize, $page);
}
}
Length aware pagination
Before we can implement length aware pagination, we'll need some way to get a count of the total number of records. That's the only difference between these two pagination implementations.
use Illuminate\Pagination\LengthAwarePaginator;
class RequestBuilder
{
// public function page($page): static
// public function pageSize($size): static
// public function simplePaginate($pageSize, $page): Paginator
public function count(): int
{
return (int) data_get($this->request->get("{$this->url()}/count", $this->options), 'count', 0);
}
public function paginate($pageSize = 25, $page = null): LengthAwarePaginator
{
$total = $this->count();
$results = $total ? $this->page($page)->pageSize($pageSize)->get() : collect();
return new LengthAwarePaginator($results, $total, $pageSize, $page, []);
}
}
Chunking
Using the chunk
method on the query builder, you can specify a callback function to process each chunk of data as it is retrieved from the database. This allows you to process the data in smaller, more manageable pieces, rather than loading everything into memory at once.
class RequestBuilder
{
// public function page($page): static
// public function pageSize($size): static
// public function paginate($pageSize, $page): LengthAwarePaginator
// public function simplePaginate($pageSize, $page): Paginator
public function chunk($count, callable $callback, $column = null): bool
{
$page = 1;
$model = $this->newModel();
$this->orderBy($column ?: $model->getKeyName());
do {
$clone = clone $this;
$results = $clone->pageSize($count)
->page($page)
->get();
$countResults = $results->count();
if ($countResults == 0) {
break;
}
if ($callback($results, $page) === false) {
return false;
}
unset($results);
$page++;
} while ($countResults == $count);
return true;
}
}
chunkById()
method.Using the queue
Similar to chunking, processing pagination inside of the queue can help you process large amounts of data more efficiently by breaking it down into memory-efficient jobs. This is especially helpful when dealing with large amounts of data that may be slow to process.
A couple of weeks ago we looked at some key benefits and methods for handling pagination within a queue.
Relationships
Building a relationship between API requests are quite a bit different from a query. So we'll have to stray a little bit from Eloquent here. A lot of the time I see them represented by a complete url to fetch the related information.
So you can design all your relationship methods to return a RequestBuilder
where the url is that of the related data.
abstract class Model
{
use ForwardsCalls;
abstract public function url(): string;
public function belongsTo($model, $path): RequestBuilder
{
return $this->newRequest(
new $model
)->url($this->getAttribute($path));
}
// newRequest()
// __call()
// __callStatic()
}
Once all your relationship methods are in place, you can begin using them by passing the path of the value using Laravel's dot notation.
class Ticket extends Model
{
// public function url(): string
public function board(): RequestBuilder
{
return $this->belongsTo(Board::class, 'board._info.board_href');
}
}
Utilizing our new relationships looks quite familiar.
Ticket::first()->board()->first();
Calling relations like properties
Ticket::first()->board;
// instead of
Ticket::first()->board()->first();
With the use of another magic method this becomes pretty simple.
class Ticket extends Model
{
// public function board(): RequestBuilder
public function __get($name)
{
if (method_exists($this, $name)) {
return $this->getRelationValue($name);
}
}
public function getRelationValue($key): Collection
{
if (array_key_exists($key, $this->relations)) {
return $this->relations[$key];
}
return tap($this->$method()->get(), function ($results) use ($method) {
$this->relations[$relation] = $results;
});
}
}
Handling token expiration
ConnectWise doesn't use expiring tokens, so this isn't really specific to them. For the ones that do, handling these expirations with retries may be the way to go.
public function boot()
{
Http::macro('connectwise', function () {
$settings = config('services.connectwise');
return Http::baseUrl($settings['url'])
->withToken($this->getToken())
->retry(1, 0, function ($exception, $request) {
if ($exception instanceof TokenExpiredException) {
$request->withToken($this->getNewToken());
return true;
}
return false;
})
->asJson()
->acceptJson()
->withHeaders(['clientId' => $settings['client_id']])
->throw();
});
}
This isn't always possible. For some services, the user will be redirected to a sign in page to get a new token. It is going to be helpful for those that behave more like an integration or a client to the server.
Rate limiting
Like handling token expiration, rate limiting could be handled in the Http macro as well. If that's the only middleware you need, that might be the simplest option.
Alternatively, you could add it to the base Model
class and include any default middlewares there. Optionally overriding these in your models.
abstract class Model
{
public function middleware(): array
{
return [];
}
}
class Ticket extend Model
{
public function middleware(): array
{
return [
new ThrottleRequests(
key: 'tickets',
maxAttempts: 30,
),
];
}
}
ThrottlesRequests
class might look like.If you go this route, the RequestBuilder
CRUD methods would need to be updated to apply the middleware to the PendingRequest
before sending the request.
Conclusion
There is more than one way to write an SDK, and the approach that is best for a particular project or organization will depend on its specific requirements and goals. There's no One SDK To Rule Them All.
I enjoy making SDKs in this way because of the investment:
Wiping the dust off an old feature weeks, months, or even years later, I want to reduce the amount of head scratching until I understand what it is I'm looking at. Having used the Laravel framework for years, I find its core APIs easy to come back to. So, designing things in such a way that feels native to Laravel is an investment into my future self.
Another added benefit is, when the time comes to introducing it to a new team member, one who is already familiar with Eloquent, they just immediately know how to use it. Providing a consistent and predictable API for them to use enables them to become productive quickly without the need for extensive hand holding.