Group
Extension

Amazon-API/lib/Amazon/API.pm

package Amazon::API;

# Generic interface to Amazon APIs (powered by botocore)

use strict;
use warnings;

use 5.010;

our %LOG4PERL_LOG_LEVELS;

BEGIN {
  %LOG4PERL_LOG_LEVELS = eval {
    require Log::Log4perl::Level;

    Log::Log4perl::Level->import();

    use vars qw($ERROR $WARN $INFO $DEBUG $TRACE $FATAL);

    return (
      error => $ERROR,
      warn  => $WARN,
      info  => $INFO,
      debug => $DEBUG,
      trace => $TRACE,
      fatal => $FATAL,
    );
  };
}

use parent qw( Exporter Class::Accessor::Fast);

use Amazon::API::Error;
use Amazon::API::Signature4;
use Amazon::API::Constants qw( :all );
use Amazon::Credentials;
use Amazon::API::Botocore qw(paginator);
use Amazon::API::Botocore::Shape::Serializer;
use Amazon::API::Botocore::Shape::Utils qw(
  require_class
  require_shape
  create_module_name
  get_service_from_class
);

use Carp;
use Carp::Always;
use Data::Dumper;
use Date::Format;
use English qw( -no_match_vars);
use HTTP::Request;
use JSON qw( encode_json decode_json );
use LWP::UserAgent;
use List::Util qw(any all pairs none);
use Readonly;
use Scalar::Util qw( reftype blessed );
use Time::Local;
use Time::HiRes qw( gettimeofday tv_interval );
use URL::Encode qw(url_encode);
use XML::LibXML;
use XML::Simple;

Readonly::Scalar my $DEFAULT_REGION   => 'us-east-1';
Readonly::Scalar my $REGIONAL_URL_FMT => '%s://%s.%s.amazonaws.com';
Readonly::Scalar my $GLOBAL_URL_FMT   => '%s://%s.amazonaws.com';

Readonly::Scalar my $DEFAULT_LAYOUT_PATTERN => '[%d] %p (%R/%r) %M:%L - %m%n';

local $Data::Dumper::Pair  = $COLON;
local $Data::Dumper::Useqq = $TRUE;
local $Data::Dumper::Terse = $TRUE;

__PACKAGE__->follow_best_practice;

__PACKAGE__->mk_accessors(
  qw(
    action
    api
    api_methods
    botocore_metadata
    botocore_operations
    botocore_shapes
    content_type
    credentials
    debug
    decode_always
    endpoint_prefix
    error
    force_array
    http_method
    last_action
    _log_level
    logger
    log_file
    log_layout
    namespace
    paginators
    print_error
    protocol
    raise_error
    raise_serialization_errors
    region
    request_uri
    response
    service
    serializer
    service_url_base
    target
    target_prefix
    url
    use_botocore
    user_agent
    use_paginator
    version
  )
);

our $VERSION = '2.1.4';  ## no critic (RequireInterpolationOfMetachars)

our @EXPORT_OK = qw(
  create_urlencoded_content
  get_api_service
  param_n
  paginator
  service
  generate_xml
);

our $START_TIME    = [gettimeofday];
our $LAST_LOG_TIME = [gettimeofday];

=begin 'ignore'

The service categories were extracted from Botocore project:

grep -ri '"protocol":"' botocore/botocore/data/* | grep 'xml' | \
  cut -f 8 -d '/' | sort -u > xml.services

grep -ri '"protocol":"' botocore/botocore/data/* | grep 'json' | \
  cut -f 8 -d '/' | sort -u > json.services

grep -ri '"protocol":"' botocore/botocore/data/* | grep 'query' | \
  cut -f 8 -d '/' | sort -u > query.services

These service categories are used to deduce content type for
parameters sent to methods for these APIs when Botocore metadata was
not used to create an API class. Content-Type can however be
overridden when invoking APIs if we guess wrong.

 rest-json => application/x-amz-1.1
 rest-xml  => application/xml
 query     => application/x-www-form-urlencoded

=end 'ignore'

=cut

our %SERVICE_CONTENT_TYPES = (
  ec2         => 'application/x-www-form-urlencoded',
  query       => 'application/x-www-form-urlencoded',
  'rest-json' => 'application/json',
  json        => 'application/x-amz-json',
  'rest-xml'  => 'application/xml',
);

our %API_TYPES = (
  query => [
    qw(
      autoscaling
      cloudformation
      cloudsearch
      cloudwatch
      docdb
      ec2
      elasticache
      elasticbeanstalk
      elb
      elbv2
      iam
      importexport
      neptune
      rds
      redshift
      sdb
      ses
      sns
      sts
    )
  ],
  xml => [
    qw(
      cloudfront
      route53
      s3
      s3control
    )
  ],
  json => [
    qw(
      accessanalyzer
      account
      acm
      acm-pca
      amp
      amplify
      amplifybackend
      amplifyuibuilder
      apigateway
      apigatewaymanagementapi
      apigatewayv2
      appconfig
      appconfigdata
      appfabric
      appflow
      appintegrations
      application-autoscaling
      application-insights
      application-signals
      applicationcostprofiler
      appmesh
      apprunner
      appstream
      appsync
      apptest
      arc-zonal-shift
      artifact
      athena
      auditmanager
      autoscaling-plans
      b2bi
      backup
      backup-gateway
      backupsearch
      batch
      bcm-data-exports
      bcm-pricing-calculator
      bedrock
      bedrock-agent
      bedrock-agent-runtime
      bedrock-data-automation
      bedrock-data-automation-runtime
      bedrock-runtime
      billing
      billingconductor
      braket
      budgets
      ce
      chatbot
      chime
      chime-sdk-identity
      chime-sdk-media-pipelines
      chime-sdk-meetings
      chime-sdk-messaging
      chime-sdk-voice
      cleanrooms
      cleanroomsml
      cloud9
      cloudcontrol
      clouddirectory
      cloudfront-keyvaluestore
      cloudhsm
      cloudhsmv2
      cloudsearchdomain
      cloudtrail
      cloudtrail-data
      codeartifact
      codebuild
      codecatalyst
      codecommit
      codeconnections
      codedeploy
      codeguru-reviewer
      codeguru-security
      codeguruprofiler
      codepipeline
      codestar-connections
      codestar-notifications
      cognito-identity
      cognito-idp
      cognito-sync
      comprehend
      comprehendmedical
      compute-optimizer
      config
      connect
      connect-contact-lens
      connectcampaigns
      connectcampaignsv2
      connectcases
      connectparticipant
      controlcatalog
      controltower
      cost-optimization-hub
      cur
      customer-profiles
      databrew
      dataexchange
      datapipeline
      datasync
      datazone
      dax
      deadline
      detective
      devicefarm
      devops-guru
      directconnect
      discovery
      dlm
      dms
      docdb-elastic
      drs
      ds
      ds-data
      dsql
      dynamodb
      dynamodbstreams
      ebs
      ec2-instance-connect
      ecr
      ecr-public
      ecs
      efs
      eks
      eks-auth
      elastictranscoder
      emr
      emr-containers
      emr-serverless
      entityresolution
      es
      events
      evidently
      finspace
      finspace-data
      firehose
      fis
      fms
      forecast
      forecastquery
      frauddetector
      freetier
      fsx
      gamelift
      gameliftstreams
      geo-maps
      geo-places
      geo-routes
      glacier
      globalaccelerator
      glue
      grafana
      greengrass
      greengrassv2
      groundstation
      guardduty
      health
      healthlake
      identitystore
      imagebuilder
      inspector
      inspector-scan
      inspector2
      internetmonitor
      invoicing
      iot
      iot-data
      iot-jobs-data
      iot-managed-integrations
      iotanalytics
      iotdeviceadvisor
      iotevents
      iotevents-data
      iotfleethub
      iotfleetwise
      iotsecuretunneling
      iotsitewise
      iotthingsgraph
      iottwinmaker
      iotwireless
      ivs
      ivs-realtime
      ivschat
      kafka
      kafkaconnect
      kendra
      kendra-ranking
      keyspaces
      kinesis
      kinesis-video-archived-media
      kinesis-video-media
      kinesis-video-signaling
      kinesis-video-webrtc-storage
      kinesisanalytics
      kinesisanalyticsv2
      kinesisvideo
      kms
      lakeformation
      lambda
      launch-wizard
      lex-models
      lex-runtime
      lexv2-models
      lexv2-runtime
      license-manager
      license-manager-linux-subscriptions
      license-manager-user-subscriptions
      lightsail
      location
      logs
      lookoutequipment
      lookoutmetrics
      lookoutvision
      m2
      machinelearning
      macie2
      mailmanager
      managedblockchain
      managedblockchain-query
      marketplace-agreement
      marketplace-catalog
      marketplace-deployment
      marketplace-entitlement
      marketplace-reporting
      marketplacecommerceanalytics
      mediaconnect
      mediaconvert
      medialive
      mediapackage
      mediapackage-vod
      mediapackagev2
      mediastore
      mediastore-data
      mediatailor
      medical-imaging
      memorydb
      meteringmarketplace
      mgh
      mgn
      migration-hub-refactor-spaces
      migrationhub-config
      migrationhuborchestrator
      migrationhubstrategy
      mq
      mturk
      mwaa
      neptune-graph
      neptunedata
      network-firewall
      networkflowmonitor
      networkmanager
      networkmonitor
      notifications
      notificationscontacts
      oam
      observabilityadmin
      omics
      opensearch
      opensearchserverless
      opsworks
      opsworkscm
      organizations
      osis
      outposts
      panorama
      partnercentral-selling
      payment-cryptography
      payment-cryptography-data
      pca-connector-ad
      pca-connector-scep
      pcs
      personalize
      personalize-events
      personalize-runtime
      pi
      pinpoint
      pinpoint-email
      pinpoint-sms-voice
      pinpoint-sms-voice-v2
      pipes
      polly
      pricing
      privatenetworks
      proton
      qapps
      qbusiness
      qconnect
      qldb
      qldb-session
      quicksight
      ram
      rbin
      rds-data
      redshift-data
      redshift-serverless
      rekognition
      repostspace
      resiliencehub
      resource-explorer-2
      resource-groups
      resourcegroupstaggingapi
      robomaker
      rolesanywhere
      route53-recovery-cluster
      route53-recovery-control-config
      route53-recovery-readiness
      route53domains
      route53profiles
      route53resolver
      rum
      s3outposts
      s3tables
      sagemaker
      sagemaker-a2i-runtime
      sagemaker-edge
      sagemaker-featurestore-runtime
      sagemaker-geospatial
      sagemaker-metrics
      sagemaker-runtime
      savingsplans
      scheduler
      schemas
      secretsmanager
      security-ir
      securityhub
      securitylake
      serverlessrepo
      service-quotas
      servicecatalog
      servicecatalog-appregistry
      servicediscovery
      sesv2
      shield
      signer
      simspaceweaver
      sms
      sms-voice
      snow-device-management
      snowball
      socialmessaging
      sqs
      ssm
      ssm-contacts
      ssm-incidents
      ssm-quicksetup
      ssm-sap
      sso
      sso-admin
      sso-oidc
      stepfunctions
      storagegateway
      supplychain
      support
      support-app
      swf
      synthetics
      taxsettings
      textract
      timestream-influxdb
      timestream-query
      timestream-write
      tnb
      transcribe
      transfer
      translate
      trustedadvisor
      verifiedpermissions
      voice-id
      vpc-lattice
      waf
      waf-regional
      wafv2
      wellarchitected
      wisdom
      workdocs
      workmail
      workmailmessageflow
      workspaces
      workspaces-thin-client
      workspaces-web
      xray
    )
  ],
);

our @GLOBAL_SERVICES = qw(
  cloudfront
  iam
  importexport
  route53
  s3
  savingsplans
  sts
);

our @REQUIRED_KEYS = qw( aws_access_key_id aws_secret_access_key );

########################################################################
sub new {
########################################################################
  my ( $class, @options ) = @_;

  $class = ref $class || $class;

  my %options = ref $options[0] ? %{ $options[0] } : @options;

  my $log_level = delete $options{log_level};
  $log_level //= 'error';

  my $self = $class->SUPER::new( \%options );

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

  croak 'service is required'
    if !$self->get_service;

  $self->_set_default_logger($log_level);

  $self->_set_defaults(%options);

  $self->init_log_level( log_level => $log_level, debug => $options{debug} );

  $self->_create_methods;

  if ( $self->is_botocore_api ) {
    $self->set_serializer(
      Amazon::API::Botocore::Shape::Serializer->new( service => get_service_from_class( ref $self ) ) );
  }

  return $self;
}

########################################################################
sub set_log_level {
########################################################################
  my ( $self, $log_level ) = @_;

  $self->set__log_level($log_level);

  if ( Log::Log4perl->initialized() && $self->get_logger ) {
    $self->get_logger->level( $LOG4PERL_LOG_LEVELS{$log_level} );
  }
  else {
    my $logger = $self->get_logger;

    if ( $logger->can('level') ) {
      $logger->level($log_level);
    }
  }

  return $self;
}

########################################################################
sub get_log_level {
########################################################################
  my ($self) = @_;

  return $self->get__log_level();
}

########################################################################
sub service { goto &get_api_service; }
########################################################################

########################################################################
sub get_api_service {
########################################################################
  my ( $service_name, @args ) = @_;

  $service_name = create_module_name $service_name;

  my $class = sprintf 'Amazon::API::%s', $service_name;

  require_class $class;

  return $class->new(@args);
}

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

  $serialize //= $TRUE;

  $response = $response || $self->get_response;

  # could be Furl or HTTP?
  if ( !ref $response && $response->can('content') ) {
    croak q{can't decode response - not a response object: } . ref $response;
  }

  my $content = $response->content;

  my $content_type = $response->content_type;

  # this is expected to fail if !$self->is_botocore_api
  my ( $protocol, $botocore_action )
    = eval { return ( $self->get_botocore_metadata->{protocol}, $self->get_botocore_action ); };

  TRACE(
    sub {
      return Dumper(
        [ protocol         => $protocol,
          response_content => $content
        ]
      );
    }
  );

  my $decoded_content;

  if ($content) {

    $decoded_content = eval {
      if ( $content_type =~ /json/xmsi ) {
        decode_json($content);
      }
      elsif ( $content_type =~ /xml/xmsi ) {
        ################################################################
        # Maddening interpretation of XML output from these ass hats (-:|3
        #
        # A little more explanation: some rest-xml and ec2 protocol
        # API responses have wrapper elements that are not part of the
        # shape description...some do. It probably would have been
        # best not to try to serialize this XML object here, but
        # rather downstream when trying to create shapes from
        # it. However, the serializer now tries to figure out whether
        # to disgard the wrapper or keep it. The botocore metadata
        # could have offered some clues, however I believe the
        # serializers in Botocore have been tuned to specific APIs in
        # some cases making it difficult to fix legacy mistakes in
        # these APIs.
        ################################################################
        XMLin(
          $content,
          KeepRoot      => $TRUE,
          SuppressEmpty => $FALSE,
          ForceContent  => $self->is_botocore_api && $botocore_action->{output},
          ForceArray    => ['item'],
          #          $self->get_force_array ? ( ForceArray => ['item'] ) : ()
        );
      }
    };

    # disregard content_type (it might be misleading?)
    # this is almost certainly going to be a problem somewhere...
    if ( !$decoded_content || $EVAL_ERROR ) {

      WARN("unable to decode content: $EVAL_ERROR");
      WARN('Trying again using JSON and XML decoders.');

      $decoded_content = eval { return decode_json($content); };

      if ( !$decoded_content || $EVAL_ERROR ) {
        $decoded_content = eval {
          return XMLin(
            $content,
            SuppressEmpty => 0,  # $protocol eq 'ec2' ? $FALSE : $TRUE,
            $self->get_force_array ? ( ForceArray => ['item'] ) : ()
          );
        };
      }
    }
  }

  $content = $decoded_content || $content;

  DEBUG( sub { return Dumper( [ 'content' => $content ] ) } );

  # we'll only have a "serializer" if this is a Botocore generated API
  my $serializer = $self->get_serializer;

  return $content
    if !ref $content || !$serializer || !$serialize;

  my $output = $botocore_action->{output};

  return $decoded_content
    if !$output;

  my $orig_content = $content;

  if ( $output->{resultWrapper} ) {
    $content = $content->{ $output->{resultWrapper} };
  }

  DEBUG(
    sub {
      return Dumper(
        [ content                => $content,
          botocore_action_output => $output
        ]
      );
    }
  );

  $serializer->set_logger( $self->get_logger );

  $content = eval {
    $serializer->serialize(
      service => get_service_from_class( ref $self ),
      shape   => $output->{shape},
      data    => $content
    );
  };

  # ...but this isn't necessarily where things STB
  if ( !$content || $EVAL_ERROR ) {
    if ( $self->get_raise_serialization_errors ) {
      die $EVAL_ERROR;
    }
    elsif ($EVAL_ERROR) {
      carp "error serializing content: please report this error\n$EVAL_ERROR";
      $content = $orig_content;
    }
  }

  return $content;
}

########################################################################
sub get_botocore_action {
########################################################################
  my ($self) = @_;

  return $self->get_botocore_operations->{ $self->get_action };
}

########################################################################
sub is_botocore_shape {
########################################################################
  my ($request) = @_;

  my $shape_name = ref $request;

  if ( $shape_name =~ /Botocore::Shape::([^:]+)::([^:]+)$/xsm ) {
    $shape_name = $2;
  }
  else {
    $shape_name = undef;
  }

  return $shape_name;
}

# returns the 'locationName' of the element or '' locationName for a
# given parameter type (uri, querystring, etc)
#
# determines where to find the parameter in the input payload

########################################################################
sub is_param_type {
########################################################################
  my ( $self, $shape_name, $param, $type ) = @_;

  my $members = $self->get_botocore_shapes->{$shape_name}->{members};
  my $member  = $members->{$param};

  my $location = $member->{location};

  TRACE(
    sub {
      return Dumper(
        [ members    => $members,
          member     => $member,
          location   => $location,
          param      => $param,
          type       => $type,
          shape_name => $shape_name
        ]
      );
    }
  );

  return ( $location && $location eq $type ) ? $member->{locationName} : $EMPTY;
}

########################################################################
sub is_query_param {
########################################################################
  my ( $self, $shape_name, $param ) = @_;

  return $self->is_param_type( $shape_name, $param, 'querystring' );
}

########################################################################
sub is_uri_param {
########################################################################
  my ( $self, $shape_name, $param ) = @_;

  return $self->is_param_type( $shape_name, $param, 'uri' );
}

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

  my ( $parameters, $action ) = @args{qw(parameters action)};

  $action //= $self->get_action;

  croak 'no action'
    if !$action;

  croak 'no parameters'
    if !$parameters;

  croak 'not a botocore API'
    if !$self->is_botocore_api($action);

  my $botocore_operations = $self->get_botocore_operations->{$action};

  my $input = $botocore_operations->{input};
  my $shape = $input->{shape};

  my $class = require_shape( $shape, get_service_from_class($self) );

  croak "could not create request shape: $shape\n$EVAL_ERROR"
    if !$class;

  my $request = $class->new($parameters);  # request class

  return $request;
}

########################################################################
# init_botocore_request( $self, $request)
########################################################################

# This function will accept either an object which is a sub-class of
# Amazon::API::Botocore::Shape, or a hash if the parameters have been
# constructed "by-hand",
#
# The parameters are used to populate both the URL if some parameters
# are passed in the URL and either a JSON or XML payload depending on
# the API type (rest-json, rest-xml).

########################################################################
sub find_content_type {
########################################################################
  my ($self) = @_;
  my $protocol = $self->get_botocore_metadata->{protocol};

  my $content_type = $SERVICE_CONTENT_TYPES{$protocol};

  if ( $protocol eq 'json' ) {
    $content_type = sprintf '%s-%s', $content_type, $self->get_botocore_metadata->{jsonVersion};
  }
  return $content_type;
}

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

  my $metadata = $self->get_botocore_metadata;

  my $protocol = $metadata->{protocol};

  my $content_type = $self->find_content_type() // $EMPTY;
  $self->set_content_type($content_type);

  $request //= {};

  my $action = $self->get_action;

  my $botocore_operations = $self->get_botocore_operations->{$action};

  my $http = $botocore_operations->{http};

  my $method = $http->{method};

  TRACE(
    sub {
      return Dumper(
        [ request             => $request,
          protocol            => $protocol,
          action              => $action,
          method              => $method,
          content_type        => $content_type,
          botocore_metadata   => $metadata,
          botocore_operations => $botocore_operations,
        ]
      );
    }
  );

  my $input = $botocore_operations->{input};

  my $shape = $input->{shape};

  my $request_shape_name = is_botocore_shape($request);

  # if a shape object is passed, it must be the correct type
  croak "$action requires a $shape object, not a $request_shape_name object"
    if $request_shape_name && $request_shape_name ne $shape;

  # try to create a Botocore request shape
  my $boto_request;

  if ( !$request_shape_name && $self->is_botocore_api ) {

    $boto_request = $self->create_botocore_request( parameters => $request );

    if ( !$boto_request ) {
      croak "could not create a botocore request object\n$EVAL_ERROR\n";
    }
    else {
      $request_shape_name = is_botocore_shape($boto_request);

      $request = $boto_request;
    }
  }

  # some XML requests require an xmlns attribute
  $self->find_namespace( $request, $input );

  my %parameters;

  # is the request a Botocore::Shape object? if so we can use metadata
  # to create URI and payload, otherwise it's up to the caller to make
  # sure the URI and the payload are correct...good luck!

  if ( !$request_shape_name ) {
    %parameters = %{$request};
  }
  else {
    my $finalized_request = $request->finalize;

    DEBUG(
      sub {
        return Dumper(
          [ request           => $request,
            finalized_request => $finalized_request,
          ]
        );
      }
    );

    if ( $protocol =~ /rest\-(xml|json)/xsm ) {
      $finalized_request = { $request_shape_name => $finalized_request };
    }
    elsif ( $protocol eq 'ec2' ) {
      $finalized_request = [ param_n($finalized_request) ];

      $self->set_http_method($method);

      return $finalized_request;
    }

    DEBUG(
      sub {
        return Dumper( [ finalized_request => $finalized_request, ] );
      }
    );

    if ( ref($finalized_request) eq 'HASH' ) {
      %parameters = %{$finalized_request};
    }
  }

  $self->set_http_method($method);

  my $uri;

  if ( $protocol =~ /^rest\-(json|xml)/xsm ) {
    my @args = @{ $http->{parsed_request_uri}->{parameters} };

    my $request_uri_tpl = $http->{parsed_request_uri}->{request_uri_tpl};

    DEBUG(
      sub {
        return Dumper(
          [ args               => \@args,
            request_uri_tpl    => $request_uri_tpl,
            request_shape_name => $request_shape_name,
            input              => $input,
          ]
        );
      }
    );

    # if the request is a shape, we've already checked for required
    # parameters but some may be buried in the payload!
    if ( !$request_shape_name ) {
      foreach my $p (@args) {
        croak 'required parameter ' . $p . ' not found.'
          if !exists $parameters{$p};
      }

      $uri = sprintf $request_uri_tpl, @parameters{@args};
      $self->set_request_uri($uri);

      delete @parameters{@args};
    }
    else {
      $uri = $http->{requestUri};  # use the Botocore template

      my $shape_parameters = $parameters{$shape};

      DEBUG(
        sub {
          return Dumper(
            [ requestUri       => $uri,
              shape_parameters => $shape_parameters,
              shape            => $shape,
              parameters       => \%parameters,
            ]
          );
        }
      );

      foreach my $p ( keys %{$shape_parameters} ) {
        TRACE( Dumper( [ parameter => $p ] ) );

        if ( my $var = $self->is_uri_param( $request_shape_name, $p ) ) {

          my $val = $shape_parameters->{$p};

          TRACE(
            Dumper(
              [ var => $var,
                val => $val,
              ]
            )
          );

          $uri =~ s/[{]$var[}]/$val/xsm;

          delete $shape_parameters->{$p};
        }
      }

      # we're not done yet...just to make things interesting, some
      # APIs embed request parameters in the uri, payload AND query
      # string!

      if ($request_shape_name) {
        my %query_parameters;

        my $shape_parameters = $parameters{$shape};

        foreach my $p ( keys %{$shape_parameters} ) {
          if ( my $var = $self->is_query_param( $request_shape_name, $p ) ) {
            $query_parameters{$var} = $shape_parameters->{$p};
            delete $shape_parameters->{$p};
          }
        }

        if ( keys %query_parameters ) {
          # $self->set_content_type(undef);

          $uri = sprintf '%s?%s', $uri, create_urlencoded_content( \%query_parameters );
        }

        if ( !keys %{$shape_parameters} ) {
          %parameters = ();
        }
      }
    }

    $self->set_request_uri($uri);

    # payload (I think) tells us where the actual object to pass will
    # be found in the request object. Since we may be formatting an
    # XML request we need to insert the namespace so our formatter can
    # properly serialize the request
    if ( $request->{payload} && $parameters{$request_shape_name}->{ $request->{payload} } ) {
      %parameters = %{ $parameters{$request_shape_name} };
      if ( $self->get_namespace ) {
        $parameters{ $request->{payload} }->{_attr} = { xmlns => $self->get_namespace };
      }
    }
    elsif ( $self->get_namespace ) {
      my $locationName = $input->{locationName};
      $parameters{$locationName}->{_attr} = { xmlns => $self->get_namespace };
    }

  }

  my $content = \%parameters;

  if ( $method ne 'POST' && !keys %parameters ) {
    $content = undef;
  }

  DEBUG(
    sub {
      return Dumper [
        namespace   => $self->get_namespace,
        parameters  => \%parameters,
        request_uri => $uri,
        content     => $content,
      ];
    }
  );

  return $content;
}

########################################################################
# namespaces may be buried a bit in the members specfication of
# request objects or can be part of the specification for the request
# method itself (cloudfront.CreateInvalidationBatch)
########################################################################
sub find_namespace {
########################################################################
  my ( $self, $request, $input ) = @_;

  my ($has_namespace) = grep { defined $_ }
    map { $request->{members}->{$_}->{xmlNamespace}->{uri} } keys %{ $request->{members} // {} };

  if ( !$has_namespace && $input->{xmlNamespace} ) {
    $has_namespace = $input->{xmlNamespace}->{uri};
  }

  $self->set_namespace($has_namespace);

  return;
}

########################################################################
sub is_botocore_api {
########################################################################
  my ($self) = @_;

  return defined $self->get_botocore_metadata;
}

########################################################################
sub serialize_content {
########################################################################
  my ( $self, $parameters ) = @_;

  my $content      = $parameters;
  my $action       = $self->get_action;
  my $version      = $self->get_version;
  my $content_type = $self->get_content_type;

  TRACE(
    sub {
      return Dumper(
        [ content_type => $content_type,
          parameters   => $parameters,
          service      => $self->get_service,
        ]
      );
    }
  );

  # if the API is a query API, url encode parameters
  if ( any { $_ eq lc $self->get_service } @{ $API_TYPES{query} } ) {
    $parameters //= [];
    $content = create_urlencoded_content( $parameters, $action, $version );
  }
  elsif ( $parameters && ref $parameters && reftype($parameters) eq 'HASH' ) {
    if ( $content_type =~ /json/xsm ) {
      delete $parameters->{_attr};
      $content = encode_json($parameters);
    }
    elsif ( $content_type =~ /xml/xms ) {
      return
        if !ref $content || !keys %{$content};

      $content = generate_xml($parameters);
    }
  }

  return $content;
}

########################################################################
# invoke_api( action, parameters, content-type, headers)
########################################################################
sub invoke_api {
########################################################################
  my ( $self, @args ) = @_;

  DEBUG(
    sub {
      return Dumper( [ args => \@args ] );
    }
  );

  my ( $action, $parameters, $content_type, $headers );

  # use_botocore overrides the creation of a Botocore request
  # object. In that case the arguments are expected to conform to the
  # format specified for this particular AWS API.

  if ( ref $args[0] && reftype( $args[0] ) eq 'HASH' ) {
    ( $action, $parameters, $content_type, $headers )
      = @{ $args[0] }{qw( action parameters content_type headers)};
  }
  else {
    ( $action, $parameters, $content_type, $headers ) = @args;
  }

  my $use_botocore;

  if ( $parameters && !ref $parameters ) {
    $use_botocore = $FALSE;
  }
  elsif ( $parameters && reftype($parameters) ne 'HASH' ) {
    $use_botocore = $FALSE;
  }
  else {
    $use_botocore = $self->is_botocore_api;
  }

  my $protocol = eval { $self->get_botocore_metadata->{protocol}; };

  DEBUG(
    sub {
      return Dumper(
        [ parameters      => $parameters,
          'content-type'  => $content_type,
          protocol        => $protocol,
          use_botocore    => $use_botocore,
          is_botocore_api => $self->is_botocore_api,
        ]
      );
    }
  );

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

  my $decode_response = $self->get_decode_always;

  # someone is trying to send a blessed object, possibly a Botocore
  # request object using a class that does not have the Botocore
  # metadata
  croak sprintf qq{"%s" was not generated with Botocore support.\n}
    . qq{Parameters should be simple objects, not blessed.\n}, ref $self
    if blessed($parameters) && !$self->is_botocore_api;

  my @paged_results;

  # auto pagination is only available when using the Botocore metadata
  my ( $paginator, $use_paginator, $limit );

  if ( $use_botocore && $self->get_use_paginator ) {
    $paginator     = $self->get_paginators && $self->get_paginators->{$action};
    $use_paginator = $paginator;
  }

  # if we're using the Botocore metadata, create a request object
  if ($use_botocore) {
    if ($paginator) {

      $paginator->{more_results} //= $paginator->{output_token};

      if ( $paginator->{limit_key} ) {
        $limit = $parameters->{ $paginator->{limit_key} };
      }

      DEBUG(
        sub {
          return Dumper(
            [ paginator  => $paginator,
              limit      => $limit,
              parameters => $parameters,
            ]
          );
        }
      );
    }

    $parameters = $self->init_botocore_request($parameters);

    $content_type = $self->get_content_type;

    DEBUG(
      sub {
        return Dumper(
          [ parameters   => $parameters,
            content_type => $content_type
          ]
        );
      }
    );

  }
  elsif ( !$content_type ) {
    $content_type = $self->set_content_type( $self->_set_content_type );
  }

  if ( !$parameters && !$use_botocore && $content_type =~ /json/xsm ) {
    $parameters = {};
  }

  my $serialized_content = $self->serialize_content($parameters);

  DEBUG(
    sub {
      Dumper [
        content_type       => $content_type,
        parameters         => $parameters,
        serialized_content => $serialized_content
      ];
    }
  );

  my $page_count = 0;

  PAGINATE:

  ++$page_count;

  DEBUG( sub { return Dumper( [ page => $page_count ] ) } );

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

  $self->set_response($rsp);

  if ( !$rsp->is_success ) {

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

    if ( $self->get_print_error && !$self->get_raise_error ) {
      print {*STDERR} $self->get_error;
    }
    elsif ( $self->get_raise_error ) {
      die $self->get_error;
    }
  }

  DEBUG(
    sub {
      return Dumper(
        [ decode_always => $self->get_decode_always,
          content       => $rsp->content
        ]
      );
    }
  );

  return $rsp->content
    if !$self->get_decode_always;

  eval {
    if ($use_paginator) {
      my $result = $self->decode_response;

      DEBUG(
        sub {
          return Dumper(
            [ paginator     => $paginator,
              paged_results => @paged_results,
              result        => $result
            ]
          );
        }
      );

      my $actual_result = dig( $result, $paginator, 'result_key' );

      return \@paged_results
        if !$actual_result;

      DEBUG(
        sub {
          return Dumper( [ result => $result, ] );
        }
      );

      push @paged_results, @{$actual_result};

      DEBUG(
        sub {
          return Dumper(
            [ paged_results => \@paged_results,
              more_results  => $paginator->{more_results},
              paginator     => $paginator,
            ]
          );
        }
      );

      if ( !dig( $result, $paginator, 'more_results' ) ) {
        # might have to dig here too, but generally I don't think so
        if ( $paginator->{limit_key} ) {
          delete $result->{ $paginator->{limit_key} };
        }

        return \@paged_results;
      }

      $limit = $limit // $result->{ $paginator->{limit_key} };

      my $parameters = $self->init_botocore_request(
        { $limit ? ( $paginator->{limit_key} => $limit ) : (),
          $paginator->{input_token} => dig( $result, $paginator, 'output_token' ),
        }
      );

      $serialized_content = $self->serialize_content($parameters);

      goto PAGINATE;
    }
  };

  if ($EVAL_ERROR) {
    if ( $self->get_raise_serialization_errors ) {
      die $EVAL_ERROR;
    }
    else {
      warn "error serializing content: please report this error\n$EVAL_ERROR";
      return $rsp->content;
    }
  }

  return bury( \@paged_results, $paginator, 'result_key' )
    if $use_paginator;

  my $results = $self->decode_response;

  # some APIs return no results, but we probably do not want to return
  # undef
  if ( !$results && $rsp->is_success ) {
    $results = {};
  }

  return $results
    if !$paginator || !$use_paginator;

  # you must have a paginator, but told me not to use it, delete
  # blank/null markers

  # TODO: figure out the comment above because it does not match the code
  # 'use_paginator' is tested above and if it is false (you told me not to use the
  # paginator) we return the results...

  if ( !dig( $results, $paginator, 'more_results' ) ) {
    delete $results->{ $paginator->{input_token} };
    delete $results->{ $paginator->{limit_key} };
  }

  return $results;
}

########################################################################
# the analog to dig, we need to set the result to a hash element specified
# by a dot encoded string. Example: DistributionList.Item
########################################################################
sub bury {
########################################################################
  my ( $result, $paginator, $key ) = @_;

  return $result
    if !$paginator;

  $key = $paginator->{$key};

  return { $key => $result }
    if $key !~ /[.]/xsm;

  my $parent = {};
  my $child  = $parent;

  my @keys       = split /[.]/xsm, $key;
  my $result_key = pop @keys;  # remove last element

  foreach (@keys) {
    $child->{$_} = {};
    $child = $child->{$_};
  }

  $child->{$result_key} = $result;

  return $parent;
}

########################################################################
# follow the . encoded key to find the hash element
# example: DistributionList.Items
########################################################################
sub dig {
########################################################################
  my ( $result, $paginator, $key, $delete ) = @_;

  return $result
    if !$paginator || !$paginator->{$key};

  $key = $paginator->{$key};

  return $result->{$key}
    if $key !~ /[.]/xsm;

  foreach ( split /[.]/xsm, $key ) {
    $result = $result->{$_};
    $key    = $_;
  }

  if ($delete) {
    delete $result->{$key};
  }

  return $result;
}

########################################################################
sub print_error {
########################################################################
  my ($self) = @_;

  my $error = eval {
    my $last_error = $self->get_error;

    return "$last_error"
      if $last_error && ref($last_error) =~ /Amazon::API::Error/xms;

    return $last_error;
  };

  $error //= $EVAL_ERROR;

  if ($error) {
    print {*STDERR} $error;
  }

  return $error;
}

########################################################################
sub get_valid_token {
########################################################################
  my ($self) = @_;

  my $credentials = $self->get_credentials;

  return
    if !$credentials->get_token;

  if ( $credentials->can('is_token_expired') ) {
    if ( $credentials->is_token_expired ) {

      if ( !$credentials->can('refresh_token') || !$credentials->refresh_token ) {
        croak 'token expired';
      }
    }
  }

  TRACE( sub { return Dumper( [ 'valid token:', $credentials->get_token ] ) } );

  return $credentials->get_token;
}

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

  DEBUG( sub { return Dumper [ submit => \%options ] } );

  my $method  = $self->get_http_method || 'POST';
  my $headers = $options{'headers'}    || [];

  my $url = $self->get_url;

  my $botocore_protocol = eval { $self->get_botocore_metadata->{'protocol'} } // $EMPTY;

  if ( $botocore_protocol =~ /^rest\-(json|xml)/xsm ) {
    croak 'no request URI provided for rest-json call'
      if !$self->get_request_uri;

    $url .= $self->get_request_uri;
  }

  DEBUG(
    sub {
      return Dumper [
        method  => $method,
        url     => $url,
        headers => $headers
      ];
    }
  );

  my $request = HTTP::Request->new( $method, $url, $headers );

  # 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 || $self->get_target_prefix ) {
    $request = $self->_set_x_amz_target($request);
  }

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

  if ( my $token = $self->get_valid_token ) {
    $request->header( 'X-Amz-Security-Token' => $token );
  }

  # TODO: global end-points
  my $region = $self->get_region;

  # sign the request
  Amazon::API::Signature4->new(
    -access_key     => $credentials->get_aws_access_key_id,
    -secret_key     => $credentials->get_aws_secret_access_key,
    -security_token => $credentials->get_token || undef,
    service         => $self->get_service,
    region          => $region,
  )->sign( $request, $self->get_region );

  DEBUG( sub { return Dumper( [ request => $request ] ) } );

  # make the request, return response object
  my $ua  = $self->get_user_agent;
  my $rsp = $ua->request($request);

  DEBUG(
    sub {
      return Dumper [ response => $rsp ];
    }
  );

  return $rsp;
}

# +------------------+
# | EXPORTED METHODS |
# +------------------+

########################################################################
sub generate_xml {
########################################################################
  my ($data) = @_;

  my $doc = XML::LibXML::Document->new( '1.0', 'UTF-8' );

  my $fragment = XML::LibXML::DocumentFragment->new();

  for my $key ( keys %{$data} ) {
    _to_xml( $doc, $fragment, $key, $data->{$key} );
  }

  my $xml = $doc->toString(1) . $fragment->toString(1);

  TRACE(
    sub {
      return Dumper(
        [ data => $data,
          xml  => $xml
        ]
      );
    }
  );

  return $xml;
}

########################################################################
sub param_n {
########################################################################
  my (@args) = @_;

  return Amazon::API::Botocore::Shape::Utils::param_n(@args);
}

########################################################################
# create_urlencoded_content(parameters, action, version)
# input:
#       parameters:
#                  SCALAR - query string to encode (x=y&w=z...)
#                  ARRAY  - either an array of hashes or...
#                           key/value pairs of the form x=y or...
#                           key/values pairs
#                  HASH   - key/value pairs, if the value is an array then
#                           it is assumed to be a list of hashes
#       action:    API method
#       version:   wsdl version for API
#
# output:
#       URL encodode query string
#
########################################################################
sub create_urlencoded_content {
########################################################################
  my ( $parameters, $action, $version ) = @_;

  my @args;

  if ( $parameters && !ref $parameters ) {
    @args = map { split /=/xsm } split /&/xsm, $parameters;
  }
  elsif ( $parameters && reftype($parameters) eq 'HASH' ) {
    foreach my $key ( keys %{$parameters} ) {
      if ( ref $parameters->{$key}
        && reftype( $parameters->{$key} ) eq 'ARRAY' ) {
        push @args, map { %{$_} } @{ $parameters->{$key} };
      }
      else {
        push @args, $key, $parameters->{$key};
      }
    }
  }
  elsif ( $parameters && reftype($parameters) eq 'ARRAY' ) {

    # if any are refs then they should be hashes...
    if ( any {ref} @{$parameters} ) {

      @args = map { %{$_} } @{$parameters};  # list of hashes
    }
    elsif ( any {/=/xsm} @{$parameters} ) {
      @args = map { split /=/xsm } @{$parameters};  # formatted list
    }
    else {
      @args = @{$parameters};                       # simple list
    }
  }

  my $content;

  if ( $action && !any {/Action/xsm} @args ) {
    push @args, 'Action', $action;
  }

  if ( $version && !any {/Version/xsm} @args ) {
    push @args, 'Version', $version;
  }

  return join $AMPERSAND, map { sprintf '%s=%s', $_->[0], url_encode( $_->[1] ) } pairs @args;
}

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

  # note that self should NOT really have keys! Not sure why I added this test
  my %creds = keys %options ? %options : map { $_ => $self->{$_} } @REQUIRED_KEYS;

  return $creds{aws_secret_access_key} && $creds{aws_access_key_id};
}

# +-----------------+
# | PRIVATE METHODS |
# +-----------------+

# should not be called if we have botocore definition
########################################################################
sub _set_content_type {
########################################################################
  my ($self) = @_;

  my $service = $self->get_service;

  # default content-type
  my $content_type = $self->get_content_type;

  return 'application/x-www-form-urlencoded'
    if any { $_ eq $service } @{ $API_TYPES{query} };

  return 'application/x-amz-json-1.1'
    if any { $_ eq $service } @{ $API_TYPES{json} };

  return 'application/xml'
    if any { $_ eq $service } @{ $API_TYPES{xml} };

  return $content_type;
}

########################################################################
sub _create_methods {
########################################################################
  my ($self) = @_;

  my $class = ref $self || $self;

  if ( $self->get_api_methods ) {
    no strict 'refs';        ## no critic (TestingAndDebugging::ProhibitNoStrict)
    no warnings 'redefine';  ## no critic (TestingAndDebugging::ProhibitNoWarnings)

    my $stash = \%{ __PACKAGE__ . $DOUBLE_COLON };

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

      my $method = lcfirst $api;

      $method =~ s/([[:lower:]])([[:upper:]])/$1_$2/xmsg;
      $method = lc $method;

      my $snake_case_method = $class . $DOUBLE_COLON . $method;
      my $camel_case_method = $class . $DOUBLE_COLON . $api;

      # snake case rules the day

      if ( !$stash->{$method} ) {
        *{$snake_case_method} = sub {
          my $self = shift;

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

      # ...but some prefer camels
      if ( !$stash->{$api} ) {
        *{$camel_case_method} = sub {
          my $self = shift;

          $self->$method(@_);
        };
      }
    }

  }

  return $self;
}

########################################################################
sub _create_stealth_logger {
########################################################################
  my ( $self, $name, $level ) = @_;

  my $sub_name = sprintf '%s::%s', $name, uc $level;

  no strict 'refs';  ## no critic ProhibitNoStrict

  return
    if defined *{$sub_name}{CODE};

  *{$sub_name} = sub {
    if ( $self->get_logger && $self->get_logger->can($level) ) {
      $self->get_logger->$level(@_);
    }
    else {
      return;
    }
  };

  return;
}

########################################################################
sub _set_default_logger {
########################################################################
  my ( $self, $log_level ) = @_;

  if ( !$self->get_logger && !Log::Log4perl->initialized ) {

    require Log::Log4perl;

    Log::Log4perl->import(':easy');

    $log_level = $LOG4PERL_LOG_LEVELS{ lc $log_level } // $LOG4PERL_LOG_LEVELS{info};

    Log::Log4perl->easy_init(
      { level  => $log_level,
        layout => $self->get_log_layout // $DEFAULT_LAYOUT_PATTERN,
        ( $self->get_log_file ? ( file => $self->get_log_file ) : () ),

      }
    );
  }
  else {
    for my $level (qw(debug trace info warn error )) {
      $self->_create_stealth_logger( __PACKAGE__, $level );
      $self->_create_stealth_logger( ref($self),  $level );
    }
  }

  return $self;
}

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

  my $debug //= ( $options{debug} || $ENV{DEBUG} );

  $options{log_level} = $debug ? 'debug' : $options{log_level} // 'info';

  return $self->set_log_level( $options{log_level} );
}

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

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

  $self->set_use_paginator( $self->get_use_paginator // $TRUE );

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

  if ( !defined $self->get_decode_always ) {
    $self->set_decode_always($TRUE);

    if ( !$self->get_decode_always && !defined $self->get_force_array ) {
      $self->set_force_array($FALSE);
    }
  }

  # most API services are POST, but using the Botocore metadata is best
  $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
  if ( !defined $self->get_region ) {
    $self->set_region( $self->get_region
        || $ENV{'AWS_REGION'}
        || $ENV{'AWS_DEFAULT_REGION'}
        || $DEFAULT_REGION );
  }

  my $debug //= ( $options{debug} || $ENV{DEBUG} );

  $options{log_level} = $debug ? 'debug' : $options{log_level} // 'info';

  $self->set_log_level( $options{log_level} );

  if ( !$self->get_credentials ) {
    if ( $self->has_keys(%options) ) {
      $self->set_credentials(
        Amazon::Credentials->new(
          { aws_secret_access_key => $options{aws_secret_access_key},
            aws_access_key_id     => $options{aws_access_key_id},
            token                 => $options{token},
            region                => $self->get_region,
            no_passkey_warning    => $options{no_passkey_warning},
          }
        )
      );
    }
    else {
      $self->set_credentials(
        Amazon::Credentials->new(
          order              => $options{order},
          region             => $options{region},
          no_passkey_warning => $options{no_passkey_warning},
        )
      );

      if ( !defined $self->get_region ) {
        $self->set_region( $self->get_credentials->get_region );
      }
    }
  }

  # set URL last since it contains region
  $self->_set_url;

  return $self;
}

########################################################################
sub _create_service_url {
########################################################################
  my ($self) = @_;

  my $url;

  my $botocore_metadata = $self->get_botocore_metadata;
  my $service           = $self->get_service;

  if ( $botocore_metadata && $botocore_metadata->{globalEndpoint} ) {
    $url = sprintf '%s://%s', $self->get_protocol, $botocore_metadata->{globalEndpoint};
  }
  else {
    my $endpoint = $self->get_endpoint_prefix || $service;

    if ( any { $_ eq $service } @GLOBAL_SERVICES ) {
      $url = sprintf $GLOBAL_URL_FMT, $self->get_protocol, $endpoint;
    }
    elsif ( $self->get_region ) {
      $url = sprintf $REGIONAL_URL_FMT, $self->get_protocol, $endpoint, $self->get_region;
    }
    else {
      $url = sprintf $REGIONAL_URL_FMT, $self->get_protocol, $endpoint, 'us-east-1';
    }
  }

  return $url;
}

########################################################################
sub _set_url {
########################################################################
  my ($self) = @_;

  my $url = $self->get_url;

  if ( !$url ) {
    $url = $self->_create_service_url;
  }
  else {
    if ( $url !~ /^https?/xmsi ) {
      $url =~ s/^\///xms;  # just remove leading slash...
      $url = $self->get_protocol . '://' . $url;
    }

  }

  $self->set_url($url);

  return $self;
}

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

  my $target  = $self->get_target_prefix;
  my $version = $self->get_version;
  my $api     = $self->get_api;
  my $action  = $self->get_action;

  if ( !$target ) {
    $target = $version ? $api . $UNDERSCORE . $version : $api;
  }

  $target = $target . $DOT . $action;

  $self->set_target($target);

  $request->header( 'X-Amz-Target' => $target );

  return $request;
}

########################################################################
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;

  TRACE(
    sub {
      return Dumper(
        [ method       => $self->get_http_method,
          args         => \%args,
          request      => $request,
          content      => $content,
          content_type => $content_type
        ]
      );
    }
  );

  if ( $self->get_http_method ne 'GET' || !defined $content ) {
    if ($content_type) {
      $request->content_type( $content_type . '; charset=utf-8' );
    }
    $request->content($content);
  }
  else {
    $request->uri( $request->uri . $QUESTION_MARK . $content );
  }

  DEBUG( sub { return Dumper( [ request => $request ] ); } );

  return $request;
}

########################################################################
# Convert a Perl data object to XML
########################################################################
sub _to_xml {
########################################################################
  my ( $doc, $parent, $key, $value ) = @_;

  # Handle array references (multiple child elements)
  if ( ref $value eq 'ARRAY' ) {
    for my $item ( @{$value} ) {
      _to_xml( $doc, $parent, $key, $item );
    }
  }
  # Handle hash references (nested structures)
  elsif ( ref $value eq 'HASH' ) {
    my $element = $doc->createElement($key);

    # Handle attributes (if _attr key exists)
    if ( exists $value->{_attr} ) {
      my $attrs = delete $value->{_attr};
      for ( keys %{$attrs} ) {
        $element->setAttribute( $_, $attrs->{$_} );
      }
    }

    # Process nested elements
    for my $subkey ( keys %{$value} ) {
      _to_xml( $doc, $element, $subkey, $value->{$subkey} );
    }

    $parent->appendChild($element);
  }
  # Handle scalar values (text nodes)
  else {
    my $element = $doc->createElement($key);
    $element->appendTextNode($value);
    $parent->appendChild($element);
  }

  return;
}

1;

__END__

=pod

=head1 NAME

Amazon::API - A generic base class for AWS Services

=head1 SYNOPSIS

  use Amazon::API;

  my $service = Amazon::API->new( service => 'events', api => 'AWSEvents');
  my $rules = $service->invoke_api('ListRules');

=head1 DESCRIPTION

https://github.com/rlauer6/perl-Amazon-API/actions/workflows/build.yml/badge.svg

=begin markdown

[![amazon-api](https://github.com/rlauer6/perl-Amazon-API/actions/workflows/build.yml/badge.svg)](https://github.com/rlauer6/perl-Amazon-API/actions/workflows/build.yml)

=end markdown

=pod

Generic class for constructing AWS API interfaces. Typically used as a
parent class, but can be used directly. This package can also
generates stubs for Amazon APIs using the Botocore project
metadata. (See L</BOTOCORE SUPPORT>).

I<The typical use of this is API is through the classes you build with
the included tool (F<amazon-api>). The tool leverages the Botocore
project's metadata to build classes that are specific to each API (and
are documented in the perlish way). Using C<Amazon::API> directly may
not work in all circumstances unless you are very familiar with the
API you are calling. If you decide to take the L<Luddite approaches|/Take the Luddite approach>, read the documentation carefully before using C<Amazon::API>.>

=over 5

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

=item * See
L<Amazon::CloudWatchEvents|https://github.com/rlauer6/perl-Amazon-CloudWatchEvents/blob/master/src/main/perl/lib/Amazon/CloudWatchEvents.pm.in>
for an example of how to use this module as a parent class.

=item * See C<amazon-api -h> for information regarding
how to automatically create Perl classes for AWS services using
Botocore metadata.

=back

=head1 BACKGROUND AND MOTIVATION

A comprehensive Perl interface to AWS services similar to the
I<Botocore> library for Python has been a long time in coming. The
Paws project has been creating an always up-to-date AWS interface with
community support. If you are looking for an extensible method of
installing and invoking a subset of services you might want to
consider C<Amazon::API>.

Think of this class as a DIY kit for installing B<only> the APIs and methods you
need for your AWS project.  Using the included C<amazon-api> utility
you can also roll your own complete Amazon API classes that
include support for serializing requests and responses based on
metadata provided by the Botocore project. The classes you create with
C<amazon-api> include full documentation as pod. (See L</BOTOCORE
SUPPORT> for more details).

=over 5

I<NOTE:> The original L<Amazon::API> was written in 2017 as a I<very>
lightweight way to call a handfull of APIs. The evolution of the
module was based on discovering, without much documentation or help,
the nature of Amazon APIs. In retrospect, even back then, it would
have been easier to consult the Botocore project and decipher how that
project managed to create a library from the metadata. Fast forward
to 2022 and L<Amazon::API> began using the Botocore
metadata in order to, in most cases, correctly call any AWS service.
The L<Amazon::API> module can still be used without the assistance of
Botocore metadata, but it works a heckuva lot better with it.

=back

You can use L<Amazon::API> in 3 different ways:

=head2 Take the Luddite approach

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

=head2 Build your own API classes with just what you need

 package Amazon::API::SQS;
 
 use strict;
 use warnings;
 
 use parent qw( Amazon::API );
 
 our @API_METHODS = qw(
   ListQueues
   PurgeQueue
   ReceiveMessage
   SendMessage
 );
 
 sub new {
   my ( $class, @options ) = @_;
   $class = ref($class) || $class;
 
   my %options = ref( $options[0] ) ? %{ $options[0] } : @options;
 
   return $class->SUPER::new(
     { service       => 'sqs',
       http_method   => 'GET',
       api_methods   => \@API_METHODS,
       decode_always => 1,
       %options
     }
   );
 }
 
 1;

 use Amazon::API::SQS;
 use Data::Dumper;

 my $sqs = Amazon::API::SQS->new;

 print {*STDERR} Dumper($sqs->ListQueues);

=head2 Use the Botocore metadata to build classes for you

 amazon-api -s sqs create-stubs
 amazon-api -s sqs create-shapes

 perl -I . -MData::Dumper -MAmazon::API:SQS -e 'print Dumper(Amazon::API::SQS->new->ListQueues);'

=over 5

I<NOTE:> In order to use Botocore metadata you must clone the Botocore
repository and point the utility to the repo.

Clone the Botocore project from GitHub:

 mkdir ~/git
 cd git
 git clone https://github.com/boto/botocore.git

Generate stub classes for the API and shapes:

 amazon-api -b ~/git/botocore -s sqs -o ~/lib/perl5 create-stubs
 amazon-api -b ~/git/botocore -s sqs -o ~/lib/perl5 create-shapes

 perldoc Amazon::API::SQS

See L<Amazon::API::Botocore::Pod> for more details regarding building
stubs and shapes.

=back

=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 were usually implemented as simple HTTP
services that accepted a query string. This module attempts to account
for most of the nuances involved in invoking AWS services and
provide a fairly generic way of invoking these APIs in the most
lightweight way possible.

Using L<Amazon::API> as a generic, lightweight module, naturally does
not provide nuanced support for individual AWS services. To use this
class in that manner for invoking the AWS APIs, you need to be very
familiar with the specific API requirements and responses and be
willng to invest time reading the documentation on Amazon's website.
The payoff is that you can probably use this class to call I<any> AWS
API without installing a large number of dependencies.

If you don't mind a few extra dependencies and overhead, you should
generate the stub APIs and support classes using the C<amazon-api>
utility. The stubs and shapes produced by the utility will serialize
and deserialize requests and responses correctly by using the Botocore
metadata. Botocore metadata provides the necessary information to
create classes that can successfully invoke all of the Amazon APIs.

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/blob/master/src/main/perl/lib/Amazon/CloudWatchEvents.pm.in>

And invoking some of the APIs can be as easy as:

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

=head1 BOTOCORE SUPPORT

Using Botocore metadata and the utilities in this project, you can
create Perl classes that simplify calling AWS services.  After
creating service classes and shape objects from the Botocore metadata
calling AWS APIs will look something like this:

 use Amazon::API::SQS;

 my $sqs = Amazon::API::SQS->new;
 my $rsp = $sqs->ListQueues();

The L<Amazon::API::Botocore> module augments L<Amazon::API> by using
Botocore metadata for determining how to call individual services and
serialize parameters passed to its API methods. A utility (C<amazon-api>)
is provided that can generate Perl classes for all AWS services using
the Botocore metadata.

Perl classes that represent AWS data structures (aka shapes) that are
passed to or returned from services can also be generated. These
classes allow you to call all of the API methods for a given service
using simple Perl objects that are serialized correctly for a specific
method.

Service classes are subclassed from C<Amazon::API> so their C<new()>
constructor takes the same arguments as C<Amazon::API::new()>.

 my $credentials = Amazon::Credential->new();

 my $sqs = Amazon::API::SQS->new( credentials => $credentials );

If you are going to use the Botocore support and automatically
generate API classes you I<must also> create the data structure classes
that are used by each service. The Botocore based APIs will use these
classes to serialize requests and responses.

For more information on generating API classes, see
L<Amazon::API::Botocore::Pod>.

=head2 Response Serialization

With little documentation to go on, interpretting the Botocore
metadata and deducing how to serialize Botocore shapes (using a single
serializer) from Perl objects has been a difficult task. It's likely
that there are still some edge cases and bugs lurking in the
serialization methods. Accordingly, starting with version 1.4.5,
serialization exceptions or exceptions that occur while attempting to
decode a response, will result in the raw response being returned to
the caller. The idea being that getting something back that allows you
figure out what to do with the response might be better than receiving
an error.

OTOH, you might want to see the error, report it, or possibly
contribute to its resolution.  You can prevent errors from being
surpressed by setting the C<raise_serializtion_errors> to a true
value. The default is I<false>.

I<Throughout the rest of this documentation a request made using one
of the classes created by the Botocore support scripts will be
referred to as a B<Botocore request> or B<Botocore API>.>

Starting with version 2.0.12 serialization has become B<much more reliable>,
but there are still some differences in the way the Python Botocore
library serialize responses. For example, some serializers may include
or exclude members that are not present in the response payload. If
you are testing a response element, the best approach is to first test
the truthiness and then test the presence of content.

  if ( $result->{$key} && @{$result->{$key}} ) 

  if ( $result->{$key} && %{result->{$key}} ) 

=head1 ERRORS

When an error is returned from an API request, an exception class
(C<Amazon::API::Error>) will be raised if C<raise_error> has been set
to a true value (the default). If you set C<print_error> to true AND
C<raise_error> is false, then errors will be printed to STDERR.

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

=head1 METHODS AND SUBROUTINES

I<Reminder: You can mostly ignore this part of the documentation when
you are leveraging Botocore to generate your API classes.>

=head2 new

 new(options)

All options are described below. C<options> can be a list of
key/values or hash reference.

=over 5

=item action

The API method. Normally, you would not set C<action> when you
construct your object. It is set when you call the C<invoke_api>
method or automatically set when you call one of the API stubs created
for you.

Example: 'PutEvents'

=item api

The name of the AWS service. See L</IMPLEMENTATION NOTES> for a
detailed explanation of when to set this value.

Example: 'AWSEvents'

=item api_methods

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

The methods that are created for you are nothing more than stubs that
call C<invoke_api>. The stub is a convenience for calling the
C<invoke_api> method as shown below.

  
 my $api = Amazon::CloudWatch->new;

 $api->PutEvents($events);

...is equivalent to:

 $api->invoke_api->('PutEvents', $events);

Consult the Amazon API documentation for the service to determine what
parameters each action requires.

=item aws_access_key_id

Your AWS access key. Both the access key and secret access key are
required if either is passed. If no credentials are passed, an attempt
will be made to find credentials using L<Amazon::Credentials>. Note
that you may need to pass C<token> as well if you are using temporary
credentials.

=item aws_secret_access_key

Your AWS secret access key.

=item content_type

Default content for parameters passed to the C<invoke_api()>
method. If you do not provide this value, a default content type will
be selected based on the service's protocol.

 query     => application/x-www-form-urlencoded
 rest-json => application/x-amz-json-1.1
 json      => application/json
 rest-xml  => application/xml

=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 10

=item 1. Pass the credentials directly.

Pass the values for the credentials (C<aws_access_key_id>,
C<aws_secaret_access_key>, C<token>) when you call the C<new> method.
A session token is typically required when you have assumed
a role, you are using the EC2's instance role or a container's role.

=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 to the class 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 do not explicitly pass credentials or do not pass a class that
will supply credentials, the module will use the
C<Amazon::Credentials> class that attempts to find credentials in the
I<environment>, your I<credentials file(s)>, or the I<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.>

=back

=item debug

Set debug to a true value to enable debug messages. Debug mode will
dump the request and response from all API calls. You can also set the
environment variable DEBUG to enable debugging output. Set the debug
value to '2' to increase the logging level.

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 no_passkey_warning

Prevent passkey warning. This is an option to C<Amazon::Credentials>.

=item print_error

Setting this value to a true value will print a detailed error message
containing the error code and any messages returned by the API to
STDERR when an error occurs. Errors will NOT be printed if
C<raise_error> is also true.

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 a true value 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. Pass an empty string if the service is a global
service that does not require or want a 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 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. However, you may want to set this manually if
for example you are using a service like
<LocalStack|https://localstack.cloud/> that mocks AWS API calls.

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

=item user_agent

Your own user agent object.  Using
C<Furl>, if you have it avaiable may result in faster response.

default: C<LWP::UserAgent>

=item version

Sets the API version.  Some APIs require a version. Consult the
documentation for individual services.

=back

=head2 invoke_api

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

or using named parameters...

 invoke_api({ action => args, ... } )

Invokes the API with the provided parameters.

=over 5

=item action

API name.

=item parameters

Parameters to send to the API. C<parameters> can be a scalar, a hash
reference or an array reference. See the discussion below regarding
C<content-type> and how C<invoke_api()> formats parameters before
sending them as a payload to the API.

You can use the C<param_n()> method to format query string arguments
that are required to be in the I<param.n> notation. This is about the
best documentation I have seen for that format. From the AWS
documentation...

=over 10

Some actions take lists of parameters. These lists are specified using
the I<param.n> notation. Values of n are integers starting from 1. For
example, a parameter list with two elements looks like this:

&AttributeName.1=first

&AttributeName.2=second

=back

An example of using this notation is to set queue attributes when
creating an SQS queue.

 my $attributes = { Attributes => [ { Name => 'VisibilityTimeout', Value => '100' } ] };
 my @sqs_attributes= Amazon::API::param_n($attributes);

 eval {
   $sqs->CreateQueue([ 'QueueName=foo', @sqs_attributes ]);
 };

See L</param_n> for more details.

=item content-type

If you pass the C<content-type> parameter, it is assumed that the parameters are
the actual payload to be sent in the request (unless the parameter is a reference).

The C<parameters> will be converted to a JSON string if the
C<parameters> value is a hash reference.  If the C<parameters> value
is an array reference it will be converted to a query string (Name=Value&...).

To pass a query string, you should send an array of key/value
pairs, or an array of scalars of the form C<Name=Value>.

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

=item headers

Array reference of key/value pairs representing additional headers to
send with the request.

=back

=head2 decode_response

Boolean that indicates whether or not to deserialize 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 first as a JSON string and then as an XML string. If
both of those fail, the raw content is returned.

You can enable or disable deserializing responses globally by setting
the C<decode_always> attribute when you call the C<new> constructor.

default: true

By default, `Amazon::API` will retrieve all results for Botocore based
API calls that require pagination. To turn this behavior off, set
C<use_paginator> to a false value when you instantiate the API
service.

 my $ec2 = Amazon::API->new(use_paginator => 0);

You can also use the L</paginator> method to retrieve all results from Botocore requests that implement pagination.

=head2 print_error

Prints a formatted version of the last error encountered to STDERR.

=head2 submit

 submit(options)

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

C<options> is a reference to a 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

Check the documentation for the individual APIs for the correct
content type.

=item headers

Array reference of key/value pairs that represent additional headers
to send with the request.

=back

=head1 EXPORTED METHODS

=head2 generate_xml

 generate_xml(object)

Generates XML from a Perl object (uses L<XML::LibXML>). This seems to
do a much better job than XMLout() in allowing a mix of attributes and
nested objects. With C<XMLout()> you need to choose between allowing
attributes (which we need to add the namespace for certain requests)
and nested elements (NoAttr => 1).

=head2 get_api_service

 get_api_service(api, options)

Convenience routine that will return an API instance.

 my $sqs = get_api_service 'sqs';

Equivalent to:

 require Amazon::API::SQS;

 my $sqs = Amazon::API::SQS->new(%options);

=over 5

=item api

The service name. Example: route53, sqs, sns

=item options

list of key/value pairs passed to the new constructor as options

=back

=head2 create_url_encoded_content

 create_urlencoded_content(parameters, action, version)

Returns a URL encoded query string. C<parameters> can be any of SCALAR, ARRAY, or HASH. See below.

=over 5

=item parameters

=over 5

=item SCALAR

Query string to encode (x=y&w=z..)

=item ARRAY

Can be one of:

=over 5

=item * Array of hashes where the keys are the query string variable and the value is the value of that variable

=item * Array of strings of the form "x=y"

=item * An array of key/value pairs - qw( x y w z )

=back

=item HASH

Key/value pairs. If value is an array it is assumed to be a list of hashes

=back

=item action

The method being called. For some query type APIs an Action query variable is required.

=item version

The WSDL version for the API. Some query type APIs require a Version query variable.

=back

=head2 paginator

 paginator(service, api, request)

Returns an array containing the results of an API call that requires
pagination,


 my $result = paginator($ec2, 'DescribeInstances', { MaxResults => 10 });

=head2 param_n

 param_n(parameters)

Format parameters in the "param.n" notation.

C<parameters> should be a hash or array reference.

A good example of a service that uses this notation is the
I<SendMessageBatch> SQS API call.

The sample request can be found here:

L<SendMessageBatch|https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_SendMessageBatch.html>


 https://sqs.us-east-2.amazonaws.com/123456789012/MyQueue/
 ?Action=SendMessageBatch
 &SendMessageBatchRequestEntry.1.Id=test_msg_001
 &SendMessageBatchRequestEntry.1.MessageBody=test%20message%20body%201
 &SendMessageBatchRequestEntry.2.Id=test_msg_002
 &SendMessageBatchRequestEntry.2.MessageBody=test%20message%20body%202
 &SendMessageBatchRequestEntry.2.DelaySeconds=60
 &SendMessageBatchRequestEntry.2.MessageAttribute.1.Name=test_attribute_name_1
 &SendMessageBatchRequestEntry.2.MessageAttribute.1.Value.StringValue=test_attribute_value_1
 &SendMessageBatchRequestEntry.2.MessageAttribute.1.Value.DataType=String
 &Expires=2020-05-05T22%3A52%3A43PST
 &Version=2012-11-05
 &AUTHPARAMS

To produce this message you would pass the Perl object below to C<param_n()>:

 my $message = {
   SendMessageBatchRequestEntry => [
     { Id          => 'test_msg_001',
       MessageBody => 'test message body 1'
     },
     { Id               => 'test_msg_002',
       MessageBody      => 'test message body 2',
       DelaySeconds     => 60,
       MessageAttribute => [
         { Name  => 'test_attribute_name_1',
           Value =>
             { StringValue => 'test_attribute_value_1', DataType => 'String' }
         }
       ]
     }
   ]
 };

=head1 CAVEATS

=over 5

=item 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.>

=back

=head1 IMPLEMENTATION NOTES

If you have taken the advice above and created classes using the
F<amazon-api> script you can probably ignore this section. This
section is intended to help those trying to create the lightest weight
possible AWS API class.

Just a reminder for those wanting to go lite...

=over 5

=item * Read the documentation AWS provides for the API. You need to understand the request parameters and headers required.

=item * Examine the Botocore data for the API. That might help you understand that structures required for the calling parameters.

=item * Use the C<aws> CLI script in debug mode to see the actual payloads and how they are formatted.

=back

=head2 Headers

=head3 X-Amz-Target

Most of the newer AWS APIs are invoked as HTTP POST operations and
accept a header C<X-Amz-Target> in lieu of the CGI parameter C<Action>
to specify the specific API 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 I<directly> to call AWS APIs, but
you kinda sorta figure it out by parsing the Botocore data for a
particular API.

When invoking an API, the class uses the C<api> value to indicate
that the action should be set in the C<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 and KMS seem to be able to use this in lieu of query
variables C<Action> and C<Version>, although again, there seems to be
a lot of inconsistency (and sometimes flexibility) in the APIs.
DynamoDB uses DynamoDB_YYYYMMDD.Action while KMS does not require 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.  Again, your
best approach is to read Amazon's documentation and look at their
sample requests for guidance.  You can also look to the L<Botocore
project|https://github.com/boto/botocore> for information regarding
the service.  Checkout the F<service-2.json> file within the
sub-directory F<botocore/botocore/data/{api-version}/{service-name}>
which contains details for each service.

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.

As noted, this generic API interface tries to make it possible to use
one class C<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.  Services like EC2 conform to that protocol and can be
invoked with relatively little fanfare.

 use Amazon::API;
 use Data::Dumper;

 print Dumper(
   Amazon::API->new(
     service => 'ec2',
     version => '2016-11-15'
   )->invoke_api('DescribeInstances')
 );

Note that invoking the API in this fashion, C<version> is
required.

For more hints regarding how to call a particular service, you can use
the AWS CLI with the --debug option.  Invoke the service using the CLI
and examine the payloads sent by the Botocore library.

=head3 Rolling a New API

Once again, your best bet is to use the C<amazon-api> script to roll a
class from the Botocore metadata, but if you really want to create your own
class the lite way read on.

The L<Amazon::API> 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.

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

 package Amazon::CloudWatchEvents;

 use strict;
 use warnings;

 use parent qw(Amazon::API);

 sub new {
   my ($class, $options) = @_;

   my $self = $class->SUPER::new(
     { %{$options},
       api         => 'AWSEvents',
       service     => 'events',
       api_methods => [qw( ListRules )],
     }
   );

   return $self;
 }

Then...

 use Data::Dumper;

 print Dumper(Amazon::CloudWatchEvents->new->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.

=head3 Overriding Methods

Because the class does some symbol table munging, you cannot easily
override the methods in the usual way.

 sub ListRules {
   my $self = shift;
   ...
   $self->SUPER::ListRules(@_)
 }

Instead, you should re-implement the method as implemented by this
class.

 sub ListRules {
   my $self = shift;
   ...
   $self->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 different C<Content-Type>
headers.  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()> method can be passed the
C<Content-Type> or will try to make its I<best guess> based on the
service protocol or the type of object being passed as
parameters. There is a hash of service names and service types that
this module uses to determine the content type required by the
service. If services are added that hash needs to be updated.

You can also set the default content type used for the calling service
by passing the C<content_type> option to the constructor.

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

=head2 ADDITIONAL HINTS

=over 5

=item * Bad Request

If you send the wrong headers or payload you're liable to get a 400
Bad Request. You may also get other errors that can be misleading when
you send incorrect parameters. When in doubt compare your requests to
requests from the AWS CLI using the C<--debug> option.

=over 10 

=item 1. Set the C<debug> option to true to see the request object and
the response object from C<Amazon::API>.

=item 2. Excecute the AWS CLI with the --debug option and compare the
request and response with that of your calls.

=back

=item * Payloads

Pay attention to the payloads that are required by each service.  B<Do
not> assume that sending nothing when you have no parameters to pass
is correct. For example, the C<ListSecrets> API of SecretsManager
requires at least an empty JSON object.

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

Failure to send at least an empty JSON object will result in a 400
response. 

=back

=head1 VERSION

This documentation refers to version 2.1.4  of C<Amazon::API>.

=head1 DIAGNOSTICS

To enable diagnostic output, set C<debug> to a true value when calling
the constructor. You can also set the C<DEBUG> environment variable to a
true value to enable diagnostics.

=head2 Logging

By default L<Amazon::API> uses L<Log::Log4perl>'s stealth loggers to
log at the DEBUG and TRACE levels. Setting the environment variable
DEBUG to some value or passing a true value for C<debug> in the
constructor will trigger verbose logging.

If you pass a logger to the constructor, C<Amazon::API> will attempt
to use that if it has the appropriate logging level methods (error,
warn, info, debug, trace). If L<Log::Log4perl> is unavailable and you
do not pass a logger, logging is essentially disabled at any level.

If, for some reason you set the enviroment variable DEBUG to a true
value but do not want C<Amazon::API> to log messages you can turn off
logging as shown below:

 my $ec2 = Amazon::API::EC2->new();

 $ec2->set_log_level('fatal');

=head1 BUGS AND LIMITATIONS

This module has not been tested on Windows OS. Please report any
issues found by opening an issue here:

L<https://github.com/rlauer6/perl-Amazon-API/issues>

=head1 FAQs

=head2 Why should I use this module instead of Paws?

Maybe you shouldn't. Paws is a community supported project and may be
a better choice for most people. The programmers who created Paws are
luminaries in the pantheon of Perl programming (alliteration
intended). If you don't want to install of the AWS services but only
need to use a single service, L<Amazon::API> may be the right choice
for you.  Paws may also have some edge cases for some of the seldom
used services and you might find this module easier to use and debug.

=head2 Does it perform better than Paws?

Probably not. But individual API calls to Amazon services have their
own performance characteristics and idiosyncracies.  The overhead
introduced by this module and Paws may be insignificant compared to
the API performance itself, however Paws is implemented using Moose
and the startup time for a Moose script can longer than the startup
script when using this module. YMMV.

=head2 Does this work for all APIs?

I don't know. Probably not? Feedback is appreciated. L<Amazon::API>
has been developed based on my needs and used accordingly.  Although I
have tested it on many APIs, there may still be some cases that are
not handled properly and I am still deciphering the nuances of
flattening, boxing and serializing objects to send to Amazon APIs. The
newer versions of this module using Botocore metadata have become
increasingly reliable over time and I'm somewhat confident that my
interpretation of the Botocore data produces working classes.

However, keep in mind that Amazon APIs are not created equal,
homogenous or invoked in the the same way for all services. Some
accept parameters as a query strings, some parameters are embedded in
the URI, some are sent as JSON payloads and others as XML. Content
types for payloads are all over the map.  Likewise with return values.

Luckily, the Botocore metadata describes the protocols, parameters and
return values for all APIs. The Botocore metadata is quite amazing
actually. It is used to provide information to the Botocore library
for calling any of the AWS services and even for creating
documentation!

L<Amazon::API> can use that information for creating the Perl classes
that invoke each API but may not interpret the metadata correctly in
all circumstances, so it is likely bugs may still exist.

If you want to use this to invoke S3 APIs, don't. I haven't tried it
and I'm pretty sure it would not work anyway. There are modules
designed specifically for S3; L<Amazon::S3>, L<Net::Amazon::S3>. Use
them instead.

=head2 Do I have to create the shape classes when I generate stubs for a service?

Probably. If you create stubs manually, then you do not need the shape
classes. If you use the scripts provided to create the API stubs using
Botocore metadata, then yes, you must create the shapes so that the
Botocore API methods know how to serialize requests. Note that you can
create the shape stubs using the Botocore metadata while not creating
the API services. You might want to do that if you want a lean stub
but want the benefits of using the shape stubs for serialization of
the parameters (or you want the pod that comes with those classes).

If you produce your stubs manually and do not create the shape stubs,
then you must pass parameters to your API methods that are ready to be
serialized by L<Amazon::API>.  Creating data structures that will be
serialized correctly however is done for you I<if you use the shape
classes>.  For example, to create an SQS queue using the shape stubs,
you can call the C<CreateQueue> API method as describe in the Botocore
documentation.

  $sqs->CreateQueue(
    { QueueName => $queue_name,
      tags      => { Name => 'my-new-queue' },
      { Env => 'dev' },
      Attributes => { VisibilityTimeout => 40 },
      { DelaySeconds => 60 }
    }
  );

If you do not use the shape classes, then you must pass the arguments
in the form that will eventually be serialized in the correct manner
as a query string.

 $sqs->CreateQueue([
  'QueueName=foo',
  'Attributes.1.Value=100',
  'Attributes.1.Name=VisibilityTimeout',
  'Tag.1.Key=Name',
  'Tag.1.Value=foo',
  'Tag.2.Key=Env',
  'Tag.2.Value=dev'
 ]);

=head2 This code does not use "Modern Perl". Why?

This code has evolved over the years from being I<ONLY> a way to make
RESTful calls to a few Amazon APIs, to incorporating the use of the
Botocore metadata. It I<was> one person's effort to create a somewhat
lightweight interface to selected AWS APIs.

The code did not start out as well designed attempt to interpret the
Botocore data by creating a monolithic framework to call ANY AWS
API. Perhaps if it were designed today it might use more of Modern
Perl, like Moose as does Paws. The code does however embrace Perl Best
Practices.  Running C<perlcritic> with the Perl Best Practices theme
should show no or very few findings.

=head2 How do I pass AWS credentials to the API?

There is a bit of magic here as L<Amazon::API> will use
L<Amazon::Credentials> transparently if you do not explicitly pass the
credentials object. I've taken great pains to try to make the
aforementioned module somewhat useful and I<secure>.

See L<Amazon::Credentials>.

=head2 Can I use more than one set of credentials to invoke different APIs?

Yes. See L<Amazon::Credentials>.

=head2 How stable is the interface?

As of version 2.1.0 the interface is quite stable.  I'm not aware of
any current bugs and now consider this project "production ready".

=head2 Why are you using XML::Simple when it clearly says "DO NOT"?

It's simple. And it seems easier to build than other modules that
almost do the same thing.

=head2 I tried to use this with XYZ service and it didn't work. What should do I do?

There are several reasons why your call might not have worked. The
most likely place for API calls to fail is when serializing requests
or serializing results. Enable debugging and see how far the API gets.
Report whether the serialization on the request or response failed.
If the serialization of the results failed, you can set
C<decode_always> to false which will prevent serialization of the
result and return the raw content sent from the API.  Other reasons
your call may have failed include:

=over 5

=item * You passed bad data

Take a look at the data you passed, how was it serialized and
ultimately passed to the API?  Setting the C<debug> flag is usually
helpful in understanding how requests and responses are serialized.

=item * You didn't read the docs and passed bad data

 amazon-api -s sqs CreateQueue

=item * The serialization of Amazon::API::Botocore::Shape isn't working

Serialization output for every class for every API has not been fully
tested and my never be given the breadth of objects and services. You
may find that some API methods return C<Bad Request> or do not
serialize the results (or more likely requests) in the manner
expected. Requests are serialized based I<solely> on the metadata
found in the Botocore project. There lie the clues for each API
(protocol, end points, etc) and the models (shapes) for requests and
response elements.

Some requests require a query string, some an XML or JSON payload. The
Botocore based API classes use the metadata to determine how to send a
request and how to interpret the results. This module uses
L<XML::Simple> or L<JSON> to parse the results. It then uses the
L<Amazon::API::Botocore::Serializer> to turn the parsed results into a
Perl object that respresents the response shape.

It's likely that there are exceptions that are handled as special
cases in the Python or Java libraries that also use the Botocore
metadata. In that case use the C<aws> CLI command in C<--debug> mode
to examine the request and response.

You can find information about each API's request and response from
the documentation created for each service.

 perldoc Amazon::API::Botocore::Shape::EC2:DescribeInstancesRequest

or more succinctly:

 amazon-api -s ec2 help DescribeInstancesRequest

Make sure you understand what the API request should look
like. C<amazon_api> will help illuminate the structure of requests you
should be sending to APIs.

 amazon-api -s sqs help CreateQueue

You can also dump the Botocore metadata from the generated classes using
C<amazon-api>.

 amazon-api -s sqs describe

=over 10

=item Additional Details

Some APIs, most notably query protocol APIs like EC2 seem to require
special serializers. Looking at the Python implementation of the
Botocore library reveals a separate EC2 serializer.  This API has no
such "hook" for APIs that require a unique intepretation of the
Botocore metadata. 

You can however create the correct payloads expected by an API and
pass those when you make a request. For example, the EC2
DescribeSecurityGroups API accepts a Filter object to filter the
results. The Python Botocore signature looks like this:

  response = client.describe_security_groups(
      Filters=[
          {
              'Name': 'string',
              'Values': [
                  'string',
              ]
          },
      ],
      GroupIds=[
          'string',
      ],
      GroupNames=[
          'string',
      ],
      DryRun=True|False,
      NextToken='string',
      MaxResults=123
  )

That signature provides a convenient way to pass the required
parameters to the API. However, when actually passed to the API the
payload is serialized into a query string parameter that might look
something like:

 Filter.1.Name=group-name&Filter.1.Value.1=some-value&Action=DescribeSecurityGroups&Version=2016-11-15

The Filters object you passed gets serialized into I<param.n> notation
as described earlier in this documentation. Knowing that fact (by
looking at the AWS API for DescribeSecurityGroups) and experiencing a
failure when sending what should be the correct request to the API
using this class, you could send correctly formatted payloads to query
protocol APIs like this one.

 my @filter = param_n(
    { Filter => [
        { Name  => 'group-name',
          Value => ['tbc-ssh-only']
        }
      ]
    }
  );

 print Dumper([filter => \@filter]);

Would result in:

 $VAR1 = [
           'filter',
           [
             'Filter.1.Name=group-name',
             'Filter.1.Value.1=tbc-ssh-only'
           ]
         ];

Arrays passed to query protocol requests are assumed to be lists of
query variables and values and are added to the URL when the request
is made.

I<Hopefully, as more is learned about serializing those kinds of API
requests this class will be able to successfully make those API
calls.>

I<UPDATE: Try using the Botocore protocol for APIs by passing a hash
reference of expected variables first. Recent updates have been made
to create special serializers for these older query protocol APIs.>

=back

If you find this project's serializer deficient, please log an issue
and I will attempt to address it.

=back

=head1 LICENSE AND COPYRIGHT

This module is free software. It may be used, redistributed and/or
modified under the same terms as Perl itself.

=head1 TBD

Over the last few years as the classes in this project have evolved,
the number of dependencies has increased to the point where it is no
longer a "lightweight" distribution. In fact, the start up time for
C<Amazon::API> is to be honest, now a bit disappointing.  Accordingly,
the biggest "to do" on the list is to see if the load time can be
reduced. Having said that, the cost of invoking some Amazon APIs and
the fact that you may be using these classes in a manner where initial
load time is not important, may not make this a high priority for
some.

=over 5

=item * decrease load time of C<Amazon::API>

=item * reduce dependencies

=item * reduce generated class modules sizes by separating out pod

=item * investigate a different way to load Botocore metadata rather than embedding it in each module

=back

=head1 SEE OTHER

L<Amazon::Credentials>, L<Amazon::API::Error>, L<AWS::Signature4>, L<Amazon::API::Botocore>, L<Paws>

=head1 AUTHOR

Rob Lauer - <rlauer6@comcast.net>

=cut


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