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 ) {
lib/Amazon/S3/Lite.pm view on Meta::CPAN
# 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 ) = @_;
$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;
lib/Amazon/S3/Lite.pm view on Meta::CPAN
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 )
#
# Deletes an object from S3. Options:
# version_id => $vid delete a specific version
#
# Returns true on success. Note S3 returns 204 for both successful
# deletes and deletes of non-existent keys â no distinction is made.
# Croaks on network or server errors.
########################################################################
sub delete_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 );
if ( defined $options{version_id} ) {
$url .= '?versionId=' . uri_escape_utf8( $options{version_id} );
}
my $response = $self->_request( 'DELETE', $url );
$self->_croak_on_error( $response, 'delete_object' );
return 1;
}
########################################################################
# create_bucket( $bucket, %options )
#
# Creates a new S3 bucket.
#
# us-east-1 is the S3 default region â the CreateBucketConfiguration
# body must NOT be sent for us-east-1 (S3 will error). All other regions
# require it with LocationConstraint set to the target region.
#
# Options: acl, region
#
# Returns true on success. Croaks on failure.
########################################################################
sub create_bucket {
########################################################################
my ( $self, $bucket, %options ) = @_;
croak 'bucket is required'
if !defined $bucket || !length $bucket;
my $region = $options{region} // $self->region;
my $url = $self->_endpoint($bucket);
my %headers;
$headers{'x-amz-acl'} = $options{acl} if $options{acl};
my $content = q{};
# us-east-1 is the implicit default â sending LocationConstraint for it
# causes an error. All other regions require it.
if ( $region ne 'us-east-1' ) {
$content
= sprintf '<CreateBucketConfiguration '
. 'xmlns="http://s3.amazonaws.com/doc/2006-03-01/">'
. '<LocationConstraint>%s</LocationConstraint>'
. '</CreateBucketConfiguration>',
$region;
$headers{'Content-Type'} = 'application/xml';
$headers{'Content-Length'} = length $content;
}
my $response = $self->_request( 'PUT', $url, \%headers, $content, {}, $region );
$self->_croak_on_error( $response, 'create_bucket' );
return 1;
}
########################################################################
# list_buckets()
#
lib/Amazon/S3/Lite.pm view on Meta::CPAN
my $n = read( $data, $buf, $chunk_size );
return $buf if $n;
return q{};
};
}
elsif ( ref $data eq 'SCALAR' ) {
# --- Scalar ref path ---
$body = ${$data};
$headers{'Content-Length'} = length $body;
$headers{'Content-MD5'} = encode_base64( md5($body), q{} );
}
else {
# --- Plain scalar path ---
$body = $data;
$headers{'Content-Length'} = length $body;
$headers{'Content-MD5'} = encode_base64( md5($body), q{} );
}
my $response = $self->_request( 'PUT', $url, \%headers, $body );
$self->_croak_on_error( $response, 'put_object' );
my $etag = $response->{headers}{etag};
$etag =~ s/\A"|"\z//gxsm if defined $etag;
return $etag;
}
########################################################################
# list_objects_v2( $bucket, %options )
#
# Lists objects in a bucket using the S3 ListObjectsV2 API.
# Returns a hashref with keys: bucket, prefix, key_count, max_keys,
# is_truncated, next_continuation_token, objects, common_prefixes.
########################################################################
sub list_objects_v2 {
########################################################################
my ( $self, $bucket, %options ) = @_;
croak 'bucket is required'
if !defined $bucket || !length $bucket;
# Map our option names to S3 query parameter names
my %param_map = (
prefix => 'prefix',
delimiter => 'delimiter',
max_keys => 'max-keys',
continuation_token => 'continuation-token',
start_after => 'start-after',
);
my %params = ( 'list-type' => '2' );
for my $opt ( keys %param_map ) {
if ( defined $options{$opt} ) {
$params{ $param_map{$opt} } = $options{$opt};
}
}
# Build query string
my $query = join q{&}, map { uri_escape_utf8($_) . q{=} . uri_escape_utf8( $params{$_} ) }
sort keys %params;
my $url = $self->_endpoint($bucket) . q{?} . $query;
my $response = $self->_request( 'GET', $url );
return undef ## no critic (Subroutines::ProhibitExplicitReturnUndef)
if _is_not_found($response);
$self->_croak_on_error( $response, 'list_objects_v2' );
return $self->_parse_list_objects_v2( $response->{content} );
}
########################################################################
# Parse the XML body of a ListObjectsV2 response
########################################################################
########################################################################
sub _parse_list_objects_v2 {
########################################################################
my ( $self, $xml ) = @_;
my ( @objects, @common_prefixes );
my ( $bucket, $prefix, $key_count, $max_keys, $is_truncated, $next_token );
XML::Twig->new(
twig_handlers => {
'Name' => sub { $bucket = $_[1]->text },
'ListBucketResult/Prefix' => sub { $prefix = $_[1]->text },
'KeyCount' => sub { $key_count = $_[1]->text + 0 },
'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,
( run in 1.457 second using v1.01-cache-2.11-cpan-9581c071862 )