SDKs, The Laravel Way

Each call to the builder will modify the underlying pending request. Every modification, another link in the chain, that will be sent, finally, with get().

SDKs, The Laravel Way

The joy I get when using a beautifully-crafted API cannot be understated. One that is approachable for beginners, and can pack a punch for advanced engineers. Beautiful, simple, flexible. An elusive combination that all engineers strive for, but some never find.

💡
tldr; Recently I built an SDK for ConnectWise's API, taking inspiration from an Eloquent example.

It's an 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.

An Eloquent Example

How do you fetch stored data in Laravel? Eloquent. Much of the time (not always), an SDK is just an abstraction for fetching data from an API. Sometimes, it's just a simple wrapper around making HTTP requests. But what's the difference between fetching data from a database and fetching it from an API?

Anatomy of Eloquent call

If we were using Eloquent models talking to the application's database. Fetching data would look something like this:

// filtering
Ticket::where('status', 'open')->get();
Ticket::open()->get();

// selecting fields
Ticket::select(['id', 'summary'])->get();
Ticket::get(['id', 'summary']);

// sorting
Ticket::orderBy('created_at', 'desc')->get();
Ticket::latest()->get();

// eager loading
Ticket::with(['board', 'notes'])->get();

// all
Ticket::get();

We've got a Model, some methods that return a Builder object for further method chaining, and some methods that execute the query and return a Collection of results.

Why couldn't we, instead of building a query to be executed, build an HTTP Request to be sent?

With this comparison in mind, modeling our SDK to mimic Eloquent feels... right. It feels like swimming with the current. Feels, natural. Like breathing.

Getting tickets

The most basic call, requiring no filtering, no sorting, should look like this.

$tickets = Ticket::get();

Unless an error has occurred, we should get a Collection of tickets.

The Ticket Model

class Ticket
{
    public function get(): Collection
    {
        $settings = config('services.connectwise');

        $response = Http::baseUrl($settings['url'])
            ->withBasicAuth(
                "{$settings['company_id']}+{$settings['public_key']}",
                $settings['private_key'],
            )
            ->asJson()
            ->acceptJson()
            ->withHeaders(['clientId' => $settings['client_id']])
            ->throw()
            ->get($this->url());
        
        return new Collection($response->json());
    }

    public function url(): string
    {
        return '/tickets';
    }
}
app/ConnectWise/Models/Ticket.php

The get() method looks pretty intense, but it's only getting some connections settings, building the request, wrapping the response in a Collection. We're going to simplify this later.

💡
Don't be afraid to make this base request your own! If your API doesn't use JSON, or if it uses another form of authentication, make the necessary changes to the HTTP Client.

Connection Settings

To make our base request work we'll need to add some values to our services config.

return [

    // other services ...
    
    'connectwise' => [
        'url' => env('CONNECTWISE_URL'),
        'public_key' => env('CONNECTWISE_PUBLIC_KEY'),
        'private_key' => env('CONNECTWISE_PRIVATE_KEY'),
        'client_id' => env('CONNECTWISE_CLIENT_ID'),
    ],
    
];
config/services.php
💡
I've chosen configuration to store my API connection settings. These settings could be coming from anywhere; another model in a multi-tenant system, from a package like spatie/laravel-settings, up to you.

Allowing for static method calls

Notice the Ticket's get() method wasn't static? Of course you did. 😉

class Ticket
{
    // url()
    // get()

    public static function __callStatic($method, $parameters)
    {
        return (new static)->$method(...$parameters);
    }
}
app/ConnectWise/Models/Ticket.php

This is copied straight from Laravel's Model. And that's all you need. From the Ticket, you can now call any method on the Ticket.

Ticket::get();
Ticket::url();

// instead of

(new Ticket)->get();
(new Ticket)->url();

Filtering, Sorting, Limiting

In Eloquent, these concerns are all handled by a Builder object. This object is responsible for providing a fluent interface for updating its own properties and returning the instance to allow for method chaining.

class RequestBuilder
{
    public ?PendingRequest $request = null;
    
    public ?Model $model = null;
    
    public function __construct(Model $model) {
    	$this->model = $model;
    	$this->request = Http::connectwise();
    }

