Amazon-S3-Lite

 view release on metacpan or  search on metacpan

lib/Amazon/S3/Lite.pm  view on Meta::CPAN

package Amazon::S3::Lite;

use strict;
use warnings;

use Amazon::Signature4::Lite;
use Amazon::S3::Lite::Credentials;
use Amazon::S3::Lite::Logger;
use Carp qw(croak);
use Data::Dumper;
use Digest::MD5 qw(md5_base64 md5);
use English qw(-no_match_vars);
use HTTP::Tiny;
use List::Util qw(pairs);
use MIME::Base64 qw(encode_base64);
use Scalar::Util qw(blessed openhandle);
use URI::Escape qw(uri_escape_utf8);
use XML::Twig;

use Readonly;
Readonly our $TRUE  => 1;
Readonly our $FALSE => 0;

our $VERSION = '1.2.2';

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

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

  my $self = bless $options, $class;

  $self->{host}    //= 's3.amazonaws.com';
  $self->{secure}  //= $TRUE;
  $self->{timeout} //= 30;
  $self->{region}  //= 'us-east-1';

  $self->_init_logger;
  $self->_init_credentials;
  $self->_init_ua;

  return $self;
}

########################################################################
# Logger setup
# Priority: caller-supplied object -> Log::Log4perl (if available) ->
#           minimal STDERR logger
########################################################################
sub _init_logger {
########################################################################
  my ($self) = @_;

  my $logger = $self->{logger};

  if ( $logger && blessed $logger ) {
    # Validate it quacks like a logger
    for my $method (qw(trace debug info warn error)) {
      croak "logger object must implement '$method'"
        if !$logger->can($method);
    }

    return;
  }

  my $log4perl = eval {
    require Log::Log4perl;
    1;
  };

  my $log_level = $self->{log_level} // 'warn';
  $self->{log_level} = $log_level;

  if ($log4perl) {
    if ( !Log::Log4perl->initialized ) {
      Log::Log4perl->easy_init( { level => uc $log_level } );
    }
    else {
      $self->{logger} = Log::Log4perl->get_logger(__PACKAGE__);
    }
    return;
  }

  # Fall back to minimal STDERR logger
  $self->{logger} = Amazon::S3::Lite::Logger->new( log_level => $log_level );

  return;
}

########################################################################
# Credential resolution
# Priority: explicit credentials object -> constructor args ->
#           environment variables -> Amazon::Credentials (if available)
########################################################################
sub _init_credentials {
########################################################################
  my ($self) = @_;

  # 1. Caller-supplied credentials object (duck-typed)
  if ( my $creds = $self->{credentials} ) {
    croak "credential object is not blessed.\n"
      if !blessed $creds;

    foreach (qw(aws_access_key_id aws_secret_access_key token)) {
      my $sub = $creds->can($_) // $creds->can("get_$_");

      croak "credentials object must implement $_ or get_$_\n"
        if !$sub;
    }

    $self->{credentials} = $creds;

    return;
  }

  # 2. Explicit constructor args
  if ( $self->{aws_access_key_id} && $self->{aws_secret_access_key} ) {
    $self->{credentials} = Amazon::S3::Lite::Credentials->new(
      aws_access_key_id     => delete $self->{aws_access_key_id},
      aws_secret_access_key => delete $self->{aws_secret_access_key},
      token                 => delete $self->{token},
    );
    return;
  }

  # 3. Environment variables
  if ( $ENV{AWS_ACCESS_KEY_ID} && $ENV{AWS_SECRET_ACCESS_KEY} ) {
    $self->{credentials} = Amazon::S3::Lite::Credentials->new(
      aws_access_key_id     => $ENV{AWS_ACCESS_KEY_ID},
      aws_secret_access_key => $ENV{AWS_SECRET_ACCESS_KEY},
      token                 => $ENV{AWS_SESSION_TOKEN},
    );
    return;
  }

  # 4. Amazon::Credentials (covers IAM roles, ECS task roles,
  #    ~/.aws/credentials, etc.)
  if ( eval { require Amazon::Credentials; 1 } ) {
    $self->{credentials} = Amazon::Credentials->new;
    return;
  }

  croak 'No AWS credentials found. Supply aws_access_key_id/'
    . 'aws_secret_access_key, set AWS_ACCESS_KEY_ID/'
    . 'AWS_SECRET_ACCESS_KEY environment variables, '
    . 'or install Amazon::Credentials for IAM role support.';
}

########################################################################
# HTTP::Tiny instance - one per object, keep-alive enabled
########################################################################
sub _init_ua {
########################################################################
  my ($self) = @_;

  $self->{ua} = HTTP::Tiny->new(
    timeout    => $self->{timeout},
    verify_SSL => $self->{secure},
  );

  return;
}

########################################################################
# Accessors
########################################################################
sub logger      { return $_[0]->{logger} }
sub log_level   { return $_[0]->{log_level}; }
sub ua          { return $_[0]->{ua} }
sub region      { return $_[0]->{region} }
sub host        { return $_[0]->{host} }
sub credentials { return $_[0]->{credentials} }

########################################################################
# Build a fresh signer from current credentials.
# Called per-request so that rotating credentials (Lambda IAM roles)
# are always current.
########################################################################
sub _signer {
########################################################################
  my ( $self, $region ) = @_;

  my $creds = $self->credentials;

  my $access_key
    = $creds->can('get_aws_access_key_id')
    ? $creds->get_aws_access_key_id
    : $creds->aws_access_key_id;

  my $secret_key
    = $creds->can('get_aws_secret_access_key')
    ? $creds->get_aws_secret_access_key
    : $creds->aws_secret_access_key;

  my $token_sub = $creds->can('get_token') // $creds->can('token');
  my $token     = $token_sub ? $token_sub->($creds) : undef;

  return Amazon::Signature4::Lite->new(
    access_key    => $access_key,
    secret_key    => $secret_key,
    session_token => $token,
    region        => $region // $self->region,
    service       => 's3',
  );
}

########################################################################
# Build the endpoint URL for a bucket/key
########################################################################
sub _endpoint {
########################################################################
  my ( $self, $bucket, $key ) = @_;

  my $scheme = $self->{secure} ? 'https' : 'http';
  my $host   = $self->host;

  # Path-style URL: https://s3.amazonaws.com/bucket/key
  # (virtual-hosted style omitted for simplicity; path-style works
  # everywhere and avoids SSL cert issues with dotted bucket names)
  my $url = "$scheme://$host";

  $url .= "/$bucket"              if defined $bucket && length $bucket;
  $url .= '/' . _encode_key($key) if defined $key    && length $key;

  return $url;
}

########################################################################
# URI-encode an S3 key, preserving '/' separators
########################################################################
sub _encode_key {
########################################################################
  my ($key) = @_;

  return join '/', map { uri_escape_utf8( $_, '^A-Za-z0-9\-._~' ) }
    split m{/}, $key, -1;
}

########################################################################
sub _request {
########################################################################
  my ( $self, $method, $url, $headers, $content, $extra, $region ) = @_;

lib/Amazon/S3/Lite.pm  view on Meta::CPAN

      $detail = " - $code: $msg";
    }
  }

  croak sprintf '%s failed: HTTP %s %s%s', $context, $status, $reason, $detail;
}

1;

## no critic (RequirePodSections)

__DATA__
:filters
<Filter>
  <S3Key>
    @filter_rules@
  </S3Key>
</Filter>
:filter-rule
<FilterRule>
  <Name>@filter_name@</Name>
  <Value>@filter@</Value>
</FilterRule>
:event
<Event>@event@</Event>
:lambda-event
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
  <CloudFunctionConfiguration>
    <Id>@id@</Id>
    <CloudFunction>@lambda_arn@</CloudFunction>
    @events@
    @filters@
  </CloudFunctionConfiguration>
</NotificationConfiguration>
:sqs-event
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
  <QueueConfiguration>
    <Id>@id@</Id>
    <Queue>@queue_arn@</Queue>
    @events@
    @filters@
  </QueueConfiguration>
</NotificationConfiguration>

=pod

=encoding utf8

=head1 NAME

Amazon::S3::Lite - A lightweight Amazon S3 client for common
operations

=head1 SYNOPSIS

  use Amazon::S3::Lite;

  # Credentials from environment or IAM role automatically
  my $s3 = Amazon::S3::Lite->new({ region => 'us-east-1' });

  # Explicit credentials
  my $s3 = Amazon::S3::Lite->new({
    region                => 'us-east-1',
    aws_access_key_id     => $key,
    aws_secret_access_key => $secret,
    token                 => $session_token,  # optional, for STS/Lambda roles
  });

  # Pass any credentials object with standard getters
  my $s3 = Amazon::S3::Lite->new({
    region      => 'us-east-1',
    credentials => $creds_obj,
  });

  # List objects in a bucket
  my $result = $s3->list_objects_v2('my-bucket', prefix => 'logs/');

  foreach my $obj ( @{ $result->{objects} } ) {
    printf "%s  %d bytes\n", $obj->{key}, $obj->{size};
  }

  # Paginate
  while ( $result->{is_truncated} ) {
    $result = $s3->list_objects_v2('my-bucket',
      prefix             => 'logs/',
      continuation_token => $result->{next_continuation_token},
    );
    # ... process $result->{objects}
  }

  # Get an object
  my $obj = $s3->get_object('my-bucket', 'path/to/key.json');
  print $obj->{content};

  # Head an object (existence check / metadata only)
  my $meta = $s3->head_object('my-bucket', 'path/to/key.json');
  if ($meta) {
    print $meta->{content_length};
  }

  # Put an object
  $s3->put_object('my-bucket', 'path/to/key.json', $json_string,
    content_type => 'application/json',
    metadata     => { source => 'lambda' },
  );

  # Copy an object
  $s3->copy_object(
    src_bucket => 'my-bucket', src_key => 'orig/file.json',
    dst_bucket => 'my-bucket', dst_key => 'archive/file.json',
  );

  # Delete an object
  $s3->delete_object('my-bucket', 'path/to/key.json');

  # List all buckets
  my $result = $s3->list_buckets;
  for my $bucket ( @{ $result->{buckets} } ) {
    print $bucket->{name}, "\n";
  }

  # Create a bucket
  $s3->create_bucket('my-bucket');
  $s3->create_bucket('my-bucket', region => 'eu-west-1');

  # Configure a Lambda notification trigger
  $s3->put_bucket_notification_configuration('my-bucket',
    type       => 'lambda',
    lambda_arn => $function_arn,
    events     => 's3:ObjectCreated:*',
    filters    => { prefix => 'uploads/' },
  );

lib/Amazon/S3/Lite.pm  view on Meta::CPAN

  # Configure an SQS notification trigger
  $s3->put_bucket_notification_configuration('my-bucket',
    type      => 'sqs',
    queue_arn => $queue_arn,
    events    => 's3:ObjectCreated:*',
  );

  # Retrieve notification configuration
  my $configs = $s3->get_bucket_notification_configuration('my-bucket');
  for my $cfg ( @{$configs} ) {
    printf "id=%s lambda=%s queue=%s\n",
      $cfg->{id}, $cfg->{lambda_arn} // '', $cfg->{queue_arn} // '';
  }

=head1 DESCRIPTION

C<Amazon::S3::Lite> is a minimal Amazon S3 client covering the
operations most commonly needed in AWS Lambda functions and
lightweight scripts: listing buckets, listing objects, reading,
writing, copying, and deleting.

It is built on L<HTTP::Tiny> (core since Perl 5.14) and
L<Amazon::Signature4::Lite>, with no dependency on LWP or any part of
the libwww-perl ecosystem. The dependency list is intentionally small,
making it well-suited for Lambda container images where minimizing
cold-start time and image size matters.

It is not a replacement for L<Amazon::S3> or L<Net::Amazon::S3>, which
support the full S3 API surface including multipart upload, bucket
management, ACLs, versioning, and presigned URLs. If you need those
features, use one of those distributions instead.

L<Amazon::S3::Thin> is another excellent lightweight S3 client with a
similar philosophy and a longer track record. It is more complete than
this module - supporting presigned URLs, bulk delete, and
virtual-hosted-style requests - and returns raw L<HTTP::Response>
objects so callers handle status codes and errors
themselves. C<Amazon::S3::Lite> differs in three ways: it has no
dependency on LWP (C<Amazon::S3::Thin> defaults to L<LWP::UserAgent>),
it returns parsed hashrefs rather than raw response objects, and it
has first-class support for Lambda IAM role credential rotation. If
you need the broader feature set or prefer direct HTTP access,
C<Amazon::S3::Thin> is a fine choice.

=head1 CONSTRUCTOR

=head2 new

  my $s3 = Amazon::S3::Lite->new(\%options);

Returns a new C<Amazon::S3::Lite> object. Options:

=over 4

=item region (options, default: us-east-1)

The AWS region for your bucket, e.g. C<us-east-1>.

=item aws_access_key_id / aws_secret_access_key

Static credentials. C<token> may also be supplied for STS temporary
credentials (as used by Lambda execution roles).

These are only consulted if no C<credentials> object is provided.

=item token

Optional STS session token, used alongside static credentials for
temporary credential sets.

=item credentials

An object providing credential getters. The object must respond to:

  $creds->aws_access_key_id
  $creds->aws_secret_access_key
  $creds->token            # may return undef

Any object that satisfies this interface is accepted -
L<Amazon::Credentials>, L<Paws::Credential::*>, or your own. The
getters are called at request time, so objects that refresh expiring
credentials transparently are supported.

=item logger

An object providing the standard log methods:

  $logger->trace(...)
  $logger->debug(...)
  $logger->info(...)
  $logger->warn(...)
  $logger->error(...)

If not supplied, the module looks for L<Log::Log4perl>. If available,
it calls C<Log::Log4perl::easy_init> with the configure log level (or
WARN) and logs to STDERR.  If Log::Log4perl is not installed, a
minimal internal logger.

=item host

Override the S3 endpoint host. Defaults to C<s3.amazonaws.com>.
Useful for S3-compatible services (MinIO, Ceph, LocalStack).

=item secure

Use HTTPS. Default is 1 (true). Set to 0 only for testing against
local S3-compatible endpoints.

=item timeout

HTTP request timeout in seconds. Default is 30.

=back

=head2 Credential resolution order

When no C<credentials> object is passed, credentials are resolved in
this order:

=over 4

=item 1.

Constructor arguments C<aws_access_key_id> and C<aws_secret_access_key>.

=item 2.

Environment variables C<AWS_ACCESS_KEY_ID>, C<AWS_SECRET_ACCESS_KEY>,
and optionally C<AWS_SESSION_TOKEN>.

=item 3.

L<Amazon::Credentials>, if installed. This covers IAM instance roles,
Lambda execution roles, ECS task roles, and C<~/.aws/credentials>
profiles.

=item 4.

If none of the above yield credentials, the constructor croaks.

=back

=head1 METHODS

All methods croak on unrecoverable errors (network failure, HTTP 5xx).
HTTP 404 is not an exception - methods that can meaningfully return
C<undef> for a missing resource do so.

=head2 list_objects_v2

  my $result = $s3->list_objects_v2($bucket, %options);

Lists objects in C<$bucket> using the S3 ListObjectsV2 API.

Options:

=over 4

=item prefix

Limit results to keys beginning with this string.

=item delimiter

Group keys sharing a common prefix up to this delimiter. Grouped
prefixes are returned in C<common_prefixes>.

=item max_keys

Maximum number of objects to return per call (1-1000, default 1000).

=item continuation_token

Resume a truncated listing from a prior call's
C<next_continuation_token>.

=item start_after

Return only keys lexicographically after this value.

=back

Returns a hashref:

  {
    bucket                 => 'my-bucket',
    prefix                 => 'logs/',
    is_truncated           => 0,
    next_continuation_token => undef,        # set when is_truncated is true
    key_count              => 42,
    objects                => [
      {
        key           => 'logs/2024-01-01.gz',
        size          => 102400,
        last_modified => '2024-01-01T00:00:00.000Z',
        etag          => 'abc123',
        storage_class => 'STANDARD',
      },
      ...

lib/Amazon/S3/Lite.pm  view on Meta::CPAN


  $s3->remove_bucket_notification_configuration($bucket);

Removes all notification configurations from C<$bucket> by sending an
empty C<NotificationConfiguration> document to S3. After this call S3
will no longer deliver any events for the bucket.

Returns true on success. Croaks on failure.

=head1 ERROR HANDLING

Methods croak on:

=over 4

=item * Network-level failures (connection refused, timeout, DNS failure)

=item * HTTP 5xx responses from S3

=item * Unexpected HTTP 3xx responses that could not be resolved

=back

Methods return C<undef> on:

=over 4

=item * HTTP 404 (key or bucket not found), where the return type allows it

=back

All other HTTP error codes (400, 403, 409, etc.) cause a croak with a
message containing the HTTP status line and the S3 error body where
available.

=head1 DEPENDENCIES

=over 4

=item * L<HTTP::Tiny> (core since Perl 5.14)

=item * L<Amazon::Signature4::Lite>

=item * L<XML::Twig> (for parsing list and copy responses)

=item * L<Digest::MD5> (core, for Content-MD5 headers)

=item * L<MIME::Base64> (core)

=item * L<URI::Escape>

=item * L<Carp> (core)

=back

Optional:

=over 4

=item * L<Amazon::Credentials> - automatic credential discovery from IAM
roles, ECS task roles, ~/.aws/credentials, and environment.

=item * L<Log::Log4perl> - structured logging; if present, used in
preference to the built-in minimal logger.

=back

=head1 LAMBDA USAGE NOTES

In a Lambda container, credentials come from the execution role via
the ECS credential provider endpoint (indicated by
C<AWS_CONTAINER_CREDENTIALS_RELATIVE_URI> in the environment).
L<Amazon::Credentials> handles this automatically when installed and
is the recommended approach. If you prefer not to take that
dependency, the Lambda runtime also populates C<AWS_ACCESS_KEY_ID>,
C<AWS_SECRET_ACCESS_KEY>, and C<AWS_SESSION_TOKEN> directly, which
this module picks up automatically from the environment.

B<Region note:> The C<list_buckets> method is a global S3 operation
and is always signed against C<us-east-1>, regardless of the region
supplied to the constructor. This is an S3 requirement, not a
limitation of this module, and is handled transparently - your
object's region is not changed.

B<Cold start:> Because this module depends only on L<HTTP::Tiny> (Perl
core), L<XML::Twig>, L<AWS::Signature4>, and L<URI::Escape>, it adds
minimal overhead to Lambda container image builds compared to
LWP-based S3 clients.

=head1 TESTING

When testing against LocalStack, be aware that LocalStack is more
lenient than real S3 regarding SigV4 requirements. In particular,
LocalStack may accept requests where the C<x-amz-content-sha256>
header is missing or where session token handling is incorrect. Tests
that pass against LocalStack should always be verified against real S3
before release.

=head1 SEE ALSO

L<Amazon::S3> - the full-featured S3 client this module draws from

L<Amazon::S3::Thin> - another excellent lightweight S3 client with a
similar philosophy, broader feature coverage, and a longer track
record. Uses LWP by default and returns raw L<HTTP::Response>
objects. See L</DESCRIPTION> for a detailed comparison.

L<Net::Amazon::S3> - a Moose-based full-featured alternative

L<Amazon::Signature4::Lite> - the signing module used internally

L<Amazon::Credentials> - credential provider with IAM role and profile
support

=head1 AUTHOR

Rob Lauer <rlauer@treasurersbriefcase.com>

=head1 LICENSE

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.

=cut



( run in 0.638 second using v1.01-cache-2.11-cpan-cdf2f3d4e48 )