Amazon-S3-Lite

 view release on metacpan or  search on metacpan

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

  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 ) = @_;

  $region  //= $self->region;
  $headers //= {};
  $content //= q{};
  $extra   //= {};

  my $content_is_coderef = ref $content eq 'CODE';

  # sign — returns merged headers ready for HTTP::Tiny
  my $signed = $self->_signer($region)->sign(
    method  => $method,
    url     => $url,
    headers => $headers,
    payload => $content_is_coderef ? q{} : $content,
  );

  # HTTP::Tiny sets Host itself — remove to avoid duplicate header error
  delete $signed->{host};

  $self->logger->debug("$method $url");

  my $options = { headers => $signed };

  if ( length $content || $content_is_coderef ) {
    $options->{content} = $content;
  }

  if ( $extra->{data_callback} ) {
    $options->{data_callback} = $extra->{data_callback};
  }

  my $response = $self->ua->request( $method, $url, $options );

  $self->logger->debug( sprintf 'Response: %s %s', $response->{status}, $response->{reason} );

  return $response;
}

########################################################################
# head_object( $bucket, $key )
#
# Fetches metadata for an object without retrieving the body.
# Returns undef if the key does not exist (404).
# Returns a hashref with content_type, content_length, etag,
# last_modified, and metadata (x-amz-meta-* headers).
########################################################################
sub head_object {
########################################################################
  my ( $self, $bucket, $key ) = @_;

  croak 'bucket is required' if !defined $bucket || !length $bucket;
  croak 'key is required'    if !defined $key    || !length $key;

  my $url      = $self->_endpoint( $bucket, $key );
  my $response = $self->_request( 'HEAD', $url );

  return undef ## no critic (Subroutines::ProhibitExplicitReturnUndef)
    if _is_not_found($response);

  $self->_croak_on_error( $response, 'head_object' );

  return $self->_extract_object_metadata( $response->{headers} );
}

########################################################################
# Extract the standard object metadata hashref from a response headers
# hash. Used by both head_object and get_object.
########################################################################
sub _extract_object_metadata {
########################################################################
  my ( $self, $headers ) = @_;

  my $etag = $headers->{etag};
  $etag =~ s/\A"|"\z//gxsm if defined $etag;

  # Collect x-amz-meta-* headers, stripping the prefix from the key
  my %metadata;
  for my $name ( keys %{$headers} ) {
    if ( $name =~ /^x-amz-meta-(.+)$/xsm ) {
      $metadata{$1} = $headers->{$name};
    }
  }

  return {
    content_type   => $headers->{'content-type'},
    content_length => $headers->{'content-length'} + 0,
    etag           => $etag,
    last_modified  => $headers->{'last-modified'},
    metadata       => \%metadata,
  };
}

########################################################################
# get_object( $bucket, $key, %options )
#
# Fetches an object from S3. Options:
#   range    => 'bytes=0-1023'   partial fetch
#   filename => '/tmp/foo'       stream body to disk; omits content key
#
# Returns undef on 404.
# Returns a hashref with content_type, content_length, etag,
# last_modified, metadata, and content (unless filename is used).
########################################################################
sub get_object {
########################################################################
  my ( $self, $bucket, $key, %options ) = @_;

  croak 'bucket is required' if !defined $bucket || !length $bucket;
  croak 'key is required'    if !defined $key    || !length $key;

  my $url = $self->_endpoint( $bucket, $key );

  my %headers;
  $headers{Range} = $options{range} if defined $options{range};

  my $filename = $options{filename};
  my $extra    = {};

  if ( defined $filename ) {
    # Open the destination file before making the request so we catch
    # permission errors early, before network round-trip
    open my $fh, '>', $filename
      or croak "cannot open '$filename' for writing: $!";

    $extra->{data_callback} = sub {
      my ($data) = @_;
      print {$fh} $data
        or croak "write to '$filename' failed: $!";
    };

    my $response = $self->_request( 'GET', $url, \%headers, q{}, $extra );

    close $fh
      or croak "close of '$filename' failed: $!";

    return undef ## no critic (Subroutines::ProhibitExplicitReturnUndef)
      if _is_not_found($response);

    $self->_croak_on_error( $response, 'get_object' );

    # Return metadata only — content is on disk
    return $self->_extract_object_metadata( $response->{headers} );
  }

  # In-memory path
  my $response = $self->_request( 'GET', $url, \%headers );

  return undef ## no critic (Subroutines::ProhibitExplicitReturnUndef)
    if _is_not_found($response);

  $self->_croak_on_error( $response, 'get_object' );

  my $result = $self->_extract_object_metadata( $response->{headers} );
  $result->{content} = $response->{content};

  return $result;
}

