diff --git a/src/S3/Parser/GetBucketLocationResultMutator.php b/src/S3/Parser/GetBucketLocationResultMutator.php new file mode 100644 index 0000000000..84de4d0761 --- /dev/null +++ b/src/S3/Parser/GetBucketLocationResultMutator.php @@ -0,0 +1,26 @@ +getName() !== 'GetBucketLocation') { + return $result; + } + + $location = 'us-east-1'; + if (preg_match('/>(.+?)<\/LocationConstraint>/', $response->getBody(), $matches)) { + $location = $matches[1] === 'EU' ? 'eu-west-1' : $matches[1]; + } + $result['LocationConstraint'] = $location; + + return $result; + } +} diff --git a/src/S3/Parser/S3Parser.php b/src/S3/Parser/S3Parser.php new file mode 100644 index 0000000000..a256361eb9 --- /dev/null +++ b/src/S3/Parser/S3Parser.php @@ -0,0 +1,228 @@ +protocolParser = $protocolParser; + $this->errorParser = $errorParser; + $this->exceptionClass = $exceptionClass; + $this->s3ResultMutators = []; + } + + /** + * Parses a S3 response. + * + * @param CommandInterface $command The command that originated the request. + * @param ResponseInterface $response The response gotten from the service. + * + * @return ResultInterface|null + */ + public function __invoke(CommandInterface $command, ResponseInterface $response):? ResultInterface + { + // Check first if the response is an error + $this->parse200Error($command, $response); + + try { + $parseFn = $this->protocolParser; + $result = $parseFn($command, $response); + } catch (ParserException $e) { + throw new $this->exceptionClass( + "Error parsing response for {$command->getName()}:" + . " AWS parsing error: {$e->getMessage()}", + $command, + ['connection_error' => true, 'exception' => $e], + $e + ); + } + + return $this->executeS3ResultMutators($result, $command, $response); + } + + /** + * Tries to parse a 200 response as an error from S3. + * If the parsed result contains a code and message then that means an error + * was found, and hence an exception is thrown with that error. + * + * @param CommandInterface $command + * @param ResponseInterface $response + * @return void + */ + private function parse200Error(CommandInterface $command, ResponseInterface $response) + { + // This error parsing should be just for 200 error responses and operations where its output shape + // does not have a streaming member. + if (200 !== $response->getStatusCode() || $this->hasStreamingTrait($command->getName())) { + return; + } + + // To guarantee we try the error parsing just for an Error xml response. + if (!$this->isFirstRootElementError($response->getBody())) { + return; + } + + try { + $errorParserFn = $this->errorParser; + $parsedError = $errorParserFn($response, $command); + } catch (ParserException $e) { + $parsedError = [ + 'code' => 'ConnectionError', + 'message' => "An error connecting to the service occurred" + . " while performing the " . $command->getName() + . " operation." + ]; + } + + if (isset($parsedError['code']) && isset($parsedError['message'])) { + throw new $this->exceptionClass( + $parsedError['message'], + $command, + [ + 'connection_error' => true, + 'code' => $parsedError['code'], + 'message' => $parsedError['message'] + ] + ); + } + } + + /** + * Checks if a specific operation has a streaming trait. + * + * @param $commandName + * @return bool + */ + private function hasStreamingTrait($commandName): bool + { + $operation = $this->api->getOperation($commandName); + $output = $operation->getOutput(); + foreach ($output->getMembers() as $_ => $memberProps) { + if (!empty($memberProps['eventstream'])) { + return true; + } + } + + return false; + } + + private function isFirstRootElementError(StreamInterface $responseBody): bool + { + $pattern = '/<\?xml version="1\.0" encoding="UTF-8"\?>\s*/'; + + return preg_match($pattern, $responseBody); + } + + /** + * Execute mutator implementations over a result. + * Mutators are logics that modifies a result. + * + * @param ResultInterface $result + * @param CommandInterface $command + * @param ResponseInterface $response + * @return ResultInterface + */ + private function executeS3ResultMutators( + ResultInterface $result, + CommandInterface $command, + ResponseInterface $response + ): ResultInterface + { + foreach ($this->s3ResultMutators as $mutator) { + $result = $mutator($result, $command, $response); + } + + return $result; + } + + /** + * Adds a mutator into the list of mutators. + * + * @param $mutatorName + * @param S3ResultMutator $s3ResultMutator + * @return void + */ + public function addS3ResultMutator($mutatorName, S3ResultMutator $s3ResultMutator) + { + if (isset($this->s3ResultMutators[$mutatorName])) { + trigger_error("The S3 Result Mutator $mutatorName already exists!", E_USER_WARNING); + + return; + } + + $this->s3ResultMutators[$mutatorName] = $s3ResultMutator; + } + + /** + * Removes a mutator from the mutator list. + * + * @param $mutatorName + * @return void + */ + public function removeS3ResultMutator($mutatorName) + { + if (!isset($this->s3ResultMutators[$mutatorName])) { + trigger_error("The S3 Result Mutator $mutatorName does not exist!", E_USER_WARNING); + + return; + } + + unset($this->s3ResultMutators[$mutatorName]); + } + + /** + * Returns the list of result mutators available. + * + * @return array + */ + public function getS3ResultMutators(): array + { + return $this->s3ResultMutators; + } + + public function parseMemberFromStream(StreamInterface $stream, StructureShape $member, $response) + { + return $this->protocolParser->parseMemberFromStream($stream, $member, $response); + } +} diff --git a/src/S3/Parser/S3ResultMutator.php b/src/S3/Parser/S3ResultMutator.php new file mode 100644 index 0000000000..bf9e01d612 --- /dev/null +++ b/src/S3/Parser/S3ResultMutator.php @@ -0,0 +1,16 @@ +api = $api; + } + + public function __invoke( + ResultInterface $result, + CommandInterface $command = null, + ResponseInterface $response = null + ): ResultInterface + { + //Skip this middleware if the operation doesn't have an httpChecksum + $operation = $this->api->getOperation($command->getName()); + if (empty($operation['httpChecksum'])) { + return $result; + } + + $checksumInfo = $operation['httpChecksum']; + //Skip this middleware if the operation doesn't send back a checksum, or the user doesn't opt in + $checksumModeEnabledMember = $checksumInfo['requestValidationModeMember'] ?? ""; + $checksumModeEnabled = $command[$checksumModeEnabledMember] ?? ""; + $responseAlgorithms = $checksumInfo['responseAlgorithms'] ?? []; + if (empty($responseAlgorithms) || strtolower($checksumModeEnabled) !== "enabled") { + return $result; + } + + if (extension_loaded('awscrt')) { + $checksumPriority = ['CRC32C', 'CRC32', 'SHA1', 'SHA256']; + } else { + $checksumPriority = ['CRC32', 'SHA1', 'SHA256']; + } + + $checksumsToCheck = array_intersect($responseAlgorithms, $checksumPriority); + $checksumValidationInfo = $this->validateChecksum($checksumsToCheck, $response); + if ($checksumValidationInfo['status'] == "SUCCEEDED") { + $result['ChecksumValidated'] = $checksumValidationInfo['checksum']; + } elseif ($checksumValidationInfo['status'] == "FAILED") { + //Ignore failed validations on GetObject if it's a multipart get which returned a full multipart object + if ($command->getName() === "GetObject" + && !empty($checksumValidationInfo['checksumHeaderValue']) + ) { + $headerValue = $checksumValidationInfo['checksumHeaderValue']; + $lastDashPos = strrpos($headerValue, '-'); + $endOfChecksum = substr($headerValue, $lastDashPos + 1); + if (is_numeric($endOfChecksum) + && intval($endOfChecksum) > 1 + && intval($endOfChecksum) < 10000) { + return $result; + } + } + + throw new S3Exception( + "Calculated response checksum did not match the expected value", + $command + ); + } + + return $result; + } + + /** + * @param $checksumPriority + * @param ResponseInterface $response + */ + public function validateChecksum($checksumPriority, ResponseInterface $response): array + { + $checksumToValidate = $this->chooseChecksumHeaderToValidate( + $checksumPriority, + $response + ); + $validationStatus = "SKIPPED"; + $checksumHeaderValue = null; + if (!empty($checksumToValidate)) { + $checksumHeaderValue = $response->getHeader( + 'x-amz-checksum-' . $checksumToValidate + ); + if (isset($checksumHeaderValue)) { + $checksumHeaderValue = $checksumHeaderValue[0]; + $calculatedChecksumValue = $this->getEncodedValue( + $checksumToValidate, + $response->getBody() + ); + $validationStatus = $checksumHeaderValue == $calculatedChecksumValue + ? "SUCCEEDED" + : "FAILED"; + } + } + return [ + "status" => $validationStatus, + "checksum" => $checksumToValidate, + "checksumHeaderValue" => $checksumHeaderValue, + ]; + } + + /** + * @param $checksumPriority + * @param ResponseInterface $response + */ + public function chooseChecksumHeaderToValidate( + $checksumPriority, + ResponseInterface $response + ) { + foreach ($checksumPriority as $checksum) { + $checksumHeader = 'x-amz-checksum-' . $checksum; + if ($response->hasHeader($checksumHeader)) { + return $checksum; + } + } + + return null; + } +} diff --git a/src/S3/S3Client.php b/src/S3/S3Client.php index 235bac3b4c..14c298d758 100644 --- a/src/S3/S3Client.php +++ b/src/S3/S3Client.php @@ -19,6 +19,9 @@ use Aws\Retry\QuotaManager; use Aws\RetryMiddleware; use Aws\RetryMiddlewareV2; +use Aws\S3\Parser\GetBucketLocationResultMutator; +use Aws\S3\Parser\S3Parser; +use Aws\S3\Parser\ValidateResponseChecksumResultMutator; use Aws\S3\RegionalEndpoint\ConfigurationProvider; use Aws\S3\UseArnRegion\Configuration; use Aws\S3\UseArnRegion\ConfigurationInterface; @@ -923,19 +926,15 @@ public static function _applyRetryConfig($value, $args, HandlerList $list) public static function _applyApiProvider($value, array &$args, HandlerList $list) { ClientResolver::_apply_api_provider($value, $args); - $args['parser'] = new GetBucketLocationParser( - new ValidateResponseChecksumParser( - new AmbiguousSuccessParser( - new RetryableMalformedResponseParser( - $args['parser'], - $args['exception_class'] - ), - $args['error_parser'], - $args['exception_class'] - ), - $args['api'] - ) + $s3Parser = new S3Parser( + $args['parser'], + $args['error_parser'], + $args['api'], + $args['exception_class'] ); + $s3Parser->addS3ResultMutator('s3.get-bucket-location', new GetBucketLocationResultMutator()); + $s3Parser->addS3ResultMutator('s3.validate-response-checksum', new ValidateResponseChecksumResultMutator($args['api'])); + $args['parser'] = $s3Parser; } /** diff --git a/tests/S3/Parser/GetBucketLocationResultMutatorTest.php b/tests/S3/Parser/GetBucketLocationResultMutatorTest.php new file mode 100644 index 0000000000..bba530d651 --- /dev/null +++ b/tests/S3/Parser/GetBucketLocationResultMutatorTest.php @@ -0,0 +1,22 @@ +$bucketTestLocation"); + $mutator = new GetBucketLocationResultMutator(); + $result = $mutator(new Result(), new Command('GetBucketLocation'), $response); + + $this->assertEquals($bucketTestLocation, $result['LocationConstraint']); + } +} diff --git a/tests/S3/Parser/S3ParserTest.php b/tests/S3/Parser/S3ParserTest.php new file mode 100644 index 0000000000..babc3abd1a --- /dev/null +++ b/tests/S3/Parser/S3ParserTest.php @@ -0,0 +1,238 @@ + + + + InternalError + We encountered an internal error. Please try again. + 656c76696e6727732072657175657374 + Uuag1LuByRx9e6j5Onimru9pO4ZVKnJ2Qz7/C1NPcfTWAtRPfTaOFg== + +EOXML; + + /** + * @dataProvider s3200ErrorHandlingCasesProvider + * @param string $operation The operation to test. + * + * @return void + */ + public function testHandle200Errors($operation) + { + $this->expectException(AwsException::class); + $this->expectExceptionMessage('We encountered an internal error. Please try again.'); + $s3Parser = $this->getS3Parser(); + $command = new Command($operation, [], new HandlerList()); + $response = new Response( + 200, + [], + self::INTERNAL_S3200_ERROR + ); + $s3Parser($command, $response); + } + + /** + * Returns a set of s3 operations. + * + * @return \Generator + */ + public function s3200ErrorHandlingCasesProvider(): \Generator + { + $operations = [ + 'AbortMultipartUpload', + 'CompleteMultipartUpload', + 'CopyObject', + 'CreateBucket', + 'CreateMultipartUpload', + 'CreateSession', + 'DeleteBucket', + 'DeleteBucketAnalyticsConfiguration', + 'DeleteBucketCors', + 'DeleteBucketEncryption', + 'DeleteBucketIntelligentTieringConfiguration', + 'DeleteBucketInventoryConfiguration', + 'DeleteBucketLifecycle', + 'DeleteBucketMetricsConfiguration', + 'DeleteBucketOwnershipControls', + 'DeleteBucketPolicy', + 'DeleteBucketReplication', + 'DeleteBucketTagging', + 'DeleteBucketWebsite', + 'DeleteObject', + 'DeleteObjectTagging', + 'DeleteObjects', + 'DeletePublicAccessBlock', + 'GetBucketAccelerateConfiguration', + 'GetBucketAcl', + 'GetBucketAnalyticsConfiguration', + 'GetBucketCors', + 'GetBucketEncryption', + 'GetBucketIntelligentTieringConfiguration', + 'GetBucketInventoryConfiguration', + 'GetBucketLifecycleConfiguration', + 'GetBucketLocation', + 'GetBucketLogging', + 'GetBucketMetricsConfiguration', + 'GetBucketNotificationConfiguration', + 'GetBucketOwnershipControls', + 'GetBucketPolicy', + 'GetBucketPolicyStatus', + 'GetBucketReplication', + 'GetBucketRequestPayment', + 'GetBucketTagging', + 'GetBucketVersioning', + 'GetBucketWebsite', + 'GetObjectAcl', + 'GetObjectAttributes', + 'GetObjectLegalHold', + 'GetObjectLockConfiguration', + 'GetObjectRetention', + 'GetObjectTagging', + 'GetPublicAccessBlock', + 'HeadBucket', + 'HeadObject', + 'ListBucketAnalyticsConfigurations', + 'ListBucketIntelligentTieringConfigurations', + 'ListBucketInventoryConfigurations', + 'ListBucketMetricsConfigurations', + 'ListBuckets', + 'ListDirectoryBuckets', + 'ListMultipartUploads', + 'ListObjectVersions', + 'ListObjects', + 'ListObjectsV2', + 'ListParts', + 'PutBucketAccelerateConfiguration', + 'PutBucketAcl', + 'PutBucketAnalyticsConfiguration', + 'PutBucketCors', + 'PutBucketEncryption', + 'PutBucketIntelligentTieringConfiguration', + 'PutBucketInventoryConfiguration', + 'PutBucketLifecycleConfiguration', + 'PutBucketLogging', + 'PutBucketMetricsConfiguration', + 'PutBucketNotificationConfiguration', + 'PutBucketOwnershipControls', + 'PutBucketPolicy', + 'PutBucketReplication', + 'PutBucketRequestPayment', + 'PutBucketTagging', + 'PutBucketVersioning', + 'PutBucketWebsite', + 'PutObject', + 'PutObjectAcl', + 'PutObjectLegalHold', + 'PutObjectLockConfiguration', + 'PutObjectRetention', + 'PutObjectTagging', + 'PutPublicAccessBlock', + 'RestoreObject', + 'UploadPart', + 'UploadPartCopy', + 'WriteGetObjectResponse' + ]; + + foreach ($operations as $operation) { + yield $operation => [ + $operation + ]; + } + } + + public function testAddsS3ResultMutator() + { + $testField = 'TestField'; + $testValue = 'TestValue'; + $s3MutatorName = 's3.test-mutator'; + $s3Parser = $this->getS3Parser(); + $s3Parser->addS3ResultMutator($s3MutatorName, new class($testField, $testValue) implements S3ResultMutator + { + /** + * @var string $testField + */ + private $testField; + /** + * @var string $testValue + */ + private $testValue; + public function __construct($testField, $testValue) + { + $this->testField = $testField; + $this->testValue = $testValue; + } + + public function __invoke(ResultInterface $result, CommandInterface $command, ResponseInterface $response): ResultInterface + { + $result[$this->testField] = $this->testValue; + + return $result; + } + }); + $mutators = $s3Parser->getS3ResultMutators(); + $command = new Command('ListBuckets', [], new HandlerList()); + $response = new Response(); + $result = $s3Parser($command, $response); + + $this->assertTrue(isset($mutators[$s3MutatorName])); + $this->assertEquals($testValue, $result[$testField]); + } + + public function testRemovesS3ResultMutator() + { + $s3Parser = $this->getS3Parser(); + $s3MutatorName = 's3.test-mutator'; + $s3Parser->addS3ResultMutator($s3MutatorName, new class implements S3ResultMutator + { + + public function __invoke(ResultInterface $result, CommandInterface $command, ResponseInterface $response): ResultInterface + { + return $result; + } + }); + $mutators = $s3Parser->getS3ResultMutators(); + $this->assertTrue(isset($mutators[$s3MutatorName])); + $s3Parser->removeS3ResultMutator($s3MutatorName); + $mutators = $s3Parser->getS3ResultMutators(); + $this->assertFalse(isset($mutators[$s3MutatorName])); + } + + private function getS3Parser(): S3Parser + { + $apiProvider = ApiProvider::defaultProvider(); + $api = new Service( + ApiProvider::resolve( + $apiProvider, + 'api', + 's3', + 'latest' + ), + $apiProvider + ); + $protocolParser = Service::createParser($api); + $errorParser = Service::createErrorParser($api->getProtocol() ,$api); + + return new S3Parser( + $protocolParser, + $errorParser, + $api + ); + } +} diff --git a/tests/S3/S3ClientTest.php b/tests/S3/S3ClientTest.php index 7eddeebfc6..aed5720f5d 100644 --- a/tests/S3/S3ClientTest.php +++ b/tests/S3/S3ClientTest.php @@ -1,6 +1,9 @@ 'latest', 'region' => 'us-west-2', 'retries' => $retryOptions, - 'http_handler' => function () use (&$retries, $failingSuccess) { + 'http_handler' => function () use (&$retries) { if (0 === --$retries) { return new FulfilledPromise(new Response( 200, @@ -661,7 +663,7 @@ public function testRetries200Errors( )); } - return new FulfilledPromise($failingSuccess); + return new FulfilledPromise(new Response(200, [], $this->getErrorXml())); }, ]); @@ -670,166 +672,45 @@ public function testRetries200Errors( $this->assertSame(0, $retries); } - public function successErrorResponseProvider() + /** + * This provider returns a set of s3 operations along with + * the required params, and a retry configuration. + * + * @return \Generator + */ + public function s3OperationsProvider(): \Generator { - return [ - [ - new Response(200, [], $this->getErrorXml()), - 'copyObject', - [ - 'Bucket' => 'foo', - 'Key' => 'bar', - 'CopySource' => 'baz', - ], - [ - 'mode' => 'legacy', - 'max_attempts' => 11 - ], - ], - [ - new Response(200, [], $this->getErrorXml()), - 'copyObject', - [ - 'Bucket' => 'foo', - 'Key' => 'bar', - 'CopySource' => 'baz', - ], - [ - 'mode' => 'standard', - 'max_attempts' => 11 - ], - ], - [ - new Response(200, [], $this->getErrorXml()), - 'copyObject', - [ - 'Bucket' => 'foo', - 'Key' => 'bar', - 'CopySource' => 'baz', - ], - [ - 'mode' => 'adaptive', - 'max_attempts' => 11 - ], - ], - [ - new Response(200, [], $this->getErrorXml()), - 'uploadPartCopy', - [ - 'PartNumber' => 1, - 'UploadId' => PHP_INT_SIZE, - 'Bucket' => 'foo', - 'Key' => 'bar', - 'CopySource' => 'baz', - ], - [ - 'mode' => 'legacy', - 'max_attempts' => 11 - ], - ], - [ - new Response(200, [], $this->getErrorXml()), - 'uploadPartCopy', - [ - 'PartNumber' => 1, - 'UploadId' => PHP_INT_SIZE, - 'Bucket' => 'foo', - 'Key' => 'bar', - 'CopySource' => 'baz', - ], - [ - 'mode' => 'standard', - 'max_attempts' => 11 - ], - ], - [ - new Response(200, [], $this->getErrorXml()), - 'uploadPartCopy', - [ - 'PartNumber' => 1, - 'UploadId' => PHP_INT_SIZE, - 'Bucket' => 'foo', - 'Key' => 'bar', - 'CopySource' => 'baz', - ], - [ - 'mode' => 'adaptive', - 'max_attempts' => 11 - ], - ], - [ - new Response(200, [], $this->getErrorXml()), - 'completeMultipartUpload', - [ - 'UploadId' => PHP_INT_SIZE, - 'Bucket' => 'foo', - 'Key' => 'bar', - ], - [ - 'mode' => 'legacy', - 'max_attempts' => 11 - ], - ], - [ - new Response(200, [], $this->getErrorXml()), - 'completeMultipartUpload', - [ - 'UploadId' => PHP_INT_SIZE, - 'Bucket' => 'foo', - 'Key' => 'bar', - ], - [ - 'mode' => 'standard', - 'max_attempts' => 11 - ], - ], - [ - new Response(200, [], $this->getErrorXml()), - 'completeMultipartUpload', - [ - 'UploadId' => PHP_INT_SIZE, - 'Bucket' => 'foo', - 'Key' => 'bar', - ], - [ - 'mode' => 'adaptive', - 'max_attempts' => 11 - ], - ], - [ - new Response(200, [], $this->getMalformedXml()), - 'listObjects', - [ - 'Bucket' => 'foo', - ], - [ - 'mode' => 'legacy', - 'max_attempts' => 11 - ], - ], - [ - new Response(200, [], $this->getMalformedXml()), - 'listObjects', - [ - 'Bucket' => 'foo', - ], - [ - 'mode' => 'standard', - 'max_attempts' => 11 - ], - ], - [ - new Response(200, [], $this->getMalformedXml()), - 'listObjects', - [ - 'Bucket' => 'foo', - ], - [ - 'mode' => 'adaptive', - 'max_attempts' => 11 - ], - ], + $operations = $this->loadOperations(); + $retryModes = [ + 'legacy', + 'standard', + 'adaptive' ]; + + foreach ($operations as $operation) { + foreach ($retryModes as $retryMode) { + yield ($operation['operation'] . '/' . $retryMode) => [ + $operation['operation'], + $operation['params'], + [ + 'mode' => $retryMode, + 'max_attempts' => 5 + ], + ]; + } + } + } + + /** + * Load a list of s3 operations along with the required params populated. + * + * @return array + */ + private function loadOperations(): array + { + $jsonContent = file_get_contents(self::OPERATIONS_WITH_PARAMS_LOCATION); + + return json_decode($jsonContent, true); } private function getErrorXml() @@ -1715,7 +1596,7 @@ public function optionsToEndpointsCases() public function testAppliesAmbiguousSuccessParsing() { - $this->expectExceptionMessage("An error connecting to the service occurred while performing the CopyObject operation"); + $this->expectExceptionMessage("Error parsing response for CopyObject: AWS parsing error: Error parsing XML: String could not be parsed as XML"); $this->expectException(\Aws\S3\Exception\S3Exception::class); $httpHandler = function ($request, array $options) { return Promise\Create::promiseFor( diff --git a/tests/S3/fixtures/operations-with-params.json b/tests/S3/fixtures/operations-with-params.json new file mode 100644 index 0000000000..bb99b9ea4c --- /dev/null +++ b/tests/S3/fixtures/operations-with-params.json @@ -0,0 +1,66 @@ +[ + { + "operation": "listBuckets", + "params": [] + }, + { + "operation": "listObjects", + "params": { + "Bucket": "foo" + } + }, + { + "operation": "getObject", + "params": { + "Bucket": "foo", + "Key": "foo" + } + }, + { + "operation": "putObject", + "params": { + "Bucket": "foo", + "Key": "foo", + "Body": "foo" + } + }, + { + "operation": "headBucket", + "params": { + "Bucket": "foo" + } + }, + { + "operation": "headObject", + "params": { + "Bucket": "foo", + "Key": "foo" + } + }, + { + "operation": "copyObject", + "params": { + "Bucket": "foo", + "Key": "foo", + "CopySource": "bazz" + } + }, + { + "operation": "uploadPartCopy", + "params": { + "PartNumber": 1, + "UploadId": "foo", + "Bucket": "foo", + "Key": "bazz", + "CopySource": "bar" + } + }, + { + "operation": "completeMultipartUpload", + "params": { + "Bucket": "foo", + "Key": "bar", + "UploadId": "foo" + } + } +]