    // where()
    // whereIn()
    // whereBetween()
    // whereNull()
    // limit()
    // select()
    // ... and so on

    public function get(): Collection
    {
        $response = $this->request->get($this->model->url());

        return new Collection($response->json());
    }
}
app/ConnectWise/RequestBuilder.php

As you can see, this object acts as the glue between our HTTP PendingRequest and our Ticket model.

Accessing the builder from the model

One of the many brilliant things Eloquent does is provide easy access to the Builder object from the Model. By forwarding any calls to methods that do not exist on the model to a new builder.

class Ticket
{
    use ForwardsCalls;

    // url()

    public function newRequest(): RequestBuilder
    {
        return new RequestBuilder($this);
    }

    public function __call($method, $parameters)
    {
        return $this->forwardCallTo($this->newRequest(), $method, $parameters);
    }
}
app/ConnectWise/Models/Ticket.php
$tickets = Ticket::get();

// instead of

$tickets = (new RequestBuilder(new Ticket))->get();

Modifying the request

For the other building methods like where, whereIn, whereNull, whereBetween, orderBy, limit, select, and so on. Each call to the RequestBuilder will modify the underlying PendingRequest. Every modification, another link in the chain, that will will be sent, finally, with get().

public function where(string $field, $operator = null, mixed $value = null): static
{
    if (func_num_args() === 2) {
        $value = $operator;
        $operator = '=';
    }
    
    $options = $this->request->getOptions();

    $conditions = data_get($options, 'query.conditions');
    
    $conditions = ! empty($conditions) ? explode(' and ', $conditions) : [];
    
    $value = match (true) {
        $value instanceof Carbon => "[{$value->toIso8601String()}]",
        is_string($value) => "'{$value}'",
        is_array($value) => "'{$value[0]}'",
        is_bool($value) => $value ? 'true' : 'false',
        is_null($value) => 'null',
        default => $value,
    };
    
    $conditions[] = "{$field}{$operator}{$value}";
    
    $conditions = implode(' and ', $conditions);
    
    data_set($options, 'query.conditions', $conditions);
    
    $this->request->withOptions($options);

    return $this;
}
app/ConnectWise/RequestBuilder.php

Read the documentation for both the API you are making requests to and Laravel's HTTP Client. Get real intimate with those. Heck, go source-diving. I've never regretting it.

Preparing for more Models

We can move most of the Ticket methods to a base Model object so that other models can extend.

abstract class Model
{
    use ForwardsCalls;

    abstract public function url(): string;
    
    // get()
    // newRequest()
    // __call()
    // __callStatic()
}
app/ConnectWise/Models/Model.php
class Ticket extends Model
{
    public function url(): string
    {
        return '/tickets';
    }
}
app/ConnectWise/Models/Ticket.php

HTTP Macro

To make the construction of the RequestBuilder less verbose, we will create an HTTP macro.

public function boot()
{
    Http::macro('connectwise', function () {
        $settings = config('services.connectwise');
        
        return Http::baseUrl($settings['url'])
            ->withBasicAuth(
                "{$settings['company_id']}+{$settings['public_key']}",
                $settings['private_key'],
            )
            ->asJson()
            ->acceptJson()
            ->withHeaders(['clientId' => $settings['client_id']])
            ->throw();
    });
}
app/ConnectWise/ServiceProvider.php

Request scopes

I really like query scopes in Eloquent. Such a small thing. Makes the developer experience so much nicer though.

class Ticket extends Model
{
    // url()
    
    public function open(): RequestBuilder
    {
        return $this->where('status', 'open');
    }
}
Ticket::open()->get();

// instead of

Ticket::where('status', 'open')->get();

If there ever comes a new model that needs the open() scope we could wrap it up in a HasStatus trait.

Other considerations

For the sake of brevity, I've chosen to omit certian topics like defining relationships. I would tackle those considerations that same way. How does Eloquent handle this? Even if the answer is "it doesn't", it's still a great place to start.

What do you think?

I really like this way of structuring SDKs and have used this methodology successfully for years. But I'd like to know what you think. Let me know.

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.
Continue reading part II