########################################################################
# delete_object( $bucket, $key, %options )

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

      'MaxKeys'                 => sub { $max_keys     = $_[1]->text + 0 },
      'IsTruncated'             => sub { $is_truncated = $_[1]->text eq 'true' ? 1 : 0 },
      'NextContinuationToken'   => sub { $next_token   = $_[1]->text },
      'Contents'                => sub {
        my ( $t, $node ) = @_;
        my $etag = $node->first_child_text('ETag') // q{};
        $etag =~ s/\A"|"\z//gxsm;
        push @objects,
          {
          key           => $node->first_child_text('Key'),
          size          => $node->first_child_text('Size') + 0,
          last_modified => $node->first_child_text('LastModified'),
          etag          => $etag,
          storage_class => $node->first_child_text('StorageClass'),
          };
        $t->purge;  # free memory as we go - important for large listings
      },
      'CommonPrefixes' => sub {
        my ( $t, $node ) = @_;
        push @common_prefixes, $node->first_child_text('Prefix');
      },
    }
  )->parse($xml);

  return {
    bucket                  => $bucket,
    prefix                  => $prefix,
    key_count               => $key_count,
    max_keys                => $max_keys,
    is_truncated            => $is_truncated,
    next_continuation_token => $next_token,
    objects                 => \@objects,
    common_prefixes         => \@common_prefixes,
  };
}

########################################################################
# list_all_objects_v2( $bucket, %options )
#
# Convenience wrapper that auto-paginates list_objects_v2 and returns
# a flat list of all matching object hashrefs.
# delimiter is ignored — use list_objects_v2 directly for that.
########################################################################
sub list_all_objects_v2 {
########################################################################
  my ( $self, $bucket, %options ) = @_;

  # delimiter is meaningless here — silently remove it
  delete $options{delimiter};

  my @all_objects;
  my $continuation_token;

  while ($TRUE) {
    if ( defined $continuation_token ) {
      $options{continuation_token} = $continuation_token;
    }

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

    last if !$result;  # 404 / empty bucket

    push @all_objects, @{ $result->{objects} };

    last if !$result->{is_truncated};

    $continuation_token = $result->{next_continuation_token};
  }

  return @all_objects;
}

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

  my $xml = $self->_create_notification_configuration( $bucket, %options );

  my $url = $self->_endpoint($bucket) . q{?notification=};

  my %headers = (
    'Content-Type'   => 'application/xml',
    'Content-Length' => length $xml,
    'Content-MD5'    => encode_base64( md5($xml), q{} ),
  );

  my $response = $self->_request( 'PUT', $url, \%headers, $xml );

  $self->_croak_on_error( $response, 'put_bucket_notification_configuration' );

  return $TRUE;
}

########################################################################
sub remove_bucket_notification_configuration {
########################################################################
  my ( $self, $bucket ) = @_;

  croak 'bucket is required'
    if !defined $bucket || !length $bucket;

  my $xml = <<'END_XML';
<NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"/>
END_XML

  my $url = $self->_endpoint($bucket) . q{?notification=};

  my %headers = (
    'Content-Type'   => 'application/xml',
    'Content-Length' => length $xml,
    'Content-MD5'    => encode_base64( md5($xml), q{} ),
  );

  my $response = $self->_request( 'PUT', $url, \%headers, $xml );

  $self->_croak_on_error( $response, 'remove_bucket_notification_configuration' );

  return $TRUE;
}

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

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

  return \%TEMPLATES
    if %TEMPLATES;

  local $RS = undef;

  my $data = <DATA>;
  $data =~ s/\A(.*?)^=pod.*\z/$1/xsm;

  my $t             = q{};
  my $template_name = q{};

  foreach my $line ( split /\n/xsm, $data ) {
    if ( $line =~ /^:(.*)$/xsm ) {
      if ( $template_name && $t ) {
        $TEMPLATES{$template_name} = $t;
      }
      $t             = q{};
      $template_name = $1;
      next;
    }

    $t .= "$line\n";
  }

  $TEMPLATES{$template_name} = $t;

  return \%TEMPLATES;
}

########################################################################
sub _resolve {
########################################################################
  my ( $self, $template, %data ) = @_;

  my $output = $template;

  foreach my $p ( pairs %data ) {
    my ( $k, $v ) = @{$p};

    $output =~ s/[@]\Q$k\E[@]/$v/xsmg;
  }

  return $output;
}

########################################################################
# Error checking helpers
########################################################################
sub _is_success {
########################################################################
  return $_[0]->{status} =~ /\A2\d{2}\z/;
}

########################################################################
sub _is_not_found {
########################################################################
  return $_[0]->{status} == 404;
}

########################################################################
sub _croak_on_error {
########################################################################
  my ( $self, $response, $context ) = @_;

  return if _is_success($response);

  my ( $status, $reason ) = @{$response}{qw(status reason)};

  # Attempt to extract S3 error message from XML body
  my $detail = q{};

  if ( $response->{content} && $response->{content} =~ /<\?xml/xsm ) {
    my ($code) = $response->{content} =~ m{<Code>([^<]+)</Code>}xsm;
    my ($msg)  = $response->{content} =~ m{<Message>([^<]+)</Message>}xsm;

    if ( $code || $msg ) {
      $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@

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

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',
      },
      ...
    ],
    common_prefixes        => [],            # populated when delimiter is set
  }

=head2 list_all_objects_v2

  my @objects = $s3->list_all_objects_v2($bucket, %options);

Convenience wrapper around L</list_objects_v2> that automatically
follows continuation tokens and returns a flat list of all matching
object hashrefs in a single call.

Accepts the same options as C<list_objects_v2> except
C<continuation_token> (which is managed internally) and C<delimiter>
(which is silently ignored - see below).

  my @logs = $s3->list_all_objects_v2('my-bucket', prefix => 'logs/');

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

Be mindful of memory when listing buckets with large numbers of
objects.  For very large listings, use L</list_objects_v2> directly
and process each page as it arrives.

C<delimiter> and C<common_prefixes> are not supported by this method.
The purpose of C<list_all_objects_v2> is a complete flat listing of
all matching keys. Hierarchical directory-style traversal using
C<delimiter> is inherently page-by-page and should use
L</list_objects_v2> directly.

Returns a (possibly empty) list of object hashrefs, each with the same
fields as the elements of C<objects> in the C<list_objects_v2>
response.

=item log_level

Log level for the internal logger. Accepted values: C<trace>, C<debug>,
C<info>, C<warn>, C<error>, C<fatal>. Default is C<warn>. Only consulted
when no C<logger> object is supplied and Log::Log4perl is not available
or not yet initialized.

=head2 get_object

  my $obj = $s3->get_object($bucket, $key);
  my $obj = $s3->get_object($bucket, $key, %options);

Fetches the object at C<$key> in C<$bucket>.

Returns C<undef> if the key does not exist (HTTP 404).

Returns a hashref on success:

  {
    content        => '...',          # raw bytes; absent when filename is used
    content_type   => 'application/json',
    content_length => 1024,
    etag           => 'abc123',
    last_modified  => 'Tue, 01 Jan 2024 00:00:00 GMT',
    metadata       => {               # x-amz-meta-* headers, lowercased
      source => 'lambda',
    },
  }

Options:

=over 4

=item range

An HTTP Range header value, e.g. C<bytes=0-1023>, for partial fetches.

=item filename

Path to a local file where the object body should be written. When
supplied, the response body is streamed directly to disk via
HTTP::Tiny's C<:content_file> mechanism and C<content> is omitted from
the returned hashref. The file is created or overwritten.

  my $meta = $s3->get_object('my-bucket', 'data/dump.csv',
    filename => '/tmp/dump.csv',
  );
  # $meta->{content} is absent; file is on disk

This is the recommended approach for large objects in Lambda where
holding the full body in memory is undesirable.

=back

=head2 head_object

  my $meta = $s3->head_object($bucket, $key);

Fetches metadata for C<$key> without retrieving the object body.
Useful for existence checks and reading C<x-amz-meta-*> headers
cheaply.

Returns C<undef> if the key does not exist (HTTP 404).

Returns a hashref on success with the same fields as C<get_object>
except C<content>, which is always absent.

=head2 put_object

  $s3->put_object($bucket, $key, $data, %options);

Stores C<$data> at C<$key> in C<$bucket>. C<$data> may be:

=over 4

=item * A scalar string (the object body verbatim)

=item * A reference to a scalar (avoids copying large strings)

=item * An open filehandle or L<IO::File> object (body is read to EOF)

=back

When passing a filehandle, C<content_length> becomes required unless
HTTP::Tiny can determine the size from the handle (i.e. the handle is
backed by a real file). For in-memory handles (C<IO::Scalar>, etc.)
you must supply C<content_length> explicitly, or the method will
croak.

  # Scalar
  $s3->put_object('my-bucket', 'hello.txt', 'Hello, world!',
    content_type => 'text/plain',
  );

  # Filehandle
  open my $fh, '<', '/tmp/data.csv' or die $!;
  $s3->put_object('my-bucket', 'data.csv', $fh,
    content_type => 'text/csv',
  );

Options:

=over 4

=item content_type

MIME type for the object. Defaults to C<application/octet-stream>.

=item content_length

Required when C<$data> is an in-memory filehandle. Optional (and
ignored) for scalar data, where length is computed automatically.

=item metadata

Hashref of user-defined metadata. Keys should be bare names - the
C<x-amz-meta-> prefix is added automatically.

  metadata => { source => 'lambda', job_id => '42' }

=item acl

Canned ACL string, e.g. C<private> (default), C<public-read>.

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

Returns an arrayref of configuration hashrefs, each containing:

=over 4

=item id

The configuration entry identifier.

=item lambda_arn

The Lambda function ARN. Present for Lambda notification entries;
C<undef> for SQS entries.

=item queue_arn

The SQS queue ARN. Present for SQS notification entries;
C<undef> for Lambda entries.

=item events

Arrayref of event type strings.

=item filters

Arrayref of hashrefs, each with C<name> (C<prefix> or C<suffix>)
and C<value>.

=back

Returns an empty arrayref if no notification configuration is set.
Croaks on failure.

=head2 remove_bucket_notification_configuration

  $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.



( run in 0.761 second using v1.01-cache-2.11-cpan-788537b7465 )