package Amazon::API;

# Generic interface to Amazon APIs

use strict;
use warnings;

use parent qw/Class::Accessor::Fast/;

use Amazon::API::Error;
use Amazon::API::Signature4;

use Amazon::Credentials;

use Carp;
use Data::Dumper;
use English;
use HTTP::Request;
use JSON::PP qw/encode_json decode_json/;
use LWP::UserAgent;
use Scalar::Util qw/reftype/;
use XML::LibXML::Simple qw/XMLin/;

use constant {
  TRUE           => 1,
  FALSE          => 0,
  DEFAULT_REGION => 'us-east-1'
};

$Data::Dumper::Pair = ':';
$Data::Dumper::Useqq = TRUE;
$Data::Dumper::Terse = TRUE;

__PACKAGE__->follow_best_practice;

__PACKAGE__->mk_accessors(
qw/ 
    action
    api
    api_methods
    aws_access_key_id
    aws_secret_access_key
    content_type
    credentials
    debug
    decode_always
    error
    http_method
    last_action
    logger
    print_error
    protocol
    raise_error
    region
    response
    service
    service_url_base
    signer
    target
    token
    url
    user_agent
    version
/
);

our $VERSION = '1.1.11'; $VERSION=~s/\-.*$//;

sub new {
  my $class = shift;
  $class = ref($class) || $class;
  
  my $options;
  
  if ( ref($_[0]) ) {
    $options = $_[0];
  }
  elsif ( !(@_ % 2) ) {
    $options = { @_ };
  }
  else {
    die "usage: Amazon::API->new(hash or hash ref)\n";
  }
       
  my $self = $class->SUPER::new($options);

  $self->set_service($self->get_service_url_base)
    if $self->get_service_url_base;

  die "service is required\n"
    if !$self->get_service;
  
  $self->_set_defaults;

  $self->_create_methods;

  {
    no strict 'refs';
    
    *{'Amazon::API::DEBUG'} =  sub {
      return TRUE if !$self->get_debug;
      
      my $message = @_;
      
      if ( reftype($_[0]) eq 'CODE' ) {
        $message = $_[0]->();
      }
      
      carp $message;
      
      return TRUE;
    };
  }
  
  $self;
}

sub _create_methods {
  my ($self) = @_;
  
  my $class = ref($self) || $self;
  
  if ( $self->get_api_methods ) {
    no strict 'refs';
    no warnings 'redefine';

    my $stash = \%{'Amazon::EC2::'};

    foreach my $api ( @{ $self->get_api_methods } ) {

      my $method = lcfirst $api;

      $method =~ s/([a-z])([A-Z])/$1_$2/g;
      $method = lc $method;

      my $snake_case_method = $class . '::' . $method;
      my $CamelCaseMethod= $class . '::' . $api;

      # snake case rules the day
      *{ $snake_case_method } = sub { my $self = shift; $self->invoke_api( "$api", @_ ) }
        if ! $stash->{$method};

      # ...but some prefer camels
      *{ $CamelCaseMethod } = sub { my $self = shift; $self->$method(@_) }
        if ! $stash->{$api};
      
    } ## end foreach my $api ( @{ $self->...})

  } ## end if ( $self->get_api_methods)

  return $self;
} ## end sub _create_methods

sub _set_defaults {
  my ($self) = @_;

  $self->set_raise_error( $self->get_raise_error // TRUE );
  $self->set_print_error( $self->get_print_error // TRUE );

  $self->set_content_type('application/x-amz-json-1.1')
    if !$self->get_content_type;

  $self->set_user_agent( LWP::UserAgent->new )
    if !$self->get_user_agent;

  # legacy behavior is to never decode... :-(
  $self->set_decode_always( $self->get_decode_always || FALSE );

  # some APIs are GET only (I'm talkin' to you IAM!)
  $self->set_http_method( $self->get_http_method // 'POST' );

  $self->set_protocol( $self->get_protocol() // 'https' );

  # note some APIs are global, hence an API may send '' to indicate global
  $self->set_region( $self->get_region
      || $ENV{AWS_REGION}
      || $ENV{AWS_DEFAULT_REGION}
      || DEFAULT_REGION );

  $self->_set_url;

  if ( !$self->get_credentials ) {
    if ( $self->get_aws_secret_access_key && $self->aws_access_key_id ) {

      $self->set_credentials(
        Amazon::Credentials->new(
          { aws_secret_access_key => $self->get_aws_secret_access_key,
            aws_access_key_id     => $self->get_aws_access_key_id,
            token                 => $self->get_token
          }
        )
      );
    } ## end if ( $self->get_aws_secret_access_key...)
    else {
      $self->set_credentials( Amazon::Credentials->new() );
    }
  } ## end if ( !$self->get_credentials)


  $self->set_signer(
    Amazon::API::Signature4->new(
      -access_key     => $self->get_credentials->get_aws_access_key_id,
      -secret_key     => $self->get_credentials->get_aws_secret_access_key,
      -security_token => $self->get_credentials->get_token,
      service         => $self->get_api,
      region          => $self->get_region

    )
  );

  return $self;
} ## end sub _set_defaults

sub _set_url {
  my ($self) = @_;
  
  my $url = $self->get_url;
  
  if ( !$url ) {
    $url =
      $self->get_protocol
      . '://'
      . $self->get_service
      . '.'
      . $self->get_region
      . '.amazonaws.com';
  } ## end if ( !$self->get_url )
  else {
    
    if ( $url !~ /^https?/i ) {
      $url =~ s/^\///;    # just remove leading slash...
      $url = $self->get_protocol . '://' . $url;
    } ## end if ( $url !~ /^https?/i)
    
  } ## end else [ if ( !$self->get_url )]

  $self->set_url($url);
  
  return $self;
}

sub get_api_name {
  my $self = shift;
  
  return join("", map { ucfirst } split /_/, shift);
}

sub invoke_api {
  my ( $self, $action, $parameters, $content_type ) = @_;

  $self->set_action($action);
  $self->set_last_action($action);
  $self->set_error(undef);

  my $content;
  my $default_content_type = $self->get_content_type;
  
  # guessing game...if you do not provide a content type
  if ( !$content_type ) {
    
    if ( ref($parameters) && reftype($parameters) eq 'HASH' ) {
      $content_type = $default_content_type;
      $content      = encode_json($parameters);
    }
    elsif ( ref($parameters) && reftype($parameters) eq 'ARRAY' ) {
      $content_type = 'application/x-www-form-url-encoded'
        if $self->get_http_method ne 'GET';

      my @query_string;
      foreach ( @{$parameters} ) {
        push @query_string, ref($_) ? sprintf( "%s=%s", %$_ ) : $_;
      }

      $content = join( '&', @query_string );
    } ## end elsif ( ref($parameters) ...)
    else {
      $content_type = 'application/x-www-form-url-encoded'
        if $self->get_http_method ne 'GET';
      
      $content = $parameters;
    }
  } ## end if ( !$content_type )
  else {
    if ( $content_type =~ /json/ && ref($parameters) ) {
      $content = encode_json($parameters);
    }
    elsif ( $content_type =~ /json/ && !$parameters ) {
      $content = encode_json( {} );
    }
    else {
      $content = $parameters;
    }
  } ## end else [ if ( !$content_type ) ]

  my $rsp = $self->submit( content => $content, content_type => $content_type );

  $self->set_response($rsp);

  DEBUG(sub { Dumper([$rsp]); });

  if ( !$rsp->is_success ) {
    $self->set_error(
      Amazon::API::Error->new(
        { error        => $rsp->code,
          message_raw  => $rsp->content,
          content_type => $rsp->content_type,
          api          => ref($self),
          response     => $rsp
        }
      )
    );

    $self->print_error if $self->get_print_error;

    if ( $self->get_raise_error ) {
      die $self->print_error;
    }
    else {
      return;
    }
  } ## end if ( !$rsp->is_success)

  return $self->get_decode_always ? $self->decode_response : $rsp->content;
} ## end sub invoke_api

sub print_error {
  my $self = shift;

  my $error = $self->get_error;

  my $err_str = 'API ERROR (' . $self->get_last_action . '): ';
  
  if ( $error && ref($error) =~ /Amazon::API::Error/ ) {
    my $response = $error->get_response;
    
    $err_str .= sprintf( "[%s], %s,%s\n",
      $error->get_error, Dumper( ref($response) ? $response : [ $response] ),
      $error->get_message_raw);
  }
  else {
    $err_str .= '[' . $error . ']';
  }
  
  return $err_str;
}

sub decode_response {
  my ($self, $response) = @_;

  $response = $response || $self->get_response;
 
  my $result = eval {
      if ( $response && $response->content_type =~ /xml/i ) {
        XMLin( $response->content );
      }
      elsif ( $response && $response->content_type =~ /json/i ) {
        decode_json( $response->content );
      }
      elsif ($response) {
        $response->content;
      }
      else {
        $response;
      }
    };
  
  return $EVAL_ERROR ? $response->content : $result;
} ## end sub decode_response


sub _set_x_amz_target {
  my ( $self, $request ) = @_;

  my $api = $self->get_version ? $self->get_api . '_' . $self->get_version : $self->get_api;
  
  $self->set_target($api . '.' . $self->get_action );

  $request->header( 'X-Amz-Target', $self->get_target );

  return $request;
} ## end sub _set_x_amz_target


sub _set_request_content {
  my ( $self, %args ) = @_;

  my $request      = $args{request};
  my $content      = $args{content};
  my $content_type = $args{content_type} || $self->get_content_type;

  if ( $self->get_http_method ne 'GET' ) {
    $request->content_type($content_type);

    if ( $content_type eq 'application/x-www-form-url-encoded' ) {
      $content = $self->_set_form_url_encoded_string($content);
    }

    $request->content($content);
  } ## end if ( $self->get_http_method...)
  else {
    $content = $self->_set_form_url_encoded_string($content);

    $request->uri( $request->uri . '?' . $content );
  }

  return $request;
} ## end sub _set_request_content


sub submit {
  my ($self, %options) = @_;

  my $method = $self->get_http_method || 'POST';

  my $request = HTTP::Request->new( $method, $self->get_url );

  # 1. set the header
  # 2. set the content
  # 3. sign the request
  # 4. send the request & return result

  # see IMPLEMENTATION NOTES for an explanation
  if ( $self->get_api ) {
    $request = $self->_set_x_amz_target($request);
  }

  $self->_set_request_content( request => $request, %options );

  $request->header( 'X-Amz-Security-Token',
    $self->get_credentials->get_token )
    if $self->get_credentials->get_token;

  # sign the request
  $self->get_signer->sign( $request, $self->get_region );

  # make the request, return response object
  DEBUG( sub {  Dumper([$request]) });

  return $self->get_user_agent->request($request);
} ## end sub submit

sub _set_form_url_encoded_string {
  my ( $self, $content ) = @_;

  my @args = $content if $content;

  if ( $content && $content !~ /Action=/ || !$content ) {
    push @args, "Action=" . $self->get_action;
  }

  if ( $self->get_version ) {
    push @args, "Version=" . $self->get_version;
  }

  return @args ? join( '&', @args ) : '';
} ## end sub _set_form_url_encoded_string

=pod

=head1 NAME

C<Amazon::API>

=head1 SYNOPSIS

 package Amazon::CloudWatchEvents;

 use parent qw/Amazon::API/;

 @API_METHODS = qw/
		  DeleteRule
		  DescribeEventBus
		  DescribeRule
		  DisableRule
		  EnableRule
		  ListRuleNamesByTarget
		  ListRules
		  ListTargetsByRule
		  PutEvents
		  PutPermission
		  PutRule
		  PutTargets
		  RemovePermission
		  RemoveTargets
		  TestEventPattern/;

 sub new {
   my $class = shift;
 
   $class->SUPER::new(
     service       => 'events',
     api           => 'AWSEvents',
     api_methods   => \@API_METHODS,
     decode_always => 1
   );
 }

 1;

Then...

 my $rules = Amazon::CloudWatchEvents->new->ListRules;

=head1 DESCRIPTION

Generic class for constructing AWS API interfaces.  Typically used as
the parent class, but can be used directly.

=over 5

=item * See L</IMPLEMENTATION NOTES> for using C<Amazon::API>
directly to call AWS services.

=item * See C<Amazon::CloudWatchEvents> for an example of how to use
this module as a parent class.

=back

=head1 BACKGROUND AND MOTIVATION

A comprehensive Perl interface to AWS services similar to the I<boto>
library for Python has been a long time in coming. The PAWS project
has attempted to create an always up-to-date interface with community
support.  Some however may find that project a little heavy in the
dependency department. If you are looking for an extensible (albeit
spartan) method of invoking a subset of services without consuming all of
CPAN you might want to consider C<Amazon::API>.

=head1 THE APPROACH

Essentially, most AWS APIs are RESTful services that adhere to a
common protocol, but differences in services make a single solution
difficult. All services more or less adhere to this framework:

=over 5

=item 1. Set HTTP headers (or query string) to indicate the API and
method to be invoked

=item 2. Set credentials in the header

=item 3. Set API specific headers

=item 4. Sign the request and set the signature in the header

=item 5. Optionally send a payload of parameters for the method being invoked

=back

Specific details of the more recent AWS services are well
documented, however early services often deviated from some of these
patterns or included special parameters. This module attempts to
account for most if not all of those nuances and provide a fairly
generic way of invoking these APIs in the most lightweight way
possible.

Of course, I<you get what you pay for>, so you'll probably need to be
very familiar with the APIs you are calling and willng to invest time
reading the documentation on Amazon's website.  However, the payoff is
that you can probably use this class to call any AWS API and you won't
need to include all of CPAN to do so!

Think of this class as a DIY kit to invoke only the methods you need
for your AWS project. A good example of creating a quick
and dirty interface to CloudWatch Events can be found here:

L<Amazon::CloudWatchEvents|https://github.com/rlauer6/perl-Amazon-CloudWatchEvents>

And invoking an API could be as easy as:

  Amazon::API->new(
    service     => 'sqs',
    http_method => 'GET'
  }
  )->invoke_api('ListQueues');



=head1 ERRORS

If an error is encountered an exception class (C<Amazon::API::Error>)
will be raised if C<raise_error> has been set. Additionally, a
detailed error message will be displayed if C<print_error> is set to
true.

See L<Amazon::API::Error> for more details.

=head1 METHODS

=head2 new

 new( options )

Options describe below.  Can be a list or hash reference.

=over 5

=item action

The API method. Example: PutEvents

=item api (reqired)

The name of the AWS service.  Example: AWSEvents

=item api_methods

A reference to an array of method names for the API.  The new
constructor will create methods for each of the method names listed in
the array.

Keep in mind these methods are nothing more than stubs. Consult the
API documentation for the service to determine what parameters each
method requires.

=item aws_access_key_id

Your AWS access key.  Both the access key and secret access key are required.

=item aws_secret_access_key

Your AWS secret access key.

=item content_type

Default content for parameters passed to the C<invoke_api()> method.
The default is C<application/x-amz-json-1.1>.  If you are calling an
API that does not expect parameters (or all of them are optional and
you do not pass a parameter) the default is to pass an empty
hash.

  $cwe->ListRules();

would be equivalent to...

  $cwe->ListRules({});

I<CAUTION! This may not be what the API expects! Always consult
the AWS API for the service you are are calling.>

=item credentials (optional)

Accessing AWS services requires credentials with sufficient privileges
to make programmatic calls to the APIs that support a service.  This
module supports three ways that you can provide those credentials.

=over 5

=item 1. Pass the credentials (C<aws_access_key_id>,
C<aws_secret_access_key>, C<token>) keys directly. A session token is
typically required when you have assumed a role, your are using the
EC2's instance role or a container's role.

Pass the values for the credential key when call the C<new> method.

=item 2. Pass a class that will provide the credential keys.

Pass a reference to a class that has I<getters> for the credential
keys. The class should supply I<getters> for all three credential keys.

Pass the reference as C<credentials> in the constructor as shown here:

 my $api = Amazon::API->new(credentials => $credentials_class, ... );

=item 3. Use the default C<Amazon::Credentials> class.

If you don't explicitly pass credentials or pass a class that will
supply credentials, the module will use the C<Amazon::Credentials>
class that attempts to find credentials in the environment, your
credentials file, or the container or instance role.  See
L<Amazon::Credentials> for more details.

I<NOTE: The latter method of obtaining credentials is probably the easiest to
use and provides the most succinct and secure way of obtaining
credentials.>

=item debug

Set debug to a true value to enable debug messages. Debug mode will
dump the request and response from all API calls.

default: false

=item decode_always

Set C<decode_always> to a true value to return Perl objects from API
method calls. The default is to return the raw output from the call.
Typically, API calls will return either XML or JSON encoded objects.
Setting C<decode_always> will attempt to decode the content based on
the returned content type.

default: false

=item error

The most recent result of an API call. C<undef> indicates no error was
encountered the last time C<invoke_api> was called.

=item http_method

Sets the HTTP method used to invoke the API. Consult the AWS
documentation for each service to determine the method utilized. Most
of the more recent services utilize the POST method, however older
services like SQS or S3 utilize GET or a combination of methods
depending on the specific method being invoked.

default: POST

=item last_action

The last method call invoked.

=item print_error

Setting this value to true enables a detailed error message containing
the error code and any messages returned by the API when errors occur.

default: true

=item protocol

One of 'http' or 'https'.  Some Amazon services do not support https
(yet).

default: https

=item raise_error

Setting this value to true will raise an exception when errors
occur. If you set this value to false you can inspect the C<error>
attribute to determine the success or failure of the last method call.

 $api->invoke_api('ListQueues');

 if ( $api->get_error ) {
   ...
 }

default: true

=item region

The AWS region.

default: $ENV{AWS_REGION}, $ENV{AWS_DEFAULT_REGION}, 'us-east-1'

=item response

The HTTP response from the last API call.

=item service

The AWS service name. Example: C<sqs>. This value is used as a prefix
when constructing the the service URL (if not C<url> attribute is set).

=item service_url_base

Deprecated, use C<service>

=item signer

The class used to sign the request.  The default is
C<Amazon::API::Signature4> which is a subclass of
C<Amazon::Signature4>.

=item token

Session token for assumed roles.

=item url

The service url.  Example: https://events.us-east-1.amazonaws.com

Typically this will be constructed for you based on the region and the
service being invoked. You may want to set this manually if, for
example you using a local service like
<LocalStack|https://localstack.cloud/> that mocks AWS API calls.

 my $api = Amazon::API->new(service => 's3', url => 'localhost:4566/');

=item user_agent

Your own user agent object or by default C<LWP::UserAgent>.  Using
C<Furl>, if you have it avaiable may result in faster response.

=item version

Sets the API version.  Some APIs enable you to set the version.

=back

=head2 invoke_api

 invoke_api(action, [parameters, [content-type]]);

=over 5

=item action

=item parameters

Parameters to send to the API. Can be a scalar, a hash reference or an
array reference.

=item content-type

If you send the C<content-type>, it is assumed that the parameters are
the payload to be sent in the request.  Otherwise, the C<parameters>
will be converted to a JSON string if the C<parameters> value is a
hash reference or a query string if the C<parameters> value is an
array reference.

Hence, to send a query string, you should send an array key/value
pairs, or an array of scalars of the form Name=Value.

 [ { Action => 'DescribeInstances' } ]
 [ "Action=DescribeInstances" ]

...are both equivalent ways to force the method to send a query string.

=back

=head2 decode_response

Attempts to decode the most recent response from an invoked API based
on the I<Content-Type> header returned.  If there is no
I<Content-Type> header, then the method will try to decode it as JSON
or XML. If those fail, the raw content is returned.

You can enable decoded responses globally by setting the
C<decode_always> attribute.

=head2 submit

 submit( options )

This method is used internally by C<invoke_api> and normally should
not be called by your applications.

C<options> is hash of options:

=over 5

=item content

Payload to send.

=item content_type

Content types we have seen used to send values to AWS APIs:

 application/json
 application/x-amz-json-1.0
 application/x-amz-json-1.1
 application/x-www-form-urlencoded

=back


=head1 IMPLEMENTATION NOTES

=head2 X-Amz-Target

Most of the newer AWS APIs accept a header (X-Amz-Target) in lieu of
the CGI parameter I<Action>. Some APIs also want the version in the
target, some don't. There is sparse documentation about the nuances of
using the REST interface directly to call AWS APIs.

We use the C<api> value as a trigger to indicate we need to set the
Action in the X-Amz-Target header.  We also check to see if the
version needs to be attached to the Action value as required by some
APIs.

  if ( $self->get_api ) {
    if ( $self->get_version) {
      $self->set_target(sprintf("%s_%s.%s", $self->get_api, $self->get_version, $self->get_action));
    }
    else {
      $self->set_target(sprintf("%s.%s", $self->get_api, $self->get_action));
    }

    $request->header('X-Amz-Target', $self->get_target());
  }


DynamoDB & KMS seems to be able to use this in lieu of query variables
Action & Version, although again, there seems to be a lot of
inconsisitency in the APIs.  DynamoDB uses DynamoDB_YYYYMMDD.Action
while KMS will not take the version that way and prefers
TrentService.Action (with no version).  There is no explanation in any
of the documentations I have been able to find as to what
"TrentService" might actually mean.

In general, the AWS API ecosystem is very organic. Each service seems
to have its own rules and protocol regarding what the content of the
headers should be.

This generic API interface tries to make it possible to use a central
class (Amazon::API) as a sort of gateway to the APIs. The most generic
interface is simply sending query variables and not much else in the
header.  APIs like EC2 conform to that protocol, so as indicated
above, we use C<action> to determine whether to send the API action in
the header or to assume that it is being sent as one of the query
variables.

=head2 Rolling a New API 

The class will stub out methods for the API if you pass an array of
API method names.  The stub is equivalent to:

 sub some_api {
   my $self = shift;

   $self ->invoke_api('SomeApi', @_);
 }

Some will also be happy to know that the class will create an
equivalent I<CamelCase> version of the method.  If you choose to
override the method, you should override the snake case version of the
method.

As an example, here is a possible implementation of
C<Amazon::CloudWatchEvents> that implements one of the API calls.

 package Amazon::CloudWatchEvents;

 use parent qw/Amazon::API/;
 
 sub new {
   my $class = shift;
   my $options = shift || {};

   $options->{api} 'AWSEvents';
   $options->{url} 'https://events.us-east-1.amazonaws.com';
   $options->{api_methods} => [ 'ListRules' ];

   return $class->SUPER::new($options);
 }

 1;

Then...

  my $cwe = new Amazon::CloudWatchEvents();
  $cwe->ListRules({});

Of course, creating a class for the service is optional. It may be
desirable however to create higher level and more convenient methods
that aid the developer in utilizing a particular API.

 my $api = new Amazon::API(
   { credentials => new Amazon::Credentials,
     api         => 'AWSEvents',
     url         => 'https://events.us-east-1.amazonaws.com'
   }
 );

$api->invoke_api( 'ListRules', {} );

=head2 Content-Type

Yet another piece of evidence that suggests the I<organic> nature of
the Amazon API ecosystem is their use of multiple forms of input to
their methods indicated by the required I<Content-Type> for different
services.  Some of the variations include:

 application/json
 application/x-amz-json-1.0
 application/x-amz-json-1.1
 application/x-www-form-urlencoded

Accordingly, the C<invoke_api()> can be passed the I<Content-Type> or
will try to make "best guess" based on the input parameter you passed.
It guesses using the following decision tree:

=over 5

=item * If the Content-Type parameter is passed as the third argument,
that is used.  Full stop.

=item * If the C<parameters> value to C<invoke_api()> is a reference,
then the Content-Type is either the value of C<get_content_type> or
C<application/x-amzn-json-1.1>.

=item * If the C<parameters> value to C<invoke_api()> is a scalar,
then the Content-Type is C<application/x-www-form-urlencoded>.

=back

You can set the default Content-Type used for the calling service when
a reference is passed to the C<invoke_api()> method by passing the
C<content_type> option to the constructor. The default is
'application/x-amz-json-1.1'.

  $class->SUPER::new(
    content_type => 'application/x-amz-json-1.1',
    api          => 'AWSEvents',
    service      => 'events'
  );

=head1 SEE OTHER

C<Amazon::Credentials>, C<Amazon::API::Error>, C<AWS::Signature4>

=head1 AUTHOR

Rob Lauer - <rlauer6@comcast.net>

=cut

1;
