diff --git a/manifest.json b/manifest.json index cd78673d8..b51dbdb26 100644 --- a/manifest.json +++ b/manifest.json @@ -577,7 +577,8 @@ "PutObject", "PutObjectAcl", "PutObjectTagging", - "UploadPart" + "UploadPart", + "UploadPartCopy" ] }, "Scheduler": { diff --git a/src/Service/S3/CHANGELOG.md b/src/Service/S3/CHANGELOG.md index e458b89db..aed00da78 100644 --- a/src/Service/S3/CHANGELOG.md +++ b/src/Service/S3/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - AWS api-change: This release adds a new field COMPLETED to the ReplicationStatus Enum. You can now use this field to validate the replication status of S3 objects using the AWS SDK. +- Added `S3Client::uploadPartCopy()` method ### Changed diff --git a/src/Service/S3/src/Input/UploadPartCopyRequest.php b/src/Service/S3/src/Input/UploadPartCopyRequest.php new file mode 100644 index 000000000..f84b97a09 --- /dev/null +++ b/src/Service/S3/src/Input/UploadPartCopyRequest.php @@ -0,0 +1,603 @@ +::accesspoint//object/`. For + * example, to copy the object `reports/january.pdf` through access point `my-access-point` owned by account + * `123456789012` in Region `us-west-2`, use the URL encoding of + * `arn:aws:s3:us-west-2:123456789012:accesspoint/my-access-point/object/reports/january.pdf`. The value must be URL + * encoded. + * + * > Amazon S3 supports copy operations using access points only when the source and destination buckets are in the + * > same Amazon Web Services Region. + * + * Alternatively, for objects accessed through Amazon S3 on Outposts, specify the ARN of the object as accessed in the + * format `arn:aws:s3-outposts:::outpost//object/`. For + * example, to copy the object `reports/january.pdf` through outpost `my-outpost` owned by account `123456789012` in + * Region `us-west-2`, use the URL encoding of + * `arn:aws:s3-outposts:us-west-2:123456789012:outpost/my-outpost/object/reports/january.pdf`. The value must be + * URL-encoded. + * + * To copy a specific version of an object, append `?versionId=` to the value (for example, + * `awsexamplebucket/reports/january.pdf?versionId=QUpfdndhfd8438MNFDN93jdnJFkdmqnh893`). If you don't specify a version + * ID, Amazon S3 copies the latest version of the source object. + * + * [^1]: https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-points.html + * + * @required + * + * @var string|null + */ + private $copySource; + + /** + * Copies the object if its entity tag (ETag) matches the specified tag. + * + * @var string|null + */ + private $copySourceIfMatch; + + /** + * Copies the object if it has been modified since the specified time. + * + * @var \DateTimeImmutable|null + */ + private $copySourceIfModifiedSince; + + /** + * Copies the object if its entity tag (ETag) is different than the specified ETag. + * + * @var string|null + */ + private $copySourceIfNoneMatch; + + /** + * Copies the object if it hasn't been modified since the specified time. + * + * @var \DateTimeImmutable|null + */ + private $copySourceIfUnmodifiedSince; + + /** + * The range of bytes to copy from the source object. The range value must use the form bytes=first-last, where the + * first and last are the zero-based byte offsets to copy. For example, bytes=0-9 indicates that you want to copy the + * first 10 bytes of the source. You can copy a range only if the source object is greater than 5 MB. + * + * @var string|null + */ + private $copySourceRange; + + /** + * Object key for which the multipart upload was initiated. + * + * @required + * + * @var string|null + */ + private $key; + + /** + * Part number of part being copied. This is a positive integer between 1 and 10,000. + * + * @required + * + * @var int|null + */ + private $partNumber; + + /** + * Upload ID identifying the multipart upload whose part is being copied. + * + * @required + * + * @var string|null + */ + private $uploadId; + + /** + * Specifies the algorithm to use to when encrypting the object (for example, AES256). + * + * @var string|null + */ + private $sseCustomerAlgorithm; + + /** + * Specifies the customer-provided encryption key for Amazon S3 to use in encrypting data. This value is used to store + * the object and then it is discarded; Amazon S3 does not store the encryption key. The key must be appropriate for use + * with the algorithm specified in the `x-amz-server-side-encryption-customer-algorithm` header. This must be the same + * encryption key specified in the initiate multipart upload request. + * + * @var string|null + */ + private $sseCustomerKey; + + /** + * Specifies the 128-bit MD5 digest of the encryption key according to RFC 1321. Amazon S3 uses this header for a + * message integrity check to ensure that the encryption key was transmitted without error. + * + * @var string|null + */ + private $sseCustomerKeyMd5; + + /** + * Specifies the algorithm to use when decrypting the source object (for example, AES256). + * + * @var string|null + */ + private $copySourceSseCustomerAlgorithm; + + /** + * Specifies the customer-provided encryption key for Amazon S3 to use to decrypt the source object. The encryption key + * provided in this header must be one that was used when the source object was created. + * + * @var string|null + */ + private $copySourceSseCustomerKey; + + /** + * Specifies the 128-bit MD5 digest of the encryption key according to RFC 1321. Amazon S3 uses this header for a + * message integrity check to ensure that the encryption key was transmitted without error. + * + * @var string|null + */ + private $copySourceSseCustomerKeyMd5; + + /** + * @var RequestPayer::*|null + */ + private $requestPayer; + + /** + * The account ID of the expected destination bucket owner. If the destination bucket is owned by a different account, + * the request fails with the HTTP status code `403 Forbidden` (access denied). + * + * @var string|null + */ + private $expectedBucketOwner; + + /** + * The account ID of the expected source bucket owner. If the source bucket is owned by a different account, the request + * fails with the HTTP status code `403 Forbidden` (access denied). + * + * @var string|null + */ + private $expectedSourceBucketOwner; + + /** + * @param array{ + * Bucket?: string, + * CopySource?: string, + * CopySourceIfMatch?: null|string, + * CopySourceIfModifiedSince?: null|\DateTimeImmutable|string, + * CopySourceIfNoneMatch?: null|string, + * CopySourceIfUnmodifiedSince?: null|\DateTimeImmutable|string, + * CopySourceRange?: null|string, + * Key?: string, + * PartNumber?: int, + * UploadId?: string, + * SSECustomerAlgorithm?: null|string, + * SSECustomerKey?: null|string, + * SSECustomerKeyMD5?: null|string, + * CopySourceSSECustomerAlgorithm?: null|string, + * CopySourceSSECustomerKey?: null|string, + * CopySourceSSECustomerKeyMD5?: null|string, + * RequestPayer?: null|RequestPayer::*, + * ExpectedBucketOwner?: null|string, + * ExpectedSourceBucketOwner?: null|string, + * '@region'?: string|null, + * } $input + */ + public function __construct(array $input = []) + { + $this->bucket = $input['Bucket'] ?? null; + $this->copySource = $input['CopySource'] ?? null; + $this->copySourceIfMatch = $input['CopySourceIfMatch'] ?? null; + $this->copySourceIfModifiedSince = !isset($input['CopySourceIfModifiedSince']) ? null : ($input['CopySourceIfModifiedSince'] instanceof \DateTimeImmutable ? $input['CopySourceIfModifiedSince'] : new \DateTimeImmutable($input['CopySourceIfModifiedSince'])); + $this->copySourceIfNoneMatch = $input['CopySourceIfNoneMatch'] ?? null; + $this->copySourceIfUnmodifiedSince = !isset($input['CopySourceIfUnmodifiedSince']) ? null : ($input['CopySourceIfUnmodifiedSince'] instanceof \DateTimeImmutable ? $input['CopySourceIfUnmodifiedSince'] : new \DateTimeImmutable($input['CopySourceIfUnmodifiedSince'])); + $this->copySourceRange = $input['CopySourceRange'] ?? null; + $this->key = $input['Key'] ?? null; + $this->partNumber = $input['PartNumber'] ?? null; + $this->uploadId = $input['UploadId'] ?? null; + $this->sseCustomerAlgorithm = $input['SSECustomerAlgorithm'] ?? null; + $this->sseCustomerKey = $input['SSECustomerKey'] ?? null; + $this->sseCustomerKeyMd5 = $input['SSECustomerKeyMD5'] ?? null; + $this->copySourceSseCustomerAlgorithm = $input['CopySourceSSECustomerAlgorithm'] ?? null; + $this->copySourceSseCustomerKey = $input['CopySourceSSECustomerKey'] ?? null; + $this->copySourceSseCustomerKeyMd5 = $input['CopySourceSSECustomerKeyMD5'] ?? null; + $this->requestPayer = $input['RequestPayer'] ?? null; + $this->expectedBucketOwner = $input['ExpectedBucketOwner'] ?? null; + $this->expectedSourceBucketOwner = $input['ExpectedSourceBucketOwner'] ?? null; + parent::__construct($input); + } + + /** + * @param array{ + * Bucket?: string, + * CopySource?: string, + * CopySourceIfMatch?: null|string, + * CopySourceIfModifiedSince?: null|\DateTimeImmutable|string, + * CopySourceIfNoneMatch?: null|string, + * CopySourceIfUnmodifiedSince?: null|\DateTimeImmutable|string, + * CopySourceRange?: null|string, + * Key?: string, + * PartNumber?: int, + * UploadId?: string, + * SSECustomerAlgorithm?: null|string, + * SSECustomerKey?: null|string, + * SSECustomerKeyMD5?: null|string, + * CopySourceSSECustomerAlgorithm?: null|string, + * CopySourceSSECustomerKey?: null|string, + * CopySourceSSECustomerKeyMD5?: null|string, + * RequestPayer?: null|RequestPayer::*, + * ExpectedBucketOwner?: null|string, + * ExpectedSourceBucketOwner?: null|string, + * '@region'?: string|null, + * }|UploadPartCopyRequest $input + */ + public static function create($input): self + { + return $input instanceof self ? $input : new self($input); + } + + public function getBucket(): ?string + { + return $this->bucket; + } + + public function getCopySource(): ?string + { + return $this->copySource; + } + + public function getCopySourceIfMatch(): ?string + { + return $this->copySourceIfMatch; + } + + public function getCopySourceIfModifiedSince(): ?\DateTimeImmutable + { + return $this->copySourceIfModifiedSince; + } + + public function getCopySourceIfNoneMatch(): ?string + { + return $this->copySourceIfNoneMatch; + } + + public function getCopySourceIfUnmodifiedSince(): ?\DateTimeImmutable + { + return $this->copySourceIfUnmodifiedSince; + } + + public function getCopySourceRange(): ?string + { + return $this->copySourceRange; + } + + public function getCopySourceSseCustomerAlgorithm(): ?string + { + return $this->copySourceSseCustomerAlgorithm; + } + + public function getCopySourceSseCustomerKey(): ?string + { + return $this->copySourceSseCustomerKey; + } + + public function getCopySourceSseCustomerKeyMd5(): ?string + { + return $this->copySourceSseCustomerKeyMd5; + } + + public function getExpectedBucketOwner(): ?string + { + return $this->expectedBucketOwner; + } + + public function getExpectedSourceBucketOwner(): ?string + { + return $this->expectedSourceBucketOwner; + } + + public function getKey(): ?string + { + return $this->key; + } + + public function getPartNumber(): ?int + { + return $this->partNumber; + } + + /** + * @return RequestPayer::*|null + */ + public function getRequestPayer(): ?string + { + return $this->requestPayer; + } + + public function getSseCustomerAlgorithm(): ?string + { + return $this->sseCustomerAlgorithm; + } + + public function getSseCustomerKey(): ?string + { + return $this->sseCustomerKey; + } + + public function getSseCustomerKeyMd5(): ?string + { + return $this->sseCustomerKeyMd5; + } + + public function getUploadId(): ?string + { + return $this->uploadId; + } + + /** + * @internal + */ + public function request(): Request + { + // Prepare headers + $headers = ['content-type' => 'application/xml']; + if (null === $v = $this->copySource) { + throw new InvalidArgument(sprintf('Missing parameter "CopySource" for "%s". The value cannot be null.', __CLASS__)); + } + $headers['x-amz-copy-source'] = $v; + if (null !== $this->copySourceIfMatch) { + $headers['x-amz-copy-source-if-match'] = $this->copySourceIfMatch; + } + if (null !== $this->copySourceIfModifiedSince) { + $headers['x-amz-copy-source-if-modified-since'] = $this->copySourceIfModifiedSince->setTimezone(new \DateTimeZone('GMT'))->format(\DateTimeInterface::RFC7231); + } + if (null !== $this->copySourceIfNoneMatch) { + $headers['x-amz-copy-source-if-none-match'] = $this->copySourceIfNoneMatch; + } + if (null !== $this->copySourceIfUnmodifiedSince) { + $headers['x-amz-copy-source-if-unmodified-since'] = $this->copySourceIfUnmodifiedSince->setTimezone(new \DateTimeZone('GMT'))->format(\DateTimeInterface::RFC7231); + } + if (null !== $this->copySourceRange) { + $headers['x-amz-copy-source-range'] = $this->copySourceRange; + } + if (null !== $this->sseCustomerAlgorithm) { + $headers['x-amz-server-side-encryption-customer-algorithm'] = $this->sseCustomerAlgorithm; + } + if (null !== $this->sseCustomerKey) { + $headers['x-amz-server-side-encryption-customer-key'] = $this->sseCustomerKey; + } + if (null !== $this->sseCustomerKeyMd5) { + $headers['x-amz-server-side-encryption-customer-key-MD5'] = $this->sseCustomerKeyMd5; + } + if (null !== $this->copySourceSseCustomerAlgorithm) { + $headers['x-amz-copy-source-server-side-encryption-customer-algorithm'] = $this->copySourceSseCustomerAlgorithm; + } + if (null !== $this->copySourceSseCustomerKey) { + $headers['x-amz-copy-source-server-side-encryption-customer-key'] = $this->copySourceSseCustomerKey; + } + if (null !== $this->copySourceSseCustomerKeyMd5) { + $headers['x-amz-copy-source-server-side-encryption-customer-key-MD5'] = $this->copySourceSseCustomerKeyMd5; + } + if (null !== $this->requestPayer) { + if (!RequestPayer::exists($this->requestPayer)) { + throw new InvalidArgument(sprintf('Invalid parameter "RequestPayer" for "%s". The value "%s" is not a valid "RequestPayer".', __CLASS__, $this->requestPayer)); + } + $headers['x-amz-request-payer'] = $this->requestPayer; + } + if (null !== $this->expectedBucketOwner) { + $headers['x-amz-expected-bucket-owner'] = $this->expectedBucketOwner; + } + if (null !== $this->expectedSourceBucketOwner) { + $headers['x-amz-source-expected-bucket-owner'] = $this->expectedSourceBucketOwner; + } + + // Prepare query + $query = []; + if (null === $v = $this->partNumber) { + throw new InvalidArgument(sprintf('Missing parameter "PartNumber" for "%s". The value cannot be null.', __CLASS__)); + } + $query['partNumber'] = (string) $v; + if (null === $v = $this->uploadId) { + throw new InvalidArgument(sprintf('Missing parameter "UploadId" for "%s". The value cannot be null.', __CLASS__)); + } + $query['uploadId'] = $v; + + // Prepare URI + $uri = []; + if (null === $v = $this->bucket) { + throw new InvalidArgument(sprintf('Missing parameter "Bucket" for "%s". The value cannot be null.', __CLASS__)); + } + $uri['Bucket'] = $v; + if (null === $v = $this->key) { + throw new InvalidArgument(sprintf('Missing parameter "Key" for "%s". The value cannot be null.', __CLASS__)); + } + $uri['Key'] = $v; + $uriString = '/' . rawurlencode($uri['Bucket']) . '/' . str_replace('%2F', '/', rawurlencode($uri['Key'])); + + // Prepare Body + $body = ''; + + // Return the Request + return new Request('PUT', $uriString, $query, $headers, StreamFactory::create($body)); + } + + public function setBucket(?string $value): self + { + $this->bucket = $value; + + return $this; + } + + public function setCopySource(?string $value): self + { + $this->copySource = $value; + + return $this; + } + + public function setCopySourceIfMatch(?string $value): self + { + $this->copySourceIfMatch = $value; + + return $this; + } + + public function setCopySourceIfModifiedSince(?\DateTimeImmutable $value): self + { + $this->copySourceIfModifiedSince = $value; + + return $this; + } + + public function setCopySourceIfNoneMatch(?string $value): self + { + $this->copySourceIfNoneMatch = $value; + + return $this; + } + + public function setCopySourceIfUnmodifiedSince(?\DateTimeImmutable $value): self + { + $this->copySourceIfUnmodifiedSince = $value; + + return $this; + } + + public function setCopySourceRange(?string $value): self + { + $this->copySourceRange = $value; + + return $this; + } + + public function setCopySourceSseCustomerAlgorithm(?string $value): self + { + $this->copySourceSseCustomerAlgorithm = $value; + + return $this; + } + + public function setCopySourceSseCustomerKey(?string $value): self + { + $this->copySourceSseCustomerKey = $value; + + return $this; + } + + public function setCopySourceSseCustomerKeyMd5(?string $value): self + { + $this->copySourceSseCustomerKeyMd5 = $value; + + return $this; + } + + public function setExpectedBucketOwner(?string $value): self + { + $this->expectedBucketOwner = $value; + + return $this; + } + + public function setExpectedSourceBucketOwner(?string $value): self + { + $this->expectedSourceBucketOwner = $value; + + return $this; + } + + public function setKey(?string $value): self + { + $this->key = $value; + + return $this; + } + + public function setPartNumber(?int $value): self + { + $this->partNumber = $value; + + return $this; + } + + /** + * @param RequestPayer::*|null $value + */ + public function setRequestPayer(?string $value): self + { + $this->requestPayer = $value; + + return $this; + } + + public function setSseCustomerAlgorithm(?string $value): self + { + $this->sseCustomerAlgorithm = $value; + + return $this; + } + + public function setSseCustomerKey(?string $value): self + { + $this->sseCustomerKey = $value; + + return $this; + } + + public function setSseCustomerKeyMd5(?string $value): self + { + $this->sseCustomerKeyMd5 = $value; + + return $this; + } + + public function setUploadId(?string $value): self + { + $this->uploadId = $value; + + return $this; + } +} diff --git a/src/Service/S3/src/Result/UploadPartCopyOutput.php b/src/Service/S3/src/Result/UploadPartCopyOutput.php new file mode 100644 index 000000000..667996ba8 --- /dev/null +++ b/src/Service/S3/src/Result/UploadPartCopyOutput.php @@ -0,0 +1,155 @@ +initialize(); + + return $this->bucketKeyEnabled; + } + + public function getCopyPartResult(): ?CopyPartResult + { + $this->initialize(); + + return $this->copyPartResult; + } + + public function getCopySourceVersionId(): ?string + { + $this->initialize(); + + return $this->copySourceVersionId; + } + + /** + * @return RequestCharged::*|null + */ + public function getRequestCharged(): ?string + { + $this->initialize(); + + return $this->requestCharged; + } + + /** + * @return ServerSideEncryption::*|null + */ + public function getServerSideEncryption(): ?string + { + $this->initialize(); + + return $this->serverSideEncryption; + } + + public function getSseCustomerAlgorithm(): ?string + { + $this->initialize(); + + return $this->sseCustomerAlgorithm; + } + + public function getSseCustomerKeyMd5(): ?string + { + $this->initialize(); + + return $this->sseCustomerKeyMd5; + } + + public function getSseKmsKeyId(): ?string + { + $this->initialize(); + + return $this->sseKmsKeyId; + } + + protected function populateResult(Response $response): void + { + $headers = $response->getHeaders(); + + $this->copySourceVersionId = $headers['x-amz-copy-source-version-id'][0] ?? null; + $this->serverSideEncryption = $headers['x-amz-server-side-encryption'][0] ?? null; + $this->sseCustomerAlgorithm = $headers['x-amz-server-side-encryption-customer-algorithm'][0] ?? null; + $this->sseCustomerKeyMd5 = $headers['x-amz-server-side-encryption-customer-key-md5'][0] ?? null; + $this->sseKmsKeyId = $headers['x-amz-server-side-encryption-aws-kms-key-id'][0] ?? null; + $this->bucketKeyEnabled = isset($headers['x-amz-server-side-encryption-bucket-key-enabled'][0]) ? filter_var($headers['x-amz-server-side-encryption-bucket-key-enabled'][0], \FILTER_VALIDATE_BOOLEAN) : null; + $this->requestCharged = $headers['x-amz-request-charged'][0] ?? null; + + $data = new \SimpleXMLElement($response->getContent()); + $this->copyPartResult = new CopyPartResult([ + 'ETag' => ($v = $data->ETag) ? (string) $v : null, + 'LastModified' => ($v = $data->LastModified) ? new \DateTimeImmutable((string) $v) : null, + 'ChecksumCRC32' => ($v = $data->ChecksumCRC32) ? (string) $v : null, + 'ChecksumCRC32C' => ($v = $data->ChecksumCRC32C) ? (string) $v : null, + 'ChecksumSHA1' => ($v = $data->ChecksumSHA1) ? (string) $v : null, + 'ChecksumSHA256' => ($v = $data->ChecksumSHA256) ? (string) $v : null, + ]); + } +} diff --git a/src/Service/S3/src/S3Client.php b/src/Service/S3/src/S3Client.php index 0f3465992..0fd24f69f 100644 --- a/src/Service/S3/src/S3Client.php +++ b/src/Service/S3/src/S3Client.php @@ -56,6 +56,7 @@ use AsyncAws\S3\Input\PutObjectAclRequest; use AsyncAws\S3\Input\PutObjectRequest; use AsyncAws\S3\Input\PutObjectTaggingRequest; +use AsyncAws\S3\Input\UploadPartCopyRequest; use AsyncAws\S3\Input\UploadPartRequest; use AsyncAws\S3\Result\AbortMultipartUploadOutput; use AsyncAws\S3\Result\BucketExistsWaiter; @@ -82,6 +83,7 @@ use AsyncAws\S3\Result\PutObjectAclOutput; use AsyncAws\S3\Result\PutObjectOutput; use AsyncAws\S3\Result\PutObjectTaggingOutput; +use AsyncAws\S3\Result\UploadPartCopyOutput; use AsyncAws\S3\Result\UploadPartOutput; use AsyncAws\S3\Signer\SignerV4ForS3; use AsyncAws\S3\ValueObject\AccessControlPolicy; @@ -2432,6 +2434,136 @@ public function uploadPart($input): UploadPartOutput return new UploadPartOutput($response); } + /** + * Uploads a part by copying data from an existing object as data source. You specify the data source by adding the + * request header `x-amz-copy-source` in your request and a byte range by adding the request header + * `x-amz-copy-source-range` in your request. + * + * For information about maximum and minimum part sizes and other multipart upload specifications, see Multipart upload + * limits [^1] in the *Amazon S3 User Guide*. + * + * > Instead of using an existing object as part data, you might use the UploadPart [^2] action and provide data in your + * > request. + * + * You must initiate a multipart upload before you can upload any part. In response to your initiate request. Amazon S3 + * returns a unique identifier, the upload ID, that you must include in your upload part request. + * + * For more information about using the `UploadPartCopy` operation, see the following: + * + * - For conceptual information about multipart uploads, see Uploading Objects Using Multipart Upload [^3] in the + * *Amazon S3 User Guide*. + * - For information about permissions required to use the multipart upload API, see Multipart Upload and Permissions + * [^4] in the *Amazon S3 User Guide*. + * - For information about copying objects using a single atomic action vs. a multipart upload, see Operations on + * Objects [^5] in the *Amazon S3 User Guide*. + * - For information about using server-side encryption with customer-provided encryption keys with the `UploadPartCopy` + * operation, see CopyObject [^6] and UploadPart [^7]. + * + * Note the following additional considerations about the request headers `x-amz-copy-source-if-match`, + * `x-amz-copy-source-if-none-match`, `x-amz-copy-source-if-unmodified-since`, and + * `x-amz-copy-source-if-modified-since`: + * + * - **Consideration 1** - If both of the `x-amz-copy-source-if-match` and `x-amz-copy-source-if-unmodified-since` + * headers are present in the request as follows: + * + * `x-amz-copy-source-if-match` condition evaluates to `true`, and; + * + * `x-amz-copy-source-if-unmodified-since` condition evaluates to `false`; + * + * Amazon S3 returns `200 OK` and copies the data. + * - **Consideration 2** - If both of the `x-amz-copy-source-if-none-match` and `x-amz-copy-source-if-modified-since` + * headers are present in the request as follows: + * + * `x-amz-copy-source-if-none-match` condition evaluates to `false`, and; + * + * `x-amz-copy-source-if-modified-since` condition evaluates to `true`; + * + * Amazon S3 returns `412 Precondition Failed` response code. + * + * - `Versioning`: + * + * If your bucket has versioning enabled, you could have multiple versions of the same object. By default, + * `x-amz-copy-source` identifies the current version of the object to copy. If the current version is a delete marker + * and you don't specify a versionId in the `x-amz-copy-source`, Amazon S3 returns a 404 error, because the object + * does not exist. If you specify versionId in the `x-amz-copy-source` and the versionId is a delete marker, Amazon S3 + * returns an HTTP 400 error, because you are not allowed to specify a delete marker as a version for the + * `x-amz-copy-source`. + * + * You can optionally specify a specific version of the source object to copy by adding the `versionId` subresource as + * shown in the following example: + * + * `x-amz-copy-source: /bucket/object?versionId=version id` + * - `Special errors`: + * + * - - *Code: NoSuchUpload* + * - - *Cause: The specified multipart upload does not exist. The upload ID might be invalid, or the multipart upload + * - might have been aborted or completed.* + * - - *HTTP Status Code: 404 Not Found* + * - + * - - *Code: InvalidRequest* + * - - *Cause: The specified copy source is not supported as a byte-range copy source.* + * - - *HTTP Status Code: 400 Bad Request* + * - + * + * + * The following operations are related to `UploadPartCopy`: + * + * - CreateMultipartUpload [^8] + * - UploadPart [^9] + * - CompleteMultipartUpload [^10] + * - AbortMultipartUpload [^11] + * - ListParts [^12] + * - ListMultipartUploads [^13] + * + * [^1]: https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html + * [^2]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_UploadPart.html + * [^3]: https://docs.aws.amazon.com/AmazonS3/latest/dev/uploadobjusingmpu.html + * [^4]: https://docs.aws.amazon.com/AmazonS3/latest/dev/mpuAndPermissions.html + * [^5]: https://docs.aws.amazon.com/AmazonS3/latest/dev/ObjectOperations.html + * [^6]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html + * [^7]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_UploadPart.html + * [^8]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html + * [^9]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_UploadPart.html + * [^10]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CompleteMultipartUpload.html + * [^11]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_AbortMultipartUpload.html + * [^12]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListParts.html + * [^13]: https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListMultipartUploads.html + * + * @see http://docs.amazonwebservices.com/AmazonS3/latest/API/mpUploadUploadPartCopy.html + * @see https://docs.aws.amazon.com/AmazonS3/latest/API/API_UploadPartCopy.html + * @see https://docs.aws.amazon.com/aws-sdk-php/v3/api/api-s3-2006-03-01.html#uploadpartcopy + * + * @param array{ + * Bucket: string, + * CopySource: string, + * CopySourceIfMatch?: null|string, + * CopySourceIfModifiedSince?: null|\DateTimeImmutable|string, + * CopySourceIfNoneMatch?: null|string, + * CopySourceIfUnmodifiedSince?: null|\DateTimeImmutable|string, + * CopySourceRange?: null|string, + * Key: string, + * PartNumber: int, + * UploadId: string, + * SSECustomerAlgorithm?: null|string, + * SSECustomerKey?: null|string, + * SSECustomerKeyMD5?: null|string, + * CopySourceSSECustomerAlgorithm?: null|string, + * CopySourceSSECustomerKey?: null|string, + * CopySourceSSECustomerKeyMD5?: null|string, + * RequestPayer?: null|RequestPayer::*, + * ExpectedBucketOwner?: null|string, + * ExpectedSourceBucketOwner?: null|string, + * '@region'?: string|null, + * }|UploadPartCopyRequest $input + */ + public function uploadPartCopy($input): UploadPartCopyOutput + { + $input = UploadPartCopyRequest::create($input); + $response = $this->getResponse($input->request(), new RequestContext(['operation' => 'UploadPartCopy', 'region' => $input->getRegion()])); + + return new UploadPartCopyOutput($response); + } + protected function getAwsErrorFactory(): AwsErrorFactoryInterface { return new XmlAwsErrorFactory(); diff --git a/src/Service/S3/src/ValueObject/CopyPartResult.php b/src/Service/S3/src/ValueObject/CopyPartResult.php new file mode 100644 index 000000000..079f66af0 --- /dev/null +++ b/src/Service/S3/src/ValueObject/CopyPartResult.php @@ -0,0 +1,132 @@ +etag = $input['ETag'] ?? null; + $this->lastModified = $input['LastModified'] ?? null; + $this->checksumCrc32 = $input['ChecksumCRC32'] ?? null; + $this->checksumCrc32C = $input['ChecksumCRC32C'] ?? null; + $this->checksumSha1 = $input['ChecksumSHA1'] ?? null; + $this->checksumSha256 = $input['ChecksumSHA256'] ?? null; + } + + /** + * @param array{ + * ETag?: null|string, + * LastModified?: null|\DateTimeImmutable, + * ChecksumCRC32?: null|string, + * ChecksumCRC32C?: null|string, + * ChecksumSHA1?: null|string, + * ChecksumSHA256?: null|string, + * }|CopyPartResult $input + */ + public static function create($input): self + { + return $input instanceof self ? $input : new self($input); + } + + public function getChecksumCrc32(): ?string + { + return $this->checksumCrc32; + } + + public function getChecksumCrc32C(): ?string + { + return $this->checksumCrc32C; + } + + public function getChecksumSha1(): ?string + { + return $this->checksumSha1; + } + + public function getChecksumSha256(): ?string + { + return $this->checksumSha256; + } + + public function getEtag(): ?string + { + return $this->etag; + } + + public function getLastModified(): ?\DateTimeImmutable + { + return $this->lastModified; + } +} diff --git a/src/Service/S3/tests/Integration/S3ClientTest.php b/src/Service/S3/tests/Integration/S3ClientTest.php index a2339bb13..3f8422d68 100644 --- a/src/Service/S3/tests/Integration/S3ClientTest.php +++ b/src/Service/S3/tests/Integration/S3ClientTest.php @@ -35,6 +35,7 @@ use AsyncAws\S3\Input\PutObjectAclRequest; use AsyncAws\S3\Input\PutObjectRequest; use AsyncAws\S3\Input\PutObjectTaggingRequest; +use AsyncAws\S3\Input\UploadPartCopyRequest; use AsyncAws\S3\Input\UploadPartRequest; use AsyncAws\S3\Result\PutObjectOutput; use AsyncAws\S3\S3Client; @@ -951,6 +952,24 @@ public function testUploadPart(): void self::assertEquals(200, $result->info()['status']); } + public function testUploadPartCopy(): void + { + $client = $this->getClient(); + + $input = new UploadPartCopyRequest([ + 'Bucket' => 'foo', + 'Key' => 'destination-object.txt', + 'PartNumber' => 1, + 'UploadId' => '123', + 'CopySource' => 'foo/bar', + ]); + $result = $client->uploadPartCopy($input); + + $result->resolve(); + + self::assertEquals(200, $result->info()['status']); + } + private function getClient(): S3Client { return new S3Client([ diff --git a/src/Service/S3/tests/Unit/Input/UploadPartCopyRequestTest.php b/src/Service/S3/tests/Unit/Input/UploadPartCopyRequestTest.php new file mode 100644 index 000000000..7326ac8ca --- /dev/null +++ b/src/Service/S3/tests/Unit/Input/UploadPartCopyRequestTest.php @@ -0,0 +1,30 @@ + 'example-bucket', + 'Key' => 'copy-movie.m2ts', + 'CopySource' => 'example-bucket/my-movie.m2ts', + 'CopySourceRange' => 'bytes=0-1', + 'PartNumber' => 1, + 'UploadId' => 'VCVsb2FkIElEIGZvciBlbZZpbmcncyBteS1tb3ZpZS5tMnRzIHVwbG9hZR', + ]); + + // see example-1.json from SDK + $expected = ' + PUT /example-bucket/copy-movie.m2ts?partNumber=1&uploadId=VCVsb2FkIElEIGZvciBlbZZpbmcncyBteS1tb3ZpZS5tMnRzIHVwbG9hZR HTTP/1.1 + Content-Type: application/xml + x-amz-copy-source: example-bucket/my-movie.m2ts + x-amz-copy-source-range: bytes=0-1'; + + self::assertRequestEqualsHttpRequest($expected, $input->request()); + } +} diff --git a/src/Service/S3/tests/Unit/Result/UploadPartCopyOutputTest.php b/src/Service/S3/tests/Unit/Result/UploadPartCopyOutputTest.php new file mode 100644 index 000000000..ecacd5c13 --- /dev/null +++ b/src/Service/S3/tests/Unit/Result/UploadPartCopyOutputTest.php @@ -0,0 +1,28 @@ + + "b0c6f0e7e054ab8fa2536a2677f8734d" + 2016-12-29T21:24:43.000Z + '); + + $client = new MockHttpClient($response); + $result = new UploadPartCopyOutput(new Response($client->request('POST', 'http://localhost'), $client, new NullLogger())); + + self::assertSame('"b0c6f0e7e054ab8fa2536a2677f8734d"', $result->getCopyPartResult()->getEtag()); + self::assertEquals(new \DateTimeImmutable('2016-12-29T21:24:43.000Z'), $result->getCopyPartResult()->getLastModified()); + } +} diff --git a/src/Service/S3/tests/Unit/S3ClientTest.php b/src/Service/S3/tests/Unit/S3ClientTest.php index 955697fb5..805209041 100644 --- a/src/Service/S3/tests/Unit/S3ClientTest.php +++ b/src/Service/S3/tests/Unit/S3ClientTest.php @@ -32,6 +32,7 @@ use AsyncAws\S3\Input\PutObjectAclRequest; use AsyncAws\S3\Input\PutObjectRequest; use AsyncAws\S3\Input\PutObjectTaggingRequest; +use AsyncAws\S3\Input\UploadPartCopyRequest; use AsyncAws\S3\Input\UploadPartRequest; use AsyncAws\S3\Result\AbortMultipartUploadOutput; use AsyncAws\S3\Result\CompleteMultipartUploadOutput; @@ -54,6 +55,7 @@ use AsyncAws\S3\Result\PutObjectAclOutput; use AsyncAws\S3\Result\PutObjectOutput; use AsyncAws\S3\Result\PutObjectTaggingOutput; +use AsyncAws\S3\Result\UploadPartCopyOutput; use AsyncAws\S3\Result\UploadPartOutput; use AsyncAws\S3\S3Client; use AsyncAws\S3\ValueObject\CORSConfiguration; @@ -540,4 +542,22 @@ public function testUploadPart(): void self::assertInstanceOf(UploadPartOutput::class, $result); self::assertFalse($result->info()['resolved']); } + + public function testUploadPartCopy(): void + { + $client = new S3Client([], new NullProvider(), new MockHttpClient()); + + $input = new UploadPartCopyRequest([ + 'Bucket' => 'destination-bucket', + 'CopySource' => 'source-bucket/image.png', + + 'Key' => 'copy-image.png', + 'PartNumber' => 1337, + 'UploadId' => '123', + ]); + $result = $client->uploadPartCopy($input); + + self::assertInstanceOf(UploadPartCopyOutput::class, $result); + self::assertFalse($result->info()['resolved']); + } }