Skip to content

Commit

Permalink
feat: s3 expires customization (#2955)
Browse files Browse the repository at this point in the history
  • Loading branch information
stobrien89 authored Aug 6, 2024
1 parent 3f9cc3f commit 971e6f3
Show file tree
Hide file tree
Showing 8 changed files with 286 additions and 13 deletions.
7 changes: 7 additions & 0 deletions .changes/nextrelease/expires-customization.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[
{
"type": "feature",
"category": "S3",
"description": "Adds customization to output structures for `Expires` parsing which adds an additional shape `ExpiresString`"
}
]
3 changes: 2 additions & 1 deletion src/Api/Service.php
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ public function getOperation($name)
$this->definition['operations'][$name],
$this->shapeMap
);
} else if ($this->modifiedModel) {
} elseif ($this->modifiedModel) {
$this->operations[$name] = new Operation(
$this->definition['operations'][$name],
$this->shapeMap
Expand Down Expand Up @@ -517,6 +517,7 @@ public function getDefinition()
public function setDefinition($definition)
{
$this->definition = $definition;
$this->shapeMap = new ShapeMap($definition['shapes']);
$this->modifiedModel = true;
}

Expand Down
1 change: 0 additions & 1 deletion src/InputValidationMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,4 @@ public function __invoke(CommandInterface $cmd) {
}
return $nextHandler($cmd);
}

}
56 changes: 56 additions & 0 deletions src/S3/ExpiresParsingMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php
namespace Aws\S3;

use Aws\CommandInterface;
use Aws\ResultInterface;
use Psr\Http\Message\RequestInterface;

/**
* Logs a warning when the `expires` header
* fails to be parsed.
*
* @internal
*/
class ExpiresParsingMiddleware
{
/** @var callable */
private $nextHandler;

/**
* Create a middleware wrapper function.
*
* @return callable
*/
public static function wrap()
{
return function (callable $handler) {
return new self($handler);
};
}

/**
* @param callable $nextHandler Next handler to invoke.
*/
public function __construct(callable $nextHandler)
{
$this->nextHandler = $nextHandler;
}

public function __invoke(CommandInterface $command, RequestInterface $request = null)
{
$next = $this->nextHandler;
return $next($command, $request)->then(
function (ResultInterface $result) {
if (empty($result['Expires']) && !empty($result['ExpiresString'])) {
trigger_error(
"Failed to parse the `expires` header as a timestamp due to "
. " an invalid timestamp format.\nPlease refer to `ExpiresString` "
. "for the unparsed string format of this header.\n"
, E_USER_WARNING
);
}
return $result;
}
);
}
}
69 changes: 58 additions & 11 deletions src/S3/S3Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -437,15 +437,16 @@ public function __construct(array $args)
InputValidationMiddleware::wrap($this->getApi(), self::$mandatoryAttributes),
'input_validation_middleware'
);
$stack->appendSign(ExpiresParsingMiddleware::wrap(), 's3.expires_parsing');
$stack->appendSign(PutObjectUrlMiddleware::wrap(), 's3.put_object_url');
$stack->appendSign(PermanentRedirectMiddleware::wrap(), 's3.permanent_redirect');
$stack->appendInit(Middleware::sourceFile($this->getApi()), 's3.source_file');
$stack->appendInit($this->getSaveAsParameter(), 's3.save_as');
$stack->appendInit($this->getLocationConstraintMiddleware(), 's3.location');
$stack->appendInit($this->getEncodingTypeMiddleware(), 's3.auto_encode');
$stack->appendInit($this->getHeadObjectMiddleware(), 's3.head_object');
$this->processModel($this->isUseEndpointV2());
if ($this->isUseEndpointV2()) {
$this->processEndpointV2Model();
$stack->after('builder',
's3.check_empty_path_with_query',
$this->getEmptyPathWithQuery());
Expand Down Expand Up @@ -763,28 +764,51 @@ public static function _default_s3_express_identity_provider(array $args)
}

/**
* Modifies API definition to remove `Bucket` from request URIs.
* If EndpointProviderV2 is used, removes `Bucket` from request URIs.
* This is now handled by the endpoint ruleset.
*
* Additionally adds a synthetic shape `ExpiresString` and modifies
* `Expires` type to ensure it remains set to `timestamp`.
*
* @param array $args
* @return void
*
* @internal
*/
private function processEndpointV2Model()
private function processModel(bool $isUseEndpointV2): void
{
$definition = $this->getApi()->getDefinition();

foreach($definition['operations'] as &$operation) {
if (isset($operation['http']['requestUri'])) {
$requestUri = $operation['http']['requestUri'];
if ($requestUri === "/{Bucket}") {
$requestUri = str_replace('/{Bucket}', '/', $requestUri);
} else {
$requestUri = str_replace('/{Bucket}', '', $requestUri);
if ($isUseEndpointV2) {
foreach($definition['operations'] as &$operation) {
if (isset($operation['http']['requestUri'])) {
$requestUri = $operation['http']['requestUri'];
if ($requestUri === "/{Bucket}") {
$requestUri = str_replace('/{Bucket}', '/', $requestUri);
} else {
$requestUri = str_replace('/{Bucket}', '', $requestUri);
}
$operation['http']['requestUri'] = $requestUri;
}
}
}

foreach ($definition['shapes'] as $key => &$value) {
$suffix = 'Output';
if (substr($key, -strlen($suffix)) === $suffix) {
if (isset($value['members']['Expires'])) {
$value['members']['Expires']['deprecated'] = true;
$value['members']['ExpiresString'] = [
'shape' => 'ExpiresString',
'location' => 'header',
'locationName' => 'Expires'
];
}
$operation['http']['requestUri'] = $requestUri;
}
}
$definition['shapes']['ExpiresString']['type'] = 'string';
$definition['shapes']['Expires']['type'] = 'timestamp';

$this->getApi()->setDefinition($definition);
}

Expand Down Expand Up @@ -1035,6 +1059,29 @@ public static function applyDocFilters(array $api, array $docs)
'shapes' => ['PutObjectRequest', 'UploadPartRequest']
];

// Add `ExpiresString` shape to output structures which contain `Expires`
// Deprecate existing `Expires` shapes in output structures
// Add/Update documentation for both `ExpiresString` and `Expires`
// Ensure `Expires` type remains timestamp
foreach ($api['shapes'] as $key => &$value) {
$suffix = 'Output';
if (substr($key, -strlen($suffix)) === $suffix) {
if (isset($value['members']['Expires'])) {
$value['members']['Expires']['deprecated'] = true;
$value['members']['ExpiresString'] = [
'shape' => 'ExpiresString',
'location' => 'header',
'locationName' => 'Expires'
];
$docs['shapes']['Expires']['refs'][$key . '$Expires']
.= '<p>This output shape has been deprecated. Please refer to <code>ExpiresString</code> instead.</p>.';
}
}
}
$api['shapes']['ExpiresString']['type'] = 'string';
$docs['shapes']['ExpiresString']['base'] = 'The unparsed string value of the <code>Expires</code> output member.';
$api['shapes']['Expires']['type'] = 'timestamp';

return [
new Service($api, ApiProvider::defaultProvider()),
new DocModel($docs)
Expand Down
26 changes: 26 additions & 0 deletions tests/Api/ServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -269,14 +269,40 @@ public function testModifyModel()
'signingName' => 'qux',
'protocol' => 'yak',
'uid' => 'foo-2016-12-09'
],
'operations' => [
'FooOperation' => [
'name' => 'FooOperation',
'output' => [
'shape' => 'FooOperationOutput'
]
]
],
'shapes' => [
'FooOperationOutput' => [
'type' => 'structure',
'members' => [
'Expires' => [
'shape' => 'Expires',
]
]
],
'Expires' => [
'type' => 'string'
]
]
],
function () { return []; }
);
$definition = $s->getDefinition();
$definition['metadata']['serviceId'] = 'bar';
$definition['shapes']['Expires']['type'] = 'timestamp';
$s->setDefinition($definition);
$this->assertTrue($s->isModifiedModel());
$this->assertEquals( 'bar', $s->getMetadata('serviceId'));
$this->assertEquals(
'timestamp',
$s->getOperation('FooOperation')->getOutput()->getMember('Expires')->getType()
);
}
}
56 changes: 56 additions & 0 deletions tests/S3/ExpiresParsingMiddlewareTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php
namespace Aws\Test\S3;

use Aws\CommandInterface;
use Aws\Result;
use Aws\S3\ExpiresParsingMiddleware;
use Aws\Test\UsesServiceTrait;
use GuzzleHttp\Promise;
use Yoast\PHPUnitPolyfills\TestCases\TestCase;
use Psr\Http\Message\RequestInterface;

/**
* @covers Aws\S3\ExpiresParsingMiddleware
*/
class ExpiresParsingMiddlewareTest extends TestCase
{
use UsesServiceTrait;

public function testEmitsWarningWhenMissingExpires()
{
$this->expectWarning();
$this->expectWarningMessage(
"Failed to parse the `expires` header as a timestamp due to "
. " an invalid timestamp format.\nPlease refer to `ExpiresString` "
. "for the unparsed string format of this header.\n"
);

$command = $this->getMockBuilder(CommandInterface::class)->getMock();
$request = $this->getMockBuilder(RequestInterface::class)->getMock();
$nextHandler = function ($cmd, $request) {
return Promise\Create::promiseFor(new Result([
'ExpiresString' => 'not-a-timestamp'
]));
};

$mw = new ExpiresParsingMiddleware($nextHandler);
$mw($command, $request)->wait();
}

public function testDoesNotEmitWarningWhenExpiresPresent()
{
$command = $this->getMockBuilder(CommandInterface::class)->getMock();
$request = $this->getMockBuilder(RequestInterface::class)->getMock();
$nextHandler = function ($cmd, $request) {
return Promise\Create::promiseFor(new Result([
'ExpiresString' => 'test',
'Expires' => 'test'
]));
};

$mw = new ExpiresParsingMiddleware($nextHandler);
$result = $mw($command, $request)->wait();
$this->assertEquals('test', $result['Expires']);
$this->assertEquals('test', $result['ExpiresString']);
}
}
81 changes: 81 additions & 0 deletions tests/S3/S3ClientTest.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php
namespace Aws\Test\S3;

use Aws\Api\DateTimeResult;
use Aws\Command;
use Aws\CommandInterface;
use Aws\Exception\AwsException;
Expand Down Expand Up @@ -2497,6 +2498,86 @@ public function testCorrectlyResolvesGlobalEndpointWithoutRegionInConstructor(
putenv('AWS_REGION=');
}

public function testExpiresStringInResult()
{
$client = new S3Client([
'region' => 'us-east-1',
'http_handler' => function (RequestInterface $request) {
return Promise\Create::promiseFor(new Response(
200,
['expires' => '1989-08-05']
));
},
]);
$result = $client->headObject(['Bucket' => 'foo', 'Key' => 'bar']);
$this->assertInstanceOf(DateTimeResult::class, $result['Expires']);
$this->assertEquals('1989-08-05', $result['ExpiresString']);
}

public function testEmitsWarningWhenExpiresUnparseable()
{
$this->expectWarning();
$this->expectWarningMessage(
"Failed to parse the `expires` header as a timestamp due to "
. " an invalid timestamp format.\nPlease refer to `ExpiresString` "
. "for the unparsed string format of this header.\n"
);

$client = new S3Client([
'region' => 'us-east-1',
'http_handler' => function (RequestInterface $request) {
return Promise\Create::promiseFor(new Response(
200,
['expires' => 'this-is-not-a-timestamp']
));
},
]);

$client->headObject(['Bucket' => 'foo', 'Key' => 'bar']);
}

public function testExpiresRemainsTimestamp() {
//S3 will be changing `Expires` type from `timestamp` to `string`
// soon. This test ensures backward compatibility
$apiProvider = static function () {
return [
'metadata' => [
'signatureVersion' => 'v4',
'protocol' => 'rest-xml'
],
'shapes' => [
'Expires' => [
'type' => 'string'
],
],
];
};

$s3Client = new S3Client([
'region' => 'us-west-2',
'api_provider' => $apiProvider
]);

$api = $s3Client->getApi();
$expiresType = $api->getDefinition()['shapes']['Expires']['type'];
$this->assertEquals('timestamp', $expiresType);
}

public function testBucketNotModifiedWithLegacyEndpointProvider()
{
$client = new S3Client([
'region' => 'us-west-2',
'endpoint_provider' => PartitionEndpointProvider::defaultProvider()
]);

$operations = $client->getApi()->getDefinition()['operations'];
$this->assertEquals('/{Bucket}', $operations['ListObjects']['http']['requestUri']);
$this->assertEquals(
'/{Bucket}?versions',
$operations['ListObjectVersions']['http']['requestUri']
);
}

public function builtinRegionProvider()
{
return [
Expand Down

0 comments on commit 971e6f3

Please sign in to comment.