Group
Extension

Business-TrueLayer/lib/Business/TrueLayer/Request.pm

package Business::TrueLayer::Request;

=head1 NAME

Business::TrueLayer::Request - abstract class to handle low level request
traffic to TrueLayer, you probably don't need to use this and should use
the main L<Business::TrueLayer> module instead.

=cut

use strict;
use warnings;
use feature qw/ signatures postderef /;

use Moose;

no warnings qw/ experimental::signatures experimental::postderef /;

use Business::TrueLayer::Types;
use Business::TrueLayer::Authenticator;
use Business::TrueLayer::Signer;

use Try::Tiny::SmartCatch;
use Mojo::UserAgent;
use Carp qw/ croak /;
use JSON;
use Data::GUID;

my $MAX_REDIRECTS = 5;

has [ qw/ client_id client_secret kid / ] => (
    is        => 'ro',
    isa       => 'Str',
    required  => 0,
);

has 'host' => (
    is        => 'ro',
    isa       => 'Str',
    required  => 0,
    default   => sub ( $self ) {
        'truelayer.com',
    }
);

has api_host => (
    is        => 'ro',
    isa       => 'Str',
    required  => 0,
    lazy      => 1,
    default   => sub ( $self ) {
        return join( '.','api',$self->host );
    }
);

has auth_host => (
    is        => 'ro',
    isa       => 'Str',
    required  => 0,
    lazy      => 1,
    default   => sub ( $self ) {
        return join( '.','auth',$self->host );
    }
);

has payment_host => (
    is        => 'ro',
    isa       => 'Str',
    required  => 0,
    lazy      => 1,
    default   => sub ( $self ) {
        return join( '.','payment',$self->host );
    }
);

has 'private_key' => (
    is       => 'ro',
    isa      => 'EC512:PrivateKey',
    coerce   => 1,
    required => 0,
);

has '_ua' => (
    is        => 'ro',
    isa       => 'UserAgent',
    required  => 0,
    default   => sub {
        return Mojo::UserAgent->new
            ->max_redirects( $MAX_REDIRECTS )
            ->connect_timeout( 5 )
            ->inactivity_timeout( 5 )
            ->request_timeout( 30 )
        ;
    },
);

has 'authenticator' => (
    is        => 'ro',
    isa       => 'Authenticator',
    lazy      => 1,
    default   => sub ( $self ) {

        Business::TrueLayer::Authenticator->new(
            client_id     => $self->client_id,
            client_secret => $self->client_secret,
            host          => $self->auth_host,
            _ua           => $self->_ua,
        );
    },
);

has 'signer' => (
    is        => 'ro',
    isa       => 'Signer',
    lazy      => 1,
    default   => sub ( $self ) {

        Business::TrueLayer::Signer->new(
            kid         => $self->kid,
            private_key => $self->private_key,
        );
    },
);

sub idempotency_key ( $self ) {
    return Data::GUID->new->as_string;
}

sub api_post (
    $self,
    $absolute_path,
    $http_request_body = undef,
    $expect_json = 1,
) {
    # sign the request
    my $idempotency_key = $self->idempotency_key;

    my $json = $http_request_body
        ? JSON->new->utf8->canonical->encode( $http_request_body )
        : undef;

    my ( $jws ) = $self->signer->sign_request(
        'POST',
        $absolute_path,
        $idempotency_key,
        $json,
    );

    return $self->_ua_request(
        "https://" . $self->api_host . $absolute_path,
        $json,
        [
            'Authorization' => "Bearer " . $self->authenticator->access_token,
            'Tl-Signature'  => $jws,
            'Idempotency-Key' => $idempotency_key,
        ],
        'POST',
        $expect_json
    );
}

sub _ua_request (
    $self,
    $url,
    $body,
    $headers = undef,
    $method = 'POST',
    $expect_json = 1
) {

    my $ua = $self->_ua;
    my $res = $ua->start($ua->build_tx(
        $method,
        $url,
        {
            'Accept'        => 'application/json; charset=UTF-8',
            'Content-Type'  => 'application/json; charset=UTF-8',
            @{ $headers // [] },
        },
        # Mojo::UserAgent::Transactor::tx calls $self->generators and then the
        # callbacks based on the count of @_, and does not expect undef here
        (defined $body ? ($body) : ()),
    ))->result;

    # Easiest to deal with this first, even though it should be very rare:
    if ( $res->code == 301 ) {
        # possibly a redirect loop
        croak( "TrueLayer $method $url failed > $MAX_REDIRECTS levels of redirect" );
    }

    if ( !$expect_json && !$res->is_success ) {
        # All error responses are documented as returning JSON
        $expect_json = 1;
    }

    my $code = $res->code;

    # no content
    return if $code == '204';

    my $type = $res->headers->content_type;
    croak( "TrueLayer $method $url returned $code with no MIME type" )
        unless defined $type;

    $body = $res->body;

    return $body
        if !$expect_json && $res->is_success;

    # Either 2xx and expecting JSON, or an error response

    croak( "TrueLayer $method $url returned $code $type not JSON, status line: "
               . $res->message)
        unless $type =~ m!\Aapplication/(?:problem\+)?JSON\b!i;

    croak( "TrueLayer $method $url returned $code with an empty body" )
        unless length $body;

    my $res_content = try sub {
        JSON->new->canonical->decode( $body );
    },
    catch_default sub {
            croak( "TrueLayer $method $url returned $code with malformed JSON length @{[ length $body ]}: $_" );
    };
    croak( "TrueLayer $method $url returned $code JSON $res_content" )
        unless ref $res_content eq 'HASH';

   return $res_content
        if $res->is_success;

    # From here onward, it's all error handling, as best we can:
    my $title = $res_content->{title};
    if ( length $title ) {
        # This is looking like an error format we expect:
        # https://docs.truelayer.com/docs/payments-api-errors
        my $detail = $res_content->{detail};
        my $message = defined $detail ? "$title - $detail" : $title;

        if ( $res_content->{errors} ) {
            $message .= ' ';
            $message .= join( "; ",$_->@* )
                for values $res_content->{errors}->%*
        }

        croak( "TrueLayer $method $url returned $code: $message" );
    }

    my $error = $res_content->{error};
    if ( length $error ) {
        # This is looking like the error format for the Access tokens
        # and the Data API
        # https://docs.truelayer.com/reference/generateaccesstoken
        my $detail = $res_content->{error_description};
        my $message = defined $detail ? "'$error' - $detail" : "'$error'";
        # There's no : in this message so that we distinguish it from the
        # message generated for the croak above.
        # (ie we can tell which format the API is actually responding
        # with, whatever the docs might claim)
        croak( "TrueLayer $method $url returned $code $message" );
    }

    # This is not in spec:
    croak( "TrueLayer $method $url returned $code with JSON keys "
               . join( ', ', map { "'$_'" } sort keys %$res_content )
               . ' and status line: '  . $res->message);
}

sub api_get (
    $self,
    $absolute_path,
    $expect_json = 1
) {
    # GET requests don't need to be signed or require an Idempotency-Key
    return $self->_ua_request(
        "https://" . $self->api_host . $absolute_path,
        undef,
        [
            'Authorization' => "Bearer " . $self->authenticator->access_token,
        ],
        'GET',
        $expect_json
    );
}

1;

# vim: ts=4:sw=4:et


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.