Skip to content

Commit

Permalink
Merge pull request #21 from xp-forge/feature/s3-key
Browse files Browse the repository at this point in the history
Add S3Key implementation to construct S3 paths
  • Loading branch information
thekid authored Oct 15, 2024
2 parents 21dc72d + c9d8f27 commit f125a6b
Show file tree
Hide file tree
Showing 8 changed files with 184 additions and 55 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Streaming uploads to S3

```php
use com\amazon\aws\api\SignatureV4;
use com\amazon\aws\{ServiceEndpoint, CredentialProvider};
use com\amazon\aws\{ServiceEndpoint, CredentialProvider, S3Key};
use io\File;
use util\cmd\Console;

Expand All @@ -75,7 +75,7 @@ $file= new File('large.txt');
$file->open(File::READ);

try {
$transfer= $s3->resource('target/{0}', [$file->filename])->open('PUT', [
$transfer= $s3->resource(new S3Key('target', $file->filename))->open('PUT', [
'x-amz-content-sha256' => SignatureV4::UNSIGNED, // Or calculate from file
'Content-Type' => 'text/plain',
'Content-Length' => $file->size(),
Expand Down
41 changes: 41 additions & 0 deletions src/main/php/com/amazon/aws/S3Key.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php namespace com\amazon\aws;

use lang\Value;

/**
* S3 Keys help construct paths from components
*
* @test com.amazon.aws.unittest.S3KeyTest
*/
class S3Key implements Value {
private $path;

/** Creates a new S3 key from given components */
public function __construct(string... $components) {
$this->path= ltrim(implode('/', $components), '/');
}

/** Returns the path */
public function path(string $base= ''): string {
return rtrim($base, '/').'/'.$this->path;
}

/** @return string */
public function __toString() { return '/'.$this->path; }

/** @return string */
public function hashCode() { return 'S3'.md5($this->path); }

/** @return string */
public function toString() { return nameof($this).'(/'.$this->path.')'; }

/**
* Comparison
*
* @param var $value
* @return int
*/
public function compareTo($value) {
return $value instanceof self ? $this->path <=> $value->path : 1;
}
}
65 changes: 38 additions & 27 deletions src/main/php/com/amazon/aws/ServiceEndpoint.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,28 +128,47 @@ public function connecting($connections) {
}

/**
* Returns a new resource consisting of path including
* optional placeholders and replacement segments.
* Returns a new resource consisting of path including optional placeholders
* and replacement segments.
*
* @param string|com.amazon.aws.S3Key $path
* @throws lang.ElementNotFoundException
*/
public function resource(string $path, array $segments= []): Resource {
public function resource($path, array $segments= []): Resource {
return new Resource($this, $path, $segments, $this->marshalling);
}

/**
* Extracts path, encoded and params from a given target. Handles S3 keys, which do
* not double-encode the path component in the canonical request.
*
* @param com.amazon.aws.api.SignatureV4 $signature
* @param string|com.amazon.aws.S3Key $target
* @return var[]
*/
private function target($signature, $target) {
if ($target instanceof S3Key) {
$path= $target->path($this->base);
return [$path, $signature->encoded($path), []];
} else if (false === ($p= strpos($target, '?'))) {
$path= $path= $this->base.ltrim($target, '/');
return [$path, $path, []];
} else {
parse_str(substr($target, $p + 1), $params);
$path= $this->base.ltrim(substr($target, 0, $p), '/');
return [$path, $path, $params];
}
}

/** Signs a given target (optionally including parameters) with a given expiry time */
public function sign(string $target, int $expires= 3600, $time= null): string {
public function sign($target, int $expires= 3600, $time= null): string {
$signature= new SignatureV4($this->credentials());
list($path, $encoded, $params)= $this->target($signature, $target);

$host= $this->domain();
$region= $this->region ?? '*';

// Combine target parameters with `X-Amz-*` headers used for signature
if (false === ($p= strpos($target, '?'))) {
$params= [];
} else {
parse_str(substr($target, $p + 1), $params);
$target= substr($target, 0, $p);
}
$params+= [
'X-Amz-Algorithm' => SignatureV4::ALGO,
'X-Amz-Credential' => $signature->credential($this->service, $region, $time),
Expand All @@ -161,12 +180,11 @@ public function sign(string $target, int $expires= 3600, $time= null): string {

// Next, sign path and query string with the special hash `UNSIGNED-PAYLOAD`,
// signing only the "Host" header as indicated above.
$link= $this->base.ltrim($target, '/');
$signature= $signature->sign(
$this->service,
$region,
'GET',
$link,
$path,
$params,
SignatureV4::UNSIGNED,
['Host' => $host],
Expand All @@ -175,33 +193,26 @@ public function sign(string $target, int $expires= 3600, $time= null): string {

// Finally, append signature parameter to signed link
$params['X-Amz-Signature']= $signature['signature'];
return "https://{$host}{$link}?".http_build_query($params, '', '&', PHP_QUERY_RFC3986);
return "https://{$host}{$encoded}?".http_build_query($params, '', '&', PHP_QUERY_RFC3986);
}

/**
* Opens a request and returns a `Transfer` instance for writing data to
*
* @throws io.IOException
*/
public function open(string $method, string $target, array $headers, $hash= null, $time= null): Transfer {
public function open(string $method, $target, array $headers, $hash= null, $time= null): Transfer {
$signature= new SignatureV4($this->credentials());
list($path, $encoded, $params)= $this->target($signature, $target);

$host= $this->domain();
$target= $this->base.ltrim($target, '/');
$conn= ($this->connections)('https://'.$host.$target);
$conn= ($this->connections)('https://'.$host.$encoded);
$conn->setTrace($this->cat);

// Parse and separate query string parameters
if (false === ($p= strpos($target, '?'))) {
$params= [];
} else {
parse_str(substr($target, $p + 1), $params);
$target= substr($target, 0, $p);
}

// Create and sign request
$request= $conn->create(new HttpRequest());
$request->setMethod($method);
$request->setTarget(strtr(rawurlencode($target), ['%2F' => '/']));
$request->setTarget($encoded);
$request->addHeaders($headers);

// Compile headers from given host and time including our user agent
Expand All @@ -228,7 +239,7 @@ public function open(string $method, string $target, array $headers, $hash= null
$this->service,
$this->region ?? '*',
$method,
$target,
$path,
$params,
$hash ?? $headers['x-amz-content-sha256'] ?? SignatureV4::NO_PAYLOAD,
$signed,
Expand All @@ -250,7 +261,7 @@ public function open(string $method, string $target, array $headers, $hash= null
*
* @throws io.IOException
*/
public function request(string $method, string $target, array $headers= [], $payload= null, $time= null): Response {
public function request(string $method, $target, array $headers= [], $payload= null, $time= null): Response {
if (null === $payload) {
$transfer= $this->open($method, $target, $headers + ['Content-Length' => 0], SignatureV4::NO_PAYLOAD, $time);
} else {
Expand Down
40 changes: 23 additions & 17 deletions src/main/php/com/amazon/aws/api/Resource.class.php
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
<?php namespace com\amazon\aws\api;

use com\amazon\aws\S3Key;
use lang\{ElementNotFoundException, IllegalArgumentException};
use text\json\Json;
use util\data\Marshalling;

/** @test com.amazon.aws.unittest.ResourceTest */
class Resource {
private $endpoint, $marshalling;
public $target= '';
public $target;

/**
* Creates a new resource on a given endpoint
*
* @param com.amazon.aws.ServiceEndpoint $endpoint
* @param string $path
* @param string|com.amazon.aws.S3Key $path
* @param string[]|[:string] $segments
* @param ?util.data.Marshalling $marshalling
* @throws lang.ElementNotFoundException
Expand All @@ -22,23 +23,28 @@ public function __construct($endpoint, $path, $segments= [], $marshalling= null)
$this->endpoint= $endpoint;
$this->marshalling= $marshalling ?? new Marshalling();

$l= strlen($path);
$offset= 0;
do {
$b= strcspn($path, '{', $offset);
$this->target.= substr($path, $offset, $b);
$offset+= $b;
if ($offset >= $l) break;
if ($path instanceof S3Key) {
$this->target= $path;
} else {
$this->target= '';
$l= strlen($path);
$offset= 0;
do {
$b= strcspn($path, '{', $offset);
$this->target.= substr($path, $offset, $b);
$offset+= $b;
if ($offset >= $l) break;

$e= strcspn($path, '}', $offset);
$name= substr($path, $offset + 1, $e - 1);
if (null === ($segment= $segments[$name] ?? null)) {
throw new ElementNotFoundException('No such segment "'.$name.'"');
}
$e= strcspn($path, '}', $offset);
$name= substr($path, $offset + 1, $e - 1);
if (null === ($segment= $segments[$name] ?? null)) {
throw new ElementNotFoundException('No such segment "'.$name.'"');
}

$this->target.= $segment;
$offset+= $e + 1;
} while ($offset < $l);
$this->target.= rawurlencode($segment);
$offset+= $e + 1;
} while ($offset < $l);
}
}

/**
Expand Down
9 changes: 7 additions & 2 deletions src/main/php/com/amazon/aws/api/SignatureV4.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ public function securityToken() {
return $this->credentials->sessionToken();
}

/** URI-encode a given path */
public function encoded(string $path): string {
return strtr(rawurlencode($path), ['%2F' => '/']);
}

/** Returns a signature */
public function sign(
string $service,
Expand All @@ -57,14 +62,14 @@ public function sign(
): array {
$requestDate= $this->datetime($time);

// Create a canonical request using the URI-encoded version of the path
// Step 1: Create a canonical request using the URI-encoded version of the path
if ($params) {
ksort($params);
$query= http_build_query($params, '', '&', PHP_QUERY_RFC3986);
} else {
$query= '';
}
$canonical= "{$method}\n".strtr(rawurlencode($target), ['%2F' => '/'])."\n{$query}\n";
$canonical= "{$method}\n{$this->encoded($target)}\n{$query}\n";

// Header names must use lowercase characters and must appear in alphabetical order.
$sorted= [];
Expand Down
12 changes: 6 additions & 6 deletions src/test/php/com/amazon/aws/unittest/RequestTest.class.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<?php namespace com\amazon\aws\unittest;

use com\amazon\aws\api\Resource;
use com\amazon\aws\{ServiceEndpoint, Credentials};
use test\{Assert, Test};
use com\amazon\aws\{ServiceEndpoint, Credentials, S3Key};
use test\{Assert, Test, Values};
use util\Date;

class RequestTest {
Expand Down Expand Up @@ -142,18 +142,18 @@ public function transfer() {
Assert::equals('', $response->content());
}

#[Test]
public function transfer_via_resource_path_and_segments() {
#[Test, Values(['with space.png', 'ümläut.png', 'encoded+char.png'])]
public function transfer_via_s3key_resource($filename) {
$file= 'PNG...';
$s3= $this->endpoint('s3', [
'/target/upload%20file.png' => [
'/target/'.rawurlencode($filename) => [
'HTTP/1.1 200 OK',
'Content-Length: 0',
'',
]
]);

$transfer= $s3->resource('/target/{0}', ['upload file.png'])->open('PUT', [
$transfer= $s3->resource(new S3Key('target', $filename))->open('PUT', [
'x-amz-content-sha256' => hash('sha256', $file),
'Content-Type' => 'image/png',
'Content-Length' => strlen($file),
Expand Down
46 changes: 46 additions & 0 deletions src/test/php/com/amazon/aws/unittest/S3KeyTest.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php namespace com\amazon\aws\unittest;

use com\amazon\aws\S3Key;
use test\{Assert, Test, Values};

class S3KeyTest {

#[Test]
public function empty() {
Assert::equals('/', (new S3Key())->path());
}

#[Test]
public function single() {
Assert::equals('/test', (new S3Key('test'))->path());
}

#[Test]
public function composed() {
Assert::equals('/target/test', (new S3Key('target', 'test'))->path());
}

#[Test, Values(['/base', '/base/'])]
public function based($base) {
Assert::equals('/base/test', (new S3Key('test'))->path($base));
}

#[Test]
public function string_cast() {
Assert::equals('/test', (string)new S3Key('test'));
}

#[Test]
public function string_representation() {
Assert::equals('com.amazon.aws.S3Key(/target/test)', (new S3Key('target', 'test'))->toString());
}

#[Test]
public function comparison() {
$fixture= new S3Key('b-test');

Assert::equals(0, $fixture->compareTo(new S3Key('b-test')));
Assert::equals(1, $fixture->compareTo(new S3Key('a-test')));
Assert::equals(-1, $fixture->compareTo(new S3Key('c-test')));
}
}
Loading

0 comments on commit f125a6b

Please sign in to comment.