Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add S3Key implementation to construct S3 paths #21

Merged
merged 8 commits into from
Oct 15, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
}
67 changes: 38 additions & 29 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,45 @@ 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 {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With PHP 8.0+, we could use string|S3Key here, see https://wiki.php.net/rfc/union_types_v2

$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 +178,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 +191,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 +237,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 +259,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
@@ -1,6 +1,6 @@
<?php namespace com\amazon\aws\api;

use com\amazon\aws\Credentials;
use com\amazon\aws\{Credentials, S3Key};
use peer\http\HttpRequest;

/**
Expand Down 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 @@ -64,7 +69,7 @@ public function sign(
} 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
Loading