Giter Site home page Giter Site logo

sammyjo20 / saloon Goto Github PK

View Code? Open in Web Editor NEW
1.9K 18.0 96.0 3.2 MB

🀠 Build beautiful API integrations and SDKs with Saloon

Home Page: https://docs.saloon.dev

License: MIT License

PHP 100.00%
laravel api api-wrapper php sdk guzzle-wrapper api-integrations framework-agnostic saloon

saloon's Introduction

Logo with brown western bar doors with western scene in background and text that says: Saloon, Your Lone Star of your API integrations

Saloon – Your Lone Star of your API integrations

A PHP package that helps you build beautiful API integrations and SDKs 🀠

Build Status Downloads

Click here to read the documentation

Introduction

Saloon is a PHP library that gives you the tools to build beautifully simple API integrations and SDKs. Saloon moves your API requests into reusable classes so you can keep all your API configurations in one place. Saloon comes with many exciting features out of the box like recording requests in your tests, caching, OAuth2 and pagination. It's a great starting point for building simple, standardised API integrations in your application.

<?php

$forge = new ForgeConnector('api-token');

$response = $forge->send(new GetServersRequest);

$data = $response->json();

Key Features

  • Provides a simple, easy-to-learn, and modern way to build clean, reusable API integrations
  • Built on top of Guzzle, the most popular and feature-rich HTTP client
  • Works great within a team as it provides a standard everyone can follow
  • Great for building your next PHP SDK or library
  • Packed full of features like request recording, request concurrency, caching, data-transfer-object support, and full Laravel support.
  • Framework agnostic
  • Lightweight and has few dependencies.

Getting Started

Click here to get started

Contributing

Please see here for more details about contributing.

Security

Please see here for our security policy.

Credits

And a special thanks to Caneco for the logo ✨

saloon's People

Contributors

ajthinking avatar anthonyvancauwenberghe avatar ashleyhood avatar binaryfire avatar boryn avatar caneco avatar cbrad24 avatar chengkangzai avatar craigpotter avatar dependabot[bot] avatar gdebrauwer avatar guanguans avatar gummibeer avatar joel-jensen avatar jonpurvis avatar juse-less avatar leo108 avatar mabdullahsari avatar markwalet avatar ronnievisser avatar sammyjo20 avatar sneycampos avatar szepeviktor avatar tbarn avatar walrussoup avatar wandesnet avatar yurirnascimento avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

saloon's Issues

FR: Circuit Breaker Plugin

Hello, love the package!

I think it could be beneficial to have a sensible default (but configurable) circuit breaker pattern plugin available to use (opt in only). A circuit breaker pattern is good for use with external API integrations for multiple reasons: alerts when an external API reaches a failure threshold, preventing subsequent requests for a time period if the external API is known to be down, prevention of further security-type lockouts (if a request is triggering an external API’s request limits, for example).

Is there any interest in this?

Thanks!

Nice job! And FR: Swappable PSR-7-compliant HTTP client library

I came here to say great work on putting this together. It looks good and it's really nicely organised. I've only been reading code and haven't had a chance to try it out yet, but I feel like this could become a great package to make integrating with lots of APIs easier!

One thing I picked up on pretty quickly though is how dependent it is on Guzzle (I love Guzzle btw, so it's fine for me). I know it's early days for this package and I'm sure (/hope) you have plans in mind to make this not such a hard dependency as it feels quite useful for such a fundamental (and PSR-compliant) library to be easily replaceable.

To that end though, one thing that I spotted that will kind of make this harder is that the request config (and possibly other structures) is exposed directly to Guzzle: your SaloonRequest class (or more correctly, the CollectsConfig trait) gathers it all up and essentially passes it Guzzle unfiltered in your RequestManager.

It feels like a good goal would be to abstract this away so that Guzzle could be swapped out as needed.

Eventually you'd end up with a standard set of config options that work across HTTP client libraries. Even if your internal structure matches Guzzle's for convenience (and it may stay that way for a long time), you could introduce a transform layer between your structure and the HTTP client being used so that devs can use a consistent interface across libraries.

You and users of this package would be less susceptible to changes on Guzzle's side, for example if they suddenly removed an option, you could help maintain backwards compatibility for folks.

You could then also remove Guzzle as a dependency, which means fewer potential conflicts for people too.

Make responses return json by default, without having to call send() method

Currently to call a request and have it return json, it looks like this:

$response = Connector::getTotalRequest('users')->send()->json()

I want all requests to return json by default, instead of a SaloonResponse object, like so:

$response = Connector::getTotalRequest('users')

I tried making a custom response, but it enforces that only a SaloonResponse object is returned, but even then I still need to call send(). How could I achieve this without having to overwrite things?

Merged URL not valid

This is a great project - exactly what I needed to standardise our API connectivity as we use a lot of integrations. I realise this is a problem with the third party API I'm using not confirming to REST specifications but want to check if anything can be done.

We use an API which has the baseUrl format api.xxx.com/v2
Requests are then made to endpoints in the format model=accounts&action=load
This leads to an actual URL of api.xxx.com/v2?model=accounts&action=load

Saloon constructs the URL by combining the two parts, trimming the right forward slash (if present) and then inserting a forward slash - e.g api.xxx.com/v2/?model=accounts&action=load - this produces a 404 error as it is now referencing a non existent directory.

I have tried using handlers but you can't modify the base URL once set (done in ManagesGuzzle).

The easy solution is to just use the full URL in the Request class but this takes away some of the simplicity that this package created.

I can't think of any other way of modifying the URL before the request is made - however an option on the SaloonConnector class could allow the behaviour to be configured at runtime per connector (or alternatively, an option in the global config to add slashes on a global basis). I'm happy to submit a pull request if this is something you'd be interested in adding

Pagination without next_page_url

Hello!

Was wondering if it's possible to use the built-in pagination without supplying a next_page_url? The API I'm using only supplies the below for pagination.

"pagination": { "pageNumber": "1", "pageSize": "100", "totalAvailable": "640" }

Another method to call requests from connectors?

Saloon has given people the option to make requests by calling the request class or using the connector by specifying requests in the $requests property on the connector, but I was thinking of another one:

$connector = new ForgeConnector();

$connector->request(new GetServersRequest); // Init the request but don't send it

$connector->send(new GetServersRequest); // Send the request right away

This gives people the ability to define defaults on the connector while not having to register their request and also get
type-hinting in their IDE.

Sender for SOAP

Can version 2 be integrated with the RicorocksDigitalAgency\Soap package using custom senders? If yes, can you please give me an example of such a sender?

Thank you in advance!

SDK-Style API

Just had an idea for Saloon, wondering if this would be useful.

Currently the only way to trigger requests would be to instansiate the request, but what if there was a "connector" first option too?

For example:

$connector = new ForgeConnector($myArgs);

$connector->getForgeServer($requestConstructorArgs)->send();

This means you could construct your own connector and pass it arguments. I don't think it would be a difficult thing to implement, but I think it would be really useful. Not 100% sure how I would define type-hints.

Internally it could look something like this:

public function getForgeServer($args): GetForgeServerRequest
{
    return $this->forwardRequest(GetForgeServerRequest::class, $args);
}

The "forwardRequest" method would instantiate the request, and pass it the args.

If this is manually defined, it could help with typehinting.

I could also make a trait for Laravel which will "auto discover" requests by looking through an expected file structure.

Saloon fails to follow redirect while Guzzle does

Hi, great package!

I'm trying to make a sdk for an API I'm using. I forked the template and started building it.

My connector is as follows:

class Asaas extends SaloonConnector
{

    /**
     * Define the base URL for the API
     *
     * @var string
     */
    protected string $apiBaseUrl = 'https://www.asaas.com';

    /**
     * Define the base URL for the Sandbox API
     *
     * @var string
     */
    protected string $apiSandboxBaseUrl = 'https://sandbox.asaas.com';

    /**
     * Define the ambient to use the API
     *
     * @var Ambient
     */
    protected Ambient $ambient = Ambient::PRODUCTION;

    /**
     * Define the API key for the account
     *
     * @var string
     */
    protected string $apiKey = ':api_key';

    /**
     * Custom response that all requests will return.
     *
     * @var string|null
     */
    protected ?string $response = AsaasResponse::class;

    /**
     * The requests/services on the Asaas.
     *
     * @var array
     */
    protected array $requests = [
        'customers' => CustomerCollection::class,
    ];

    /**
     * Define the base URL of the API.
     *
     * @return string
     */
    public function defineBaseUrl(): string
    {
        return $this->ambient === Ambient::PRODUCTION ? $this->apiBaseUrl : $this->apiSandboxBaseUrl;
    }

    /**
     * @param string $apiKey
     * @param Ambient|null $ambient
     */
    public function __construct(string $apiKey, Ambient $ambient = null)
    {
        $this->apiKey = $apiKey;

        if (isset($ambient)) {
            $this->ambient = $ambient;
        }
    }

    public function defaultAuth(): ?AuthenticatorInterface
    {
        return new Authenticator($this->apiKey);
    }

    /**
     * Define any default headers.
     *
     * @return array
     */
    public function defaultHeaders(): array
    {
        return [];
    }

    /**
     * Define any default config.
     *
     * @return array
     */
    public function defaultConfig(): array
    {
        return [];
    }
}

My Request:

class ListCustomersRequest extends SaloonRequest
{
    protected ?string $connector = Asaas::class;
    protected ?string $method = Saloon::GET;
    /**
     * Define allowed filters to be applied to the customer
     *
     * @var array|string[]
     */
    protected array $allowedCustomerFilters = ['name', 'cpfCnpj', 'externalReference'];
    /**
     * Define page to be fetched
     *
     * @var int|mixed
     */
    protected int $page;
    /**
     * Define limit of records to be fetched (max.: 100)
     *
     * @var int|mixed
     */
    protected int $limit;

    /**
     * @param Customer $customer
     * @param int $page
     * @param int $limit
     */
    public function __construct(
        public Customer $customer,
        int             $page = 1,
        int             $limit = 25,
    ) {
        $this->page = max($page, 1);
        $this->limit = max(10, min($limit, 100));
    }

    /**
     * @return string
     */
    public function defineEndpoint(): string
    {
        return '/customers' . ($this->customer->id !== null ? "/{$this->customer->id}" : '');
    }

    /**
     * @return array
     */
    public function defaultQuery(): array
    {
        return [
            ...array_filter(
                $this->customer->toArray(),
                fn ($value, $prop) => ! empty($value) && in_array($prop, $this->allowedCustomerFilters),
                ARRAY_FILTER_USE_BOTH
            ),
            'offset' => ($this->page - 1) * $this->limit,
            'limit' => $this->limit,
        ];
    }
}

I've made a test for this request, but it just doesn't work. I tried everything, including configuring the curl options of Guzzle but nothing works.

it('fetches a list of all customers in account.', function () {
    $asaas = new Asaas(
        'xxxxxxxx',
        Ambient::SANDBOX,
    );
    $response = $asaas->customers()->get();
    dump($response->body());
    expect($response)->toBeInstanceOf(SaloonResponse::class);
});

However, I made another test using Guzzle directly, with minimal configuration, and it works just fine

it('fetches a list of all customers in account with guzzle.', function () {
    $client = new Client([
        'handler' => HandlerStack::create(),
    ]);
    $config = [
        'headers' => [
            'access_token' => 'xxxxxxxxxxxx',
        ],
    ];
    $request = new Request('GET', 'https://sandbox.asaas.com/api/v3/customers');
    $res = $client->send($request, $config);
    $body = $res->getBody()->getContents();
    dump($body);
    expect($body)->json();
});

In the first test, I get a HTML page with the title login, and if I put a function on the property on_redirect in the defaultConfig I can see that it redirects me exactly one time for the route login/auth, but the second test return the JSON with the results.
image

Any idea of what am I doing wrong?

Some feedback on Pagination

Hi!

It was wonderful to discover that you already had thought about automatic pagination :)

I had a few observations while implementing OffsetPaginator in our project.

  • Maybe we could as well bind pagination directly to the request not generally to a connector? I now connect to an API which for some endpoints, it expects limit/skip parameters passed in GET, and for some by POST. And some endpoints don't accept limit/skip parameters at all.
  • Some API don't return total count, maybe with the option $noTotal, you could make a isFinished() check based on the count of currently fetched items?
  • Due to some bad configuration, pagination may hit an endless loop. Maybe it would be good to introduce an "emergency" maximum iterations limit? So that if we already do 100 calls, isFinished() will return true / throw an exception?
  • Having some params at defaultQuery() in the connector, it seems these values are not merged in at applyPagination(). Only values defined at defaultQuery() in the request are merged.
  • Already mentioned that $paginator->setCurrentOffset() is not implemented.

I solved some of the above issues using a custom paginator:

use Saloon\Contracts\Request;
use Saloon\Enums\Method;
use Saloon\Http\Paginators\OffsetPaginator;

class CustomOffsetPaginator extends OffsetPaginator
{
    protected int $iterationsCnt = 0;
    protected int $iterationsMax = 100;

    protected function isFinished(): bool
    {
        $this->iterationsCnt++;

        if ($this->iterationsCnt > $this->iterationsMax) {
            return true;
        }

        return count($this->currentResponse->json('data')) < $this->limit();
    }

    protected function applyPagination(Request $request): void
    {
        if ($this->originalRequest->getMethod() === Method::POST) {
            $request->body()->merge([
                $this->getLimitKeyName() => $this->limit(),
                $this->getOffsetKeyName() => $this->currentOffset(),
            ]);
        } else {
            $request->query()->merge([
                $this->getLimitKeyName() => $this->limit(),
                $this->getOffsetKeyName() => $this->currentOffset(),
            ]);
        }
    }
}

IMHO it would be great to have the support for for different request methods (GET/POST, etc.) and stopping the loop without the "total" information out of the box in the library.

Saloon Hooks

I think it would be useful to add various hooks into Saloon which can be added which can be useful for logging. For example I could add a hook before a request has happened, after a request has happened, after a successful response and after a failure response.

This could be added to the connector or the request (or both) and can be used.

There could also be a shouldSendRequest() which can return false if a provided condition fails

Add multipart/form-data Saloon 2.0.0-beta6

Hello,

I am using Saloon 2.0.0-beta6, Laravel 9.19 and trying to transform the following curl request to Saloon code:

curl --location --request POST 'https://api.sandbox.com/photo/api/v2/photos' \
--header 'Content-Type: multipart/form-data' \
--header 'Authorization: Bearer MYBEAREAR' \
--form 'ans="{\"version\": \"0.1\",\"type\": \"image\",\"owner\": {\"id\": \"sandbox.id\"},\"additional_properties\": {\"originalUrl\": \"https://cdn.player.com/v2/media/poster.jpp\" },\"subtitle\": \"Test image\"}";type=application/json'

I have a connector:

<?php

namespace App\Http\Integrations\PhotoCenter;

use Saloon\Contracts\Authenticator;
use Saloon\Http\Auth\TokenAuthenticator;
use Saloon\Http\Connector;

class PhotoCenterConnector extends Connector {
    /**
     * The Base URL of the API
     *
     * @return string
     */
    public function resolveBaseUrl(): string {
        return (string) env( 'API_PHOTO_BASE' );
    }

    /**
     * Default headers for every request
     *
     * @return string[]
     */
    protected function defaultHeaders(): array {
        return [
            'Content-Type' => 'multipart/form-data',
            //'Accept'       => 'application/json',
        ];
    }

    /**
     * Default HTTP client options
     *
     * @return string[]
     */
    protected function defaultConfig(): array {
        return [];
    }

    protected function defaultAuth(): ?Authenticator {
        return new TokenAuthenticator( env( 'ACCESS_TOKEN' ) );
    }
}

And the request:

<?php

namespace App\Http\Integrations\PhotoCenter\Requests;

use Saloon\Contracts\Body\HasBody;
use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\Traits\Body\HasMultipartBody;

class CreatePhotoCenterRequest extends Request implements HasBody {
    use \Saloon\Traits\Body\HasBody;

    /**
     * Define the HTTP method
     *
     * @var Method
     */
    protected Method $method = Method::POST;

    /**
     * Define the endpoint for the request
     *
     * @return string
     */
    public function resolveEndpoint(): string {
        return 'photos';
    }
}

My example code:

        $photo_connector = new PhotoCenterConnector();
        $request            = new CreatePhotoCenterRequest();
        $request
            ->body()
            ->set('ans="{"version": "0.1","type": "image","owner": {"id": "sandbox.id"},"additional_properties": {"originalUrl": "https://cdn.jwplayer.com/v2/media/poster.jpg" },"subtitle": "Test image from"}";type=application/json');
            /*
           // Other example
          ->add(
                name: 'ans',
                contents: '{"version": "0.1","type": "image","owner": {"id": "sandbox.id"},"additional_properties": {"originalUrl": "https://cdn.player.com/v2/media/poster.jpg" },"subtitle": "Test image from"}',
                filename: 'myfile.png',
                headers: [
                    'type' => 'application/json'
                ] );*/

        $response = $photo_connector->send( $request );
        dd( $response );

but I always get the same error:

array:5 [β–Ό // app/Http/Controllers/JWPlayerController.php:401
  "error" => "Payload Too Large"
  "exception" => "org.springframework.web.multipart.MultipartException"
  "message" => "Could not parse multipart servlet request; nested exception is java.io.IOException: org.apache.tomcat.util.http.fileupload.FileUploadException: the request was rejected because no multipart boundary was found "
  "status" => 413
  "timestamp" => 1676008975851
]

but from the curl command, the request does work.

What would be the correct way to convert the curl command to Saloon code?

Thank you very much for your help.

Extend a connector ?

Hi, I'm trying to extend my connector.
The extended connector implements the AlwaysCacheResponses trait.

But I'm getting the following error (notice: is not a valid.)
The provided connector is not a valid. The class must also extend SaloonConnector.

The word also, also feels a bit weird, I do not believe PHP classes can extend multiple classes ?

Question: I this not a valid approach ?

Base Connector:

<?php

namespace App\Http\Integrations\Service;

use Sammyjo20\Saloon\Http\SaloonConnector;
use Sammyjo20\Saloon\Traits\Plugins\AcceptsJson;

class BaseConnector extends SaloonConnector
{
    use AcceptsJson;

    public function defineBaseUrl(): string
    {
        return config('services.service.base_url');
    }
}

Extended Connector:

<?php

namespace App\Http\Integrations\Service;

use App\Http\Integrations\Service\BaseConnector;
use Illuminate\Support\Facades\Cache;
use Sammyjo20\SaloonCachePlugin\Drivers\LaravelCacheDriver;
use Sammyjo20\SaloonCachePlugin\Interfaces\DriverInterface;
use Sammyjo20\SaloonCachePlugin\Traits\AlwaysCacheResponses;

class CachedConnector extends BaseConnector
{
    use AlwaysCacheResponses;

    public function cacheDriver(): DriverInterface
    {
        return new LaravelCacheDriver(Cache::store('file'));
    }

    public function cacheTTLInSeconds(): int
    {
        return 7200;
    }
}

A Request:

<?php

namespace App\Http\Integrations\Service\Requests;

use Sammyjo20\Saloon\Constants\Saloon;
use Sammyjo20\Saloon\Http\SaloonRequest;
use Sammyjo20\Saloon\Http\SaloonResponse;
use App\Http\Integrations\Mfiles\CachedConnector;

class GetLanguagesRequest extends SaloonRequest
{
    use CastsToDto;

    /**
     * The connector class.
     *
     * @var string|null
     */
    protected ?string $connector = CachedConnector::class;

    /**
     * The HTTP verb the request will use.
     *
     * @var string|null
     */
    protected ?string $method = Saloon::GET;

    /**
     * The endpoint of the request.
     *
     * @return string
     */
    public function defineEndpoint(): string
    {
        return config('services.service.endpoints.languages.get');
    }
}

Version 2 is in beta 🀠

Hey everyone, thanks so much for all the support that you have given for Saloon. I can't believe it's almost at 500 stars on GitHub and receiving over 150 installs a day. I just wanted to take a moment to thank you all! πŸ˜€πŸ™Œ

That being said, there are some good things coming to Saloon. I'm working on version 2, which will help create a road for the future of Saloon, as well as improving developer experience and making your life easier.

Here is a summary of the changes that are going to happen.

New Flow

Currently, Saloon will run your request through the "Request Manager" which merges all of the headers, config, and everything else from the connector and request into one. With Version 2, I am introducing the "PendingSaloonRequest". Inside of here, this is where all of the logic like merging properties together and running your plugins will happen. This is so the building of a request is entirely separate from the sending of requests. After that, it will be sent to the "Guzzle Sender", and the class will receive the full PendingSaloonRequest with all the final configuration and headers before sending.

I am changing to this new flow because eventually, I want Saloon to be HTTP client agnostic and allow you to use any client you like, so you don't have to use Guzzle if you don't want - and if Guzzle decides to be abandoned, Saloon won't be left in the dark.

Here's the new flow in detail.

image

The only exception to the flow in the image above is that it will not create a PSR-7 request just yet, I am still working that one out.

Other Changes

  • Brand new middleware pipeline so you don't have to rely on Guzzle's handler pipeline
  • Requests don’t go straight to the request manager anymore, they can be built within a PendingSaloonRequest.
  • Better way to send data, by implementing interfaces that are processed by the PendingSaloonRequest.
  • All properties of a request like headers, config, data, handlers etc. Will all be standardised and now in separate buckets like $requestβ†’headers()β†’push().
  • Interfaces to be used a lot more
  • PHP 8.1 minimum
  • Real asynchronous request
  • Request pooling support

Middleware Pipeline

To help move the dependency on Guzzle, Saloon has also implanted its own middleware pipeline for requests and responses. This will replace the old Guzzle Handler support and response interceptors. You will be able to define request pipes and response pipes to modify the request/response before it is sent or given back to the user.

$request = new GetForgeServersRequest;

$request->middleware()
    ->addRequestPipe(function (PendingSaloonRequest $request) {
       //
    })
    ->addRequestPipe(new MyInvokableClass)
     ->addResponsePipe(function (SaloonResponse $response) {
       //
    })

Saloon's middleware pipeline will also be supported for asynchronous requests, so even if you have a pool of requests being sent simultaneously, they can each have their own middleware pipeline, which is something that Guzzle does not support with their existing handler stack logic, since you can only have one handler stack per client.

Middleware pipes can be added anywhere. Inside the request/connector, added by plugins, or even applied right before a request is sent. It will really allow you to tap into Saloon.

External API

Saloon’s external API will still remain the same, like the following:

<?php

$request = new GetForgeServersRequest;
$response = $request->send($mockClient);

$connnector = new ForgeConnector;
$request = $connector->request(new GetForgeServersRequest)->send();
$response = $request->send();

// Or

$connector->send($request);

There will be some additions to the external API, like interacting with request properties

<?php

// Before

$request = new GetForgeServersRequest;
$request->addHeader('X-User-Name', 'Sammy');
$request->addConfig('debug', true);
$config = $request->getConfig(); // Array

// Now

$request->headers()->push('X-User-Name', 'Sammy');
$request->config()->push('debug', true);

$config = $request->config()->all();

// Same with config, handlers, response interceptors, etc.

There will also be some new features, like the ability to set a mock client on the connector or a request, so it doesn’t have to be passed into the send of every request.

<?php

$request = new GetForgeServersRequest;
$request->withMockClient($mockClient);
$request->send(); // Won't need to set it here!

// Even more useful

$connector = new ForgeConnector;
$connector->withMockClient($mockClient);

// All requests using the connector will use the same mock client!

$connector->request($requestA)->send();
$connector->request($requestB)->send();

How to handle expectations when testing?

Hi all!

Saloon is pretty cool! Helps me to integrate API's in a clean way in my Laravel application.

However, I was wondering how to handle expectations when testing. You are able to use mocked responses, but you never know if they are called, how many times, and with which data.

I already understand how to create fake MockResponses, but in my tests I want to validate what data has been passed to the response, and make sure it is called (once, never on x times).

For now I came up with this solution myself:

    /**
     * Helper method to mock a MockResponse object to validate if the request has been sent.
     */
    protected function mockMockResponse(mixed $data = [], int $status = 200, ?InvokedCount $expected = null): MockObject
    {
        $mock = $this->getMockBuilder(MockResponse::class)
            ->onlyMethods(['getStatus'])
            ->setConstructorArgs([ $data, $status ])
            ->getMock();

        $mock
            ->expects($expected ?: $this->once())
            ->method('getStatus')
            ->willReturn($status);

        return $mock;
    }


    // Use the mock for Saloon
    Saloon::fake([
        SendMessageRequest::class => $this->mockMockResponse(['name' => 'Sam'], 200),
    ]);

So I was wondering:

  • Is there any preferred way I can do myself to add expectations to my API tests?
  • Is there anything Saloon is missing we could add to make this easier to work? I'd be happy to help.

[v2] Drop illuminate/support requirement

As already discussed on Twitter - the illuminate/support package comes with some handy classes but also a ton of Laravel-specific ones. Requiring it only because of the Arr and/or Str helpers isn't really "right" and questions the "plain php" nature of that package, as there's a specific Laravel wrapper.

It will also tie this package's versioning to the Laravel lifecycle even if there hasn't been anything changed relevant to that package. As otherwise it won't be installable on the latest Laravel projects - at the same time it should support the lowest Illuminate version possible as with Illuminate and Laravel comes also PHP constraints which will possibly limit the usability of the package even further. In my experience Symfony devs also don't really like to load illuminate/support as it comes with a ton of classes absolutely not needed and will make navigating IDE more complicated.

So far I've seen the only classes used are the following. All should be "easy" to replace with a custom Util class or even plain PHP.

  • Illuminate\Support\Arr
  • Illuminate\Support\Str
  • Illuminate\Support\Collection

https://github.com/Sammyjo20/Saloon/search?q=Illuminate

Coming Soon: Caching Plugins for Laravel

Just wanted to add an issue mentioning that I'm adding support for request caching in Saloon soon. It's going to use the popular Guzzle caching plugin https://github.com/Kevinrob/guzzle-cache-middleware but I'm hoping to extend it slightly.

Additions

  • Adds HasExplicitCaching trait
  • Adds HasImplicitCaching trait

HasExplicitCaching

This caching plugin will allow you to define explicit caching rules, like how long a response should be cached for.

HasImplicitCaching

This caching plugin will work automatically as it will use the caching headers (if provided) on the response.

Both plugins will allow you to purge the cache at any time and see if a SaloonResponse has been cached.

Saloon Fixtures

So already you can create your own mock responses but it would be really cool if you could create your own "fixtures" for requests.

Ideas

  • Define a fixture() method on your request that Saloon can use
  • Specify MockResponse::fixture($array)
  • Specify MockResponse::fixtureFile($jsonFile)

OAuth2.0 documentation feedback

First, I just must say this is an exceptionally great package, perfectly thought out and with great scalability. WOW!

I have some feedback on the documentation, in the places where I got blocked during the implementation. When looking at https://docs.saloon.dev/digging-deeper/oauth2-authentication#refreshing-access-tokens, there is:

$authenticator = $user->auth; // Your authenticator class.

which makes me think, what the heck is $user->auth??? Is this a field? Is this a relationship? What have I missed?

I bet it would be more clear if the field was named authenticator and comment was something like this:
// $authenticator stored in the model using OAuthAuthenticatorCast or EncryptedOAuthAuthenticatorCast

Later, the docs at https://docs.saloon.dev/digging-deeper/oauth2-authentication#building-a-method-to-create-an-authenticated-instance-of-your-sdk show the spotify() method example. But within this, there should be no $user-> but $this-> as we are already in the User class.

PS. I would as well suggest splitting this method into two for better readability:

public function spotify(): SpotifyApiConnector
{
    $authenticator = $this->refreshAccessToken($this->spotify_authenticator);

    $spotify = new SpotifyApiConnector();
    $spotify->authenticate($authenticator);

    return $spotify;
}

public function refreshAccessToken(
    OAuthAuthenticator $authenticator,
    $forceRefresh = false
): OAuthAuthenticator {
    if ($authenticator->hasExpired() || $forceRefresh) {
        $authenticator = SpotifyAuthConnector::make()->refreshAccessToken($authenticator);

        $this->spotify_authenticator = $authenticator;
        $this->save();
    }

    return $authenticator;
}

[v2] Add QueryKeyAuthenticator

Some APIs use a query param based Key authentication. For sure that's possible with a defaultQuery() method but somehow feels wrong. So how about a QUeryAuthenticator or similar named class with a signature like new QueryAuth(string $name, string $value). πŸ€”

Ratelimit handling & Pagination

Hi,

For a project these past few months I have been using a simpler, self created implementation of saloon (wish I had known this amazing package existed sooner). I've been looking through the documentation & code (of both v1 & v2), but i'm missing 2 (common) features that I need quite often while working with external api's:
the first one is pagination handling (for which I see there is already a draft? #152).
The second is rate limit handling.

Are you open to accepting PR's for rate limit handling? Since I'm new to this package could you give some basic pointers on how/where to start with this.

Kind regards,
Anthony

Fix AssertSentJson

I'm aware of an issue with AssertSentJson if there are multiple requests that have been sent using the same Saloon request but with different data.

How to reproduce:

  • Send two mock requests
  • Use the AssertSentJson method to check the data in the second request
  • It will fail because it will pull out the first request and check the data against the first match.

Casting paginated responses to DTO

I have a response with 'data' key, where there are several items. Normally (without automatic pagination), I'm able to get the collection of DTOs based on these suggestions: #186 using just $response->dto().

Now, I wanted to have the automatic pagination (I use OffsetPaginator), so instead of

$response = $this->connector()->send($this->request);

I used:

$paginator = $this->connector()->paginate($this->request);

everything is properly configured, as I can run

foreach($paginator as $response) {
    $pageData = $response->json('data');
}

as well I may create a collection of individual "raw" items in response: $paginator->collect('data').

I wanted to create DTOs based on the combined paginator results, and tried $paginator->dto(), but got: "Call to undefined method OffsetPaginator::dto()"

I know, I can easily build the DTOs manually:

$dtos = $paginator->collect('data')->map(
    fn ($post) =>  PostDto::fromItem($post),
);

but it would be nice to have a "helper/shortcut" method dto() as with single responses which would use the same createDtoFromResponse() method and we could similarly use $paginator->dto().

Access to `->body()` while building custom cache key at cacheKey()

As some endpoints pass the parameters through the body using POST method (and some through query using GET), I wanted to specifically build the cache key using this information as well.

The CacheKeyHelper already uses $query = $pendingRequest->query()->all(); and in my own cacheKey(PendingRequest $pendingRequest) implementation, I wanted to similarly use: $body = $pendingRequest->body()->all();. Unfortunately the body() is not accesible there, I get Call to a member function all() on null.

Different grant type for oauth

Hi, we need to use 'client_credentials' to get an oauth token before every other api call we make to a third party api call. Well we only need a new token if the previous one has expired, they have a 2 hour life.

Wondering how I can create a custom authenticator so it adds the latest token to every other request we need to make

Thanks,
Brian Smith

Caching GET requests which has query parameters

I understand that this can be solved by a custom cache key but I think this should either be changed or clarified in the documentation.

Saloon offers cache requests through the use of the Trait "AlwaysCacheResponses". However by default the cache key is not including the URL query parameters. The documentation states that "By default, the cache key will be built up from the full URL of the request".

Then the expected behavior should be that the cache key includes the query parameters as a "full URL" according to RFC 1738 describes a HTTP URL as following:
An HTTP URL takes the form:

  http://<host>:<port>/<path>?<searchpart>"

`URLHelper::join()` does not respect full-url in `$endpoint`

The \Sammyjo20\Saloon\Helpers\URLHelper::join() method does not respect a full URL in the $endpoint argument but still prefixes it with the $baseUrl.
Some APIs have a few endpoints on another base URL/domain. I would like to override the default base URL with a full URL in the request endpoint instead of creating a whole new connector.

The already used filter_var($endpoint, FILTER_VALIDATE_URL) could be used to early return in case the $endpoint is already a full and valid URL. Possibly also combined with parse_url() to check scheme and host are defined.

Right now I'm running in the following curl error which fully bubbles through all layers. Even with the AlwaysThrowsOnErrors trait it fully bubbles through and doesn't get wrapped up in a \GuzzleHttp\Exception\ConnectException or similar.

cURL error 6: Could not resolve host: api.steampowered.comhttps (see https://curl.haxx.se/libcurl/c/libcurl-errors.html) for https://api.steampowered.comhttps//steamcommunity.com/actions/QueryLocations?key=xyz&format=json

class SteamConnector extends SaloonConnector
{
    public function defineBaseUrl(): string
    {
        return 'https://api.steampowered.com';
    }
}
class QueryLocationsRequest extends SaloonRequest
{
    protected ?string $method = 'GET';

    public function defineEndpoint(): string
    {
        $query = '';

        if ($this->countryCode) {
            $query .= "/{$this->countryCode}";

            if ($this->stateCode) {
                $query .= "/{$this->stateCode}";
            }
        }

        return "https://steamcommunity.com/actions/QueryLocations{$query}";
    }
}

Saloon Testing/Mocking (Coming Soon!)

Just wanted to open an issue here to let people know that I am currently working on a mocking system for Saloon. It's going to introduce two ways to mock in Saloon, one way would be the "Larvel" way - inspired by the wonderful testing that is provided by Illuminate/Http. The other way would require a bit more intervention but I hope would still be useful especially when writing SDKs.

Laravel Mocking

Laravel mocking will have slightly more features than the standard PHP mocking that can be used within SDKs. This is because Laravel has a super powerful service container. See the testing page for the HTTP Client in the Laravel docs. I will recreate how it works there.

Mocking for SDKs

SDK mocking is slightly different, but the idea is that you will be able to tell Saloon to send a fake response when a request has been created.

$mockClient = new SaloonMockClient();
$mockClient->addResponse(200, $data, $headers);
$mockClient->addResponse(500, $data, $headers);

$response = (new SaloonRequest)->send($mockClient); // 200
$response = (new SaloonRequest)->send($mockClient); // 500

Here's an example of how it could be implemented

$sdk = new ForgeSdk($token, $saloonMockClient);
$sdk->getServers(); // Internally sends $saloonRequest->send($this->mockClient);

Extra Features

  • Interceptors and handlers to have a new argument to specify if they can be run during tests
  • Saloon plugins to have a new argument to specify if they can be run during tests

Coming Soon: Mocking Assertions

I realised that after I had built the mocking functionality in Saloon, I had forgotten to add assertions for the mocking. Coming soon, you will be able to assert the following things in your tests:

  • Assert a request was sent
  • Assert data was sent
  • Assert a request was sent to a URL.

Unable to authenticate a pending request

According to the docs, I should be able to call
$pendingRequest->authenticate(...) from the retry handler, however it does not seem to be updating the header on the pending request. I have created a reproduction test case here.

test('you can authenticate the pending request inside the retry handler', function () {
    $mockClient = new MockClient([
        MockResponse::make(['name' => 'Sam'], 401),
        MockResponse::make(['name' => 'Gareth'], 200),
    ]);

    $connector = new TestConnector;
    $connector->withMockClient($mockClient);

    $response = $connector->sendAndRetry(new UserRequest, 2, 0, function (Exception $exception, PendingRequest $pendingRequest) {

        $pendingRequest->authenticate(new \Saloon\Http\Auth\TokenAuthenticator('newToken'));

        return true;
    });

    expect($response->status())->toBe(200);
    expect($response->json())->toEqual(['name' => 'Gareth']);
    expect($response->getPendingRequest()->headers()->get('Authorization'))->toEqual('Bearer newToken');
});

The test results in Failed asserting that null matches expected 'Bearer newToken'.

Authorization base url is not the same with resolveBaseUrl

HubSpot OAuth2 authorization base url is https://app.hubspot.com while the api base url is https://api.hubapi.com

I don't see the option to override the base url in getAuthorizationUrl

What I'm doing is using str_replace to overcome this

<a href="{{ str_replace(config('services.hubspot.api_url'), 'https://app.hubspot.com', $authorizeUrl) }}">Login</a>

https://developers.hubspot.com/docs/api/working-with-oauth#initiating-an-integration-with-oauth-2-0

Problem with HasFormBody trait

Hi! I have a problem with a Request, I am using HasFormBody trait for simulate this, with Laravel HTTP Client works!

    $post = Http::asForm()->post('https://api.service.com/oauth2/token', [
        'grant_type' => 'client_credentials',
        'client_id' => '9bf9f97e-be08-11ed-afa1-0242ac120002',
        'client_secret' => 'xf0f18ae1e45bac5e22246024b90a4af1f403d86202b290aef46dcfa23abe8c',
    ]);

    $token = $post->json('access_token');

But when I try with this, the request fails me.

<?php

namespace App\Http\Integrations\Medplum\Requests;

use Saloon\Contracts\Body\HasBody;
use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\Traits\Body\HasFormBody;

class GetAuthToken extends Request implements HasBody
{
    use HasFormBody;
    /**
     * Define the HTTP method
     *
     * @var Method
     */
    protected Method $method = Method::POST;

    /**
     * Define the endpoint for the request
     *
     * @return string
     */
    public function resolveEndpoint(): string
    {
        return '/oauth2/token';
    }

    protected function defaultBody(): array
    {
        return [
            'grant_type' => 'client_credentials',
            'client_id' => '9bf9f97e-be08-11ed-afa1-0242ac120002',
            'client_secret' => 'xf0f18ae1e45bac5e22246024b90a4af1f403d86202b290aef46dcfa23abe8c',
            'redirect_uri' => 'http://localhost:80/api'
        ];
    }
}
$authorizeRequest = new GetAuthToken();
$code = $connector->send($authorizeRequest);

Anyone with the same error? I don't know if I'm doing something wrong

Good place to add validations for requests?

What is a good place to add validations for requests before they are submitted? For example, a request requires atleast 2 values on an array passed to it. I want to add this validation before the request is submitted and throw an error. What would be a good place to do this?

[proposal] Use `illuminate/http` instead of the Guzzle client

This package looks promising to implement API SDKs.

I was thinking about testing requests with this package - would make sense to use illuminate/http as it can be mocked internally without having to re-write mocks within this package.

Http::fake([
    'https://forge.laravel.com/*' => Http::response(['foo' => 'bar'], 200, $headers),
]);

$request = new GetForgeServerRequest(serverId: '123456');

$response = $request->send();
$data = $response->json();

I might as well submit a PR to make this change, but that would leave a lot of changes (perhaps). πŸ€”

retry in case of errors

first, thank you for this very nice package, I really like the idea and your concept. the very first question that comes into my mind is: how do I implement a "retry x times with x seconds sleep between" with this? would it make sense to implement a interceptor here or is this something that should be implemented outside of this package? thank for your advice πŸ™

Rate limit plugin

Hi,

One of my needs is to be able to use a request rate limit, because normally my jobs run for long periods but need to respect the API's request limit.

I'm creating a plugin to use the Spatie/GuzzleRateLimiterMIddleware

Would it be useful to anyone else?

License

I can see you've got the LICENSE file in .github/, but it might be worth setting it somewhere more public.

I think if it's in the root of the project, GitHub can pick it up and pop a link to it in the sidebar

Screenshot 2022-01-20 at 17 48 56

Client Credentials Grant

I needed the client_crendentials grant for an API. I was able to implement this by reusing most of the code of the authorization_code grant. I want to PR this, but there were some differences I'm unsure of how to implement.

Some of the things I encountered:

  • no code is needed as required by \Sammyjo20\Saloon\Http\OAuth2\GetAccessTokenRequest::__construct()
    • should there be different requests per grant type? if so, should they be moved to different directories in the OAuth2 folder?
  • in \Sammyjo20\Saloon\Helpers\OAuth2\OAuthConfig::validate() the redirect uri is checked, but this is not required for the client_credentials grant
    • should there be different configurations per grant type? eg. AuthorizationCodeOAuthConfig and ClientCredentialsOAuthConfig with a common (abstract?) parent
    • or should the grant type be a option on the OAuthConfig and handle validation differently based on that
  • the client_credentials grant does not require a refresh token (altough some implementations do provide it)
    • should that be removed from \Sammyjo20\Saloon\Interfaces\OAuthAuthenticatorInterface or implemented optionally?

Implementation Docs: Plugin Based Authentication

This was my approach to implementing plugin based authentication. Hopefully it's not an anti-pattern for how you envisioned Saloon being used. Putting docs here in case they can be brought into the official docs if it's an acceptable solution.

Plugin Based Authentication

In release v0.8.0 the authentication feature withToken was added. This trait adds the functionality to requests:

$request = UserRequest::make()->withToken('Sammyjo20');

This can be great for one offs, but depending on the size of the API you're interacting with, can feel less clean than simply newing up a request class and having it happen behind the scenes.

Pain Point

There are a few pain points which can be addressed using plugin based authentication:

  • Limits copy pasting of code that would otherwise be present in every request for larger API's
  • Allows additional logic such as expiration, finding proper keys to use (when you have multiple users authenticated, for instance) to be attached at single point and used for all subsequent requests
  • Architecturally feels more familiar to how we are accustomed to handling authorization (base URL, configuration, headers live in the "Connector"), abstracted away from the requests
  • For large API's such as salesforce, it could be helpful to build more than one connector depending on how you want to organize the project

While it is shown here that using defaultHeaders() is possible, with expiring tokens this approach does not feel as streamlined.

Lastly, to use Salesforce as an example, per-user OAuth configurations may have radically different endpoints. To validate and get the OAuth configuration, then initialize the wrapper and not need to worry about subsequent requests, design wise, feels more like what Saloon was going for.

Implementation

In your wrapping class that invokes the API, you can add the following properties.

// MyApiWrapper.php
protected static string $token;

public static function token() : string 
{
    return self::$token;
}

These can be supplied during construction of your wrapper. For my use with Salesforce, it looks like this (where $accessToken is an oauth token that has not expired):

public function __construct(string $accessToken, string $instanceUrl, string $apiVersion)
{
    self::$token = $accessToken;
    self::$instanceUrl = $instanceUrl;
    self::$apiVersion = $apiVersion;
}

Then, create a plugin with the following:

// Plugins/WithAuthorizationHeader.php
public function bootWithAuthorizationHeader() : void
    $this->mergeHeaders([
        'Authorization' => 'Bearer ' . MyApiWrapper::token()
    ]);
}

Finally, in your connector class(es), simply include the Trait

// Connectors\MyConnector.php

use WithAuthorizationHeader;

The connector will automatically merge the token into each request.

Dynamic BaseUrl

It is possible to have dynamic base url in a connector?

The base url of the endpoint changes but it uses the same standard so i think Saloon can help me.

Thanks

Queueing requests in Laravel

Any chance you could get this to work with Laravel Queues so I can just add ShouldQueue to a request and any time I call it it would be done asynchronously via the queue?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    πŸ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. πŸ“ŠπŸ“ˆπŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❀️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.