diff --git a/Gax/src/Call.php b/Gax/src/Call.php index 53c5d32bb2c4..adcc77e19457 100644 --- a/Gax/src/Call.php +++ b/Gax/src/Call.php @@ -45,6 +45,7 @@ class Call const SERVER_STREAMING_CALL = 3; const LONGRUNNING_CALL = 4; const PAGINATED_CALL = 5; + const RESUMABLE_UPLOAD_CALL = 6; private $method; private $callType; diff --git a/Gax/src/GapicClientTrait.php b/Gax/src/GapicClientTrait.php index 46d4967055da..0d8877a9b40b 100644 --- a/Gax/src/GapicClientTrait.php +++ b/Gax/src/GapicClientTrait.php @@ -44,6 +44,7 @@ use Google\ApiCore\Options\CallOptions; use Google\ApiCore\Options\ClientOptions; use Google\ApiCore\Options\TransportOptions; +use Google\ApiCore\ResumableUpload\ResumableUpload; use Google\ApiCore\Transport\GrpcFallbackTransport; use Google\ApiCore\Transport\GrpcTransport; use Google\ApiCore\Transport\RestTransport; @@ -567,6 +568,7 @@ private function startAsyncCall( case Call::SERVER_STREAMING_CALL: case Call::CLIENT_STREAMING_CALL: case Call::BIDI_STREAMING_CALL: + case Call::RESUMABLE_UPLOAD_CALL: throw new ValidationException("Call type '$callType' of requested method " . "'$methodName' is not supported for async execution."); } @@ -629,6 +631,10 @@ private function startApiCall( return $this->getPagedListResponse($methodName, $optionalArgs, $decodeType, $request, $interfaceName); } + if ($callType == Call::RESUMABLE_UPLOAD_CALL) { + return $this->startResumableUploadCall($methodName, $optionalArgs, $decodeType, $request, $interfaceName); + } + // Unary, and all Streaming types handled by startCall. return $this->startCall($methodName, $decodeType, $optionalArgs, $request, $callType, $interfaceName); } @@ -880,6 +886,38 @@ private function getPagedListResponse( )->wait(); } + /** + * @param string $methodName + * @param array $optionalArgs + * @param string $decodeType + * @param Message|null $request + * @param string|null $interfaceName + * + * @return ResumableUpload + */ + private function startResumableUploadCall( + string $methodName, + array $optionalArgs, + string $decodeType, + ?Message $request, + ?string $interfaceName = null + ) { + $call = new Call( + $this->buildMethod($interfaceName, $methodName), + $decodeType, + $request, + $this->descriptors[$methodName] ?? [], + Call::RESUMABLE_UPLOAD_CALL + ); + + return new ResumableUpload( + $this->resumableUploadClient, + $call, + $optionalArgs, + $optionalArgs['uploadUrl'] ?? null + ); + } + /** * @param string $methodName * @param array $optionalArgs diff --git a/Gax/src/ResumableUpload/ResumableUpload.php b/Gax/src/ResumableUpload/ResumableUpload.php new file mode 100644 index 000000000000..f08feffd6086 --- /dev/null +++ b/Gax/src/ResumableUpload/ResumableUpload.php @@ -0,0 +1,130 @@ +resumeUpload($methodName, $uploadUrl)` + * to resume the upload across process restarts or background jobs. + * + * @return ?string + */ + public function getUploadUrl(): ?string + { + return $this->uploadUrl; + } + + /** + * Sets the resumable upload session URL. + * + * @param string $uploadUrl + * @return void + */ + public function setUploadUrl(string $uploadUrl): void + { + $this->uploadUrl = $uploadUrl; + } + + /** + * Starts or resumes the resumable upload exchange using the provided data stream. + * If this instance already has an `uploadUrl` (e.g. created via `$client->resumeUpload($methodName, $uploadUrl)` + * or after a previous start/interruption), calling `startUpload($dataStream, $resumableUploadOptions)` queries + * the server for the current byte offset and resumes transmitting remaining chunks. + * + * @param StreamInterface $dataStream + * @param array $resumableUploadOptions { + * Optional. + * + * @type int $chunkSize Optional. The size of each chunk to upload in bytes. + * Must be a multiple of 262144 (256 KB). Values smaller than the server's chunk + * granularity (typically 256 KB) will be rounded up to match the granularity. + * Defaults to 8388608 (8 MB). + * @type callable $progressCallback Optional. A callback function executed after + * every chunk upload or query. The callback should accept two arguments: + * (int $bytesUploaded, ResumableUpload $upload). + * @type int $totalTimeoutMillis Optional. The total timeout in milliseconds for the + * entire resumable upload operation. Defaults to 600000 (10 minutes). + * } + * @return Message + */ + public function startUpload(StreamInterface $dataStream, array $resumableUploadOptions = []): Message + { + return $this->resumableUploadClient->startUpload( + $this, + $dataStream, + $this->call, + $this->callOptions, + $resumableUploadOptions + ); + } +} diff --git a/Gax/src/ResumableUpload/ResumableUploadClient.php b/Gax/src/ResumableUpload/ResumableUploadClient.php new file mode 100644 index 000000000000..40b4702c06a3 --- /dev/null +++ b/Gax/src/ResumableUpload/ResumableUploadClient.php @@ -0,0 +1,431 @@ +finalResponse = null; + $uploadUrl = $upload->getUploadUrl() ?? $resumableUploadOptions['uploadUrl'] ?? null; + $totalTimeoutMillis = (float) ($resumableUploadOptions['totalTimeoutMillis'] + ?? self::DEFAULT_TOTAL_TIMEOUT_MILLIS); + $deadlineMs = microtime(true) * 1000 + $totalTimeoutMillis; + + $state = new ResumableUploadState( + $resumableUploadOptions['chunkSize'] ?? self::DEFAULT_CHUNK_SIZE, + $resumableUploadOptions['progressCallback'] ?? null, + $uploadUrl, + $uploadUrl !== null ? self::PHASE_RECOVERY : self::PHASE_STARTING + ); + + while ($state->phase !== self::PHASE_DONE) { + $this->checkDeadline($deadlineMs); + try { + $state->phase = match ($state->phase) { + self::PHASE_STARTING => $call->getMessage() !== null + ? $this->phaseStarting( + $state, + $upload, + $dataStream, + $call, + $callOptions + ) + : throw new ValidationException( + 'A Call with request message is required when starting a new resumable upload.' + ), + self::PHASE_TRANSMITTING, + self::PHASE_FINALIZING => $this->phaseUploading($state, $upload, $dataStream), + self::PHASE_RECOVERY => $this->phaseRecovery($state, $upload, $dataStream), + default => throw new ApiException("Unexpected phase: {$state->phase}", 0, ApiStatus::INTERNAL), + }; + } catch (Throwable $e) { + $state->phase = $this->handleException( + $e, + $state, + $deadlineMs + ); + } + } + + $decodeType = $call->getDecodeType(); + if ($decodeType === null || !class_exists($decodeType)) { + throw new ValidationException('A valid decodeType is required on the Call object.'); + } + + if ($this->finalResponse === null) { + throw new ApiException('No final response received from server.', 0, ApiStatus::INTERNAL); + } + + $body = (string) $this->finalResponse->getBody(); + if ($body === '') { + throw new ApiException('Final response body was empty.', 0, ApiStatus::INTERNAL); + } + + /** @var Message $responseMessage */ + $responseMessage = new $decodeType(); + $responseMessage->mergeFromJsonString($body, true); + + return $responseMessage; + } + + private function phaseStarting( + ResumableUploadState $state, + ResumableUpload $upload, + StreamInterface $dataStream, + Call $call, + array $callOptions = [] + ): string { + $headers = array_merge($this->headers, $callOptions['headers'] ?? []); + $headers['X-Goog-Upload-Protocol'] = 'resumable'; + $headers['X-Goog-Upload-Command'] = 'start'; + if ($dataStream->getSize() !== null) { + $headers['X-Goog-Upload-Header-Content-Length'] = (string) $dataStream->getSize(); + } + + $request = $this->transport->buildRequest($call->getMethod(), $call->getMessage(), $headers); + + // Add upload prefix + $uri = $request->getUri(); + $request = $request->withUri($uri->withPath($this->uploadPrefix . $uri->getPath())); + + // Add retry settings + $retrySettings = $callOptions['retrySettings'] ?? null; + if ($retrySettings !== null && !$retrySettings instanceof RetrySettings) { + $retrySettings = RetrySettings::constructDefault()->with($retrySettings); + } + + // Make the request + $response = $this->sendRequest($request, $callOptions['timeoutMillis'] ?? null, $retrySettings); + if ($response->getStatusCode() !== 200) { + $this->handleErrorResponse($response); + } + $urlHeader = $response->getHeaderLine('X-Goog-Upload-URL'); + if (!empty($urlHeader)) { + if ($request->getUri()->getScheme() === 'https' + && str_starts_with($urlHeader, 'http://') + ) { + $urlHeader = 'https://' . substr($urlHeader, 7); + } + $state->uploadUrl = $urlHeader; + } + if ($state->uploadUrl !== null) { + $upload->setUploadUrl($state->uploadUrl); + } + $granularityHeader = $response->getHeaderLine('X-Goog-Upload-Chunk-Granularity'); + $state->chunkGranularity = !empty($granularityHeader) ? (int) $granularityHeader : 1; + $statusHeader = $response->getHeaderLine('X-Goog-Upload-Status'); + if ($statusHeader === 'final') { + $this->finalResponse = $response; + return self::PHASE_DONE; + } + return self::PHASE_TRANSMITTING; + } + + private function phaseUploading( + ResumableUploadState $state, + ResumableUpload $upload, + StreamInterface $dataStream + ): string { + $state->prepareBuffer($dataStream); + + $headers = []; + $headers['X-Goog-Upload-Offset'] = (string) $state->committedOffset; + $body = (string) $state->buffer; + + if ($state->isEof) { + $phase = self::PHASE_FINALIZING; + $headers['X-Goog-Upload-Command'] = strlen($body) > 0 ? 'upload, finalize' : 'finalize'; + } else { + $phase = self::PHASE_TRANSMITTING; + $headers['X-Goog-Upload-Command'] = 'upload'; + } + + $response = $this->sendRequest( + new Request('POST', (string) $state->uploadUrl, $headers, $body) + ); + if ($response->getStatusCode() !== 200) { + $this->handleErrorResponse($response); + } + + if ($state->progressCallback && $headers['X-Goog-Upload-Command'] !== 'finalize') { + ($state->progressCallback)( + $state->committedOffset + strlen($body), + $upload + ); + } + + if ($response->getHeaderLine('X-Goog-Upload-Status') === 'final') { + $this->finalResponse = $response; + return self::PHASE_DONE; + } + + $state->commitBuffer(); + return self::PHASE_TRANSMITTING; + } + + private function phaseRecovery( + ResumableUploadState $state, + ResumableUpload $upload, + StreamInterface $dataStream + ): string { + if (empty($state->uploadUrl)) { + throw new ValidationException('Cannot recover resumable upload: uploadUrl is not set.'); + } + $headers = ['X-Goog-Upload-Command' => 'query']; + $response = $this->sendRequest( + new Request('POST', (string) $state->uploadUrl, $headers, '') + ); + $statusCode = $response->getStatusCode(); + if ($statusCode === 200) { + $serverOffsetStr = $response->getHeaderLine('X-Goog-Upload-Size-Received'); + $serverOffset = !empty($serverOffsetStr) || $serverOffsetStr === '0' + ? (int) $serverOffsetStr + : $state->committedOffset; + + $state->reconcileRecoveryOffset($serverOffset, $dataStream, self::MAX_RECOVERY_ATTEMPTS); + + $statusHeader = $response->getHeaderLine('X-Goog-Upload-Status'); + if ($statusHeader === 'final') { + $this->finalResponse = $response; + return self::PHASE_DONE; + } + + if ($state->progressCallback) { + ($state->progressCallback)($state->committedOffset, $upload); + } + + return self::PHASE_TRANSMITTING; + } + $this->handleErrorResponse($response); + } + + private function sendRequest( + RequestInterface $request, + ?int $timeoutMillis = null, + ?RetrySettings $retrySettings = null + ): ResponseInterface { + $reqHeaders = $request->getHeaders(); + if ($authCallback = $this->credentialsWrapper->getAuthorizationHeaderCallback()) { + $reqHeaders = array_merge($reqHeaders, $authCallback()); + } + foreach ($reqHeaders as $k => $v) { + $request = $request->withHeader($k, $v); + } + + $callOptions = []; + if ($timeoutMillis !== null && $timeoutMillis > 0) { + $callOptions['timeout'] = $timeoutMillis / 1000; + } + + if ($retrySettings !== null) { + $callOptions['retrySettings'] = $retrySettings; + $middleware = new RetryMiddleware( + fn (Call $unusedCall, array $options) => Create::promiseFor( + $this->transport->sendRawRequest($request, $options) + ), + $retrySettings + ); + $response = $middleware( + new Call(''), // unused + $callOptions + ); + } else { + $response = $this->transport->sendRawRequest($request, $callOptions); + } + + if (is_object($response) && method_exists($response, 'wait')) { + $response = $response->wait(); + } + return $response; + } + + private function checkDeadline(float $deadlineMs, ?\Throwable $previous = null): void + { + if (microtime(true) * 1000 >= $deadlineMs) { + throw new ApiException( + 'Resumable upload total timeout exceeded.', + Code::DEADLINE_EXCEEDED, + ApiStatus::DEADLINE_EXCEEDED, + $previous ? ['previous' => $previous] : [] + ); + } + } + + private function handleException( + \Throwable $e, + ResumableUploadState $state, + float $deadlineMs + ): string { + $this->checkDeadline($deadlineMs, $e); + + $code = (int) $e->getCode(); + if ($e instanceof RequestException) { + $response = method_exists($e, 'getResponse') ? $e->getResponse() : null; + if ($response) { + $code = $response->getStatusCode(); + } + } + + // For transient HTTP errors, return the current phase unchanged so that the match loop + // re-runs the phase and retries the request until the total deadline is exceeded. + if (in_array($code, [429, 500, 502, 503, 504])) { + return $state->phase; + } + + // For range mismatch or bad request errors, transition to the query/recovery phase to + // verify the server's received offset and resume transmitting from there, provided + // an uploadUrl session has already been established. + if ($state->uploadUrl !== null && in_array($code, [308, 400, 412, 416])) { + return self::PHASE_RECOVERY; + } + + if ($e instanceof ApiException || $e instanceof ValidationException) { + throw $e; + } + throw new ApiException( + $e->getMessage(), + $code, + ApiStatus::INTERNAL, + ['previous' => $e] + ); + } + + private function handleErrorResponse(ResponseInterface $response): never + { + $statusCode = $response->getStatusCode(); + $body = (string) $response->getBody(); + + if ($response->getHeaderLine('X-Goog-Upload-Status') === 'final') { + throw new ApiException( + $body ?: 'Upload rejected by server', + $statusCode, + ApiStatus::INVALID_ARGUMENT + ); + } + + throw new ApiException( + "HTTP error {$statusCode}: {$body}", + $statusCode, + ApiStatus::statusFromRpcCode(ApiStatus::rpcCodeFromHttpStatusCode($statusCode)) + ); + } +} diff --git a/Gax/src/ResumableUpload/ResumableUploadState.php b/Gax/src/ResumableUpload/ResumableUploadState.php new file mode 100644 index 000000000000..0522ce1d393d --- /dev/null +++ b/Gax/src/ResumableUpload/ResumableUploadState.php @@ -0,0 +1,179 @@ +buffer !== null) { + return; + } + + $effectiveChunkSize = $this->chunkSize; + if ($this->chunkGranularity > 0 && ($effectiveChunkSize % $this->chunkGranularity !== 0)) { + $effectiveChunkSize = (int) ( + floor($effectiveChunkSize / $this->chunkGranularity) * $this->chunkGranularity + ); + if ($effectiveChunkSize === 0) { + $effectiveChunkSize = $this->chunkGranularity; + } + } + + if ($this->committedOffset > 0 && $dataStream->tell() !== $this->committedOffset) { + if (!$dataStream->isSeekable()) { + throw new ValidationException( + "Cannot read from stream at offset {$this->committedOffset}: the stream " + . "position is {$dataStream->tell()} and the stream is not seekable." + ); + } + try { + $dataStream->seek($this->committedOffset); + } catch (\Throwable $e) { + throw new ValidationException( + "Failed to seek data stream to offset {$this->committedOffset}: " . $e->getMessage(), + 0, + $e + ); + } + } + + try { + $this->buffer = $dataStream->read($effectiveChunkSize); + } catch (\Throwable $e) { + throw new ValidationException( + 'Error reading from data stream: ' . $e->getMessage(), + 0, + $e + ); + } + $this->isEof = $dataStream->eof(); + } + + public function commitBuffer(): void + { + $this->previousBuffer = $this->buffer; + $this->previousOffset = $this->committedOffset; + $this->committedOffset += strlen((string) $this->buffer); + $this->buffer = null; + } + + public function reconcileRecoveryOffset( + int $serverOffset, + StreamInterface $dataStream, + int $maxRecoveryAttempts + ): void { + if ($serverOffset === $this->lastRecoveryOffset) { + $this->recoveryAttempts++; + if ($this->recoveryAttempts >= $maxRecoveryAttempts) { + throw new ApiException( + 'Exhausted recovery attempts with unchanged offset', + 0, + ApiStatus::ABORTED + ); + } + } else { + $this->recoveryAttempts = 0; + } + $this->lastRecoveryOffset = $serverOffset; + + if ($this->buffer !== null + && $serverOffset >= $this->committedOffset + && $serverOffset <= $this->committedOffset + strlen((string) $this->buffer) + ) { + $sliceOffset = $serverOffset - $this->committedOffset; + $this->buffer = substr($this->buffer, $sliceOffset); + $this->committedOffset = $serverOffset; + } elseif ($this->previousBuffer !== null + && $serverOffset >= $this->previousOffset + && $serverOffset < $this->committedOffset + ) { + $combined = $this->previousBuffer . (string) $this->buffer; + $sliceOffset = $serverOffset - $this->previousOffset; + $this->buffer = substr($combined, $sliceOffset); + $this->committedOffset = $serverOffset; + } else { + if (!$dataStream->isSeekable()) { + throw new ValidationException( + "Cannot recover resumable upload: the server confirmed offset {$serverOffset}, " + . 'which falls outside the buffered chunks, and the provided data stream is not seekable.' + ); + } + try { + $dataStream->seek($serverOffset); + } catch (\Throwable $e) { + throw new ValidationException( + "Failed to seek data stream to offset {$serverOffset}: " . $e->getMessage(), + 0, + $e + ); + } + $this->committedOffset = $serverOffset; + $this->buffer = null; + } + } +} diff --git a/Gax/src/ResumableUpload/ResumableUploadTrait.php b/Gax/src/ResumableUpload/ResumableUploadTrait.php new file mode 100644 index 000000000000..8bd06106a1f9 --- /dev/null +++ b/Gax/src/ResumableUpload/ResumableUploadTrait.php @@ -0,0 +1,95 @@ +startApiCall(ucfirst($methodName), null, $optionalArgs); + } + + /** + * Create the ResumableUploadClient for this GAPIC client. + * + * @param array $options + * @return ResumableUploadClient + */ + private function createResumableUploadClient(array $options): ResumableUploadClient + { + $transport = $options['transport'] ?? null; + if (!$transport instanceof ResumableUploadTransportInterface) { + $transport = $this->createTransport( + $options['apiEndpoint'] ?? '', + 'rest', + $options['transportConfig'] ?? [], + $options['clientCertSource'] ?? null, + $options['hasEmulator'] ?? false + ); + } + + return new ResumableUploadClient( + $transport, + $this->credentialsWrapper, + $this->agentHeader + ); + } +} diff --git a/Gax/src/ResumableUpload/ResumableUploadTransportInterface.php b/Gax/src/ResumableUpload/ResumableUploadTransportInterface.php new file mode 100644 index 000000000000..b1e49d7e116e --- /dev/null +++ b/Gax/src/ResumableUpload/ResumableUploadTransportInterface.php @@ -0,0 +1,65 @@ +buildRequest($call, $options), + $this->buildGrpcFallbackRequest($call, $options), $this->getCallOptions($options) )->then( function (ResponseInterface $response) use ($options) { @@ -133,7 +133,7 @@ function (\Exception $ex) { * @param array $options * @return RequestInterface */ - private function buildRequest(Call $call, array $options) + private function buildGrpcFallbackRequest(Call $call, array $options) { // Build common headers and set the content type to 'application/x-protobuf' $headers = ['Content-Type' => 'application/x-protobuf'] + self::buildCommonHeaders($options); diff --git a/Gax/src/Transport/RestTransport.php b/Gax/src/Transport/RestTransport.php index 0afd1193e77f..ae0245130dd9 100644 --- a/Gax/src/Transport/RestTransport.php +++ b/Gax/src/Transport/RestTransport.php @@ -35,6 +35,7 @@ use Google\ApiCore\Call; use Google\ApiCore\InsecureRequestBuilder; use Google\ApiCore\RequestBuilder; +use Google\ApiCore\ResumableUpload\ResumableUploadTransportInterface; use Google\ApiCore\ServerStream; use Google\ApiCore\ServiceAddressTrait; use Google\ApiCore\Transport\Rest\RestServerStreamingCall; @@ -48,7 +49,7 @@ /** * A REST based transport implementation. */ -class RestTransport implements TransportInterface +class RestTransport implements TransportInterface, ResumableUploadTransportInterface { use ValidationTrait; use ServiceAddressTrait; @@ -224,6 +225,31 @@ public function startServerStreamingCall(Call $call, array $options) ); } + /** + * Sends a raw PSR-7 request. + * + * @param RequestInterface $request + * @param array $options + * @return \Psr\Http\Message\ResponseInterface|\GuzzleHttp\Promise\PromiseInterface + */ + public function sendRawRequest(RequestInterface $request, array $options = []) + { + return ($this->httpHandler)($request, $options); + } + + /** + * Builds a PSR-7 request. + * + * @param string $method + * @param ?Message $message + * @param array $headers + * @return RequestInterface + */ + public function buildRequest(string $method, ?Message $message = null, array $headers = []): RequestInterface + { + return $this->requestBuilder->build($method, $message, $headers); + } + /** * Creates and starts a RestServerStreamingCall. * diff --git a/Gax/tests/Conformance/ResumableUploadTest.php b/Gax/tests/Conformance/ResumableUploadTest.php new file mode 100644 index 000000000000..4ea01801176c --- /dev/null +++ b/Gax/tests/Conformance/ResumableUploadTest.php @@ -0,0 +1,177 @@ + self::SHOWCASE_HOST, + ]; + if (file_exists(self::PEM_PATH)) { + $httpHandler = \Google\Auth\HttpHandler\HttpHandlerFactory::build( + new \GuzzleHttp\Client(['verify' => self::PEM_PATH]) + ); + $options['transportConfig'] = [ + 'rest' => [ + 'httpHandler' => [$httpHandler, 'async'], + ], + ]; + $options['credentials'] = new \Google\ApiCore\InsecureCredentialsWrapper(); + } else { + $options['hasEmulator'] = true; + } + + $client = new ResumableUploadServiceClient($options); + + $callOptions = [ + 'headers' => $headers + ]; + $resumableUploadOptions = [ + 'chunkSize' => 1024 + ]; + if ($progressCallback !== null) { + $resumableUploadOptions['progressCallback'] = $progressCallback; + } + + $request = new UploadMediaRequest(); + $upload = $client->uploadMedia($request, $callOptions); + + $stream = Utils::streamFor($data); + return $upload->startUpload($stream, $resumableUploadOptions); + } + + public function testHappyPathUpload() + { + $callbackBytes = null; + $callbackUrl = null; + $payload = 'hello world from happy path integration test to resumable upload service!'; + + $result = $this->createClientAndUpload( + $payload, + function (int $bytes, ResumableUpload $upload) use (&$callbackBytes, &$callbackUrl) { + $callbackBytes = $bytes; + $callbackUrl = $upload->getUploadUrl(); + } + ); + + $scheme = file_exists(self::PEM_PATH) ? 'https' : 'http'; + $this->assertInstanceOf(UploadMediaResponse::class, $result); + $this->assertEquals(strlen($payload), $callbackBytes); + $expectedUrlPrefix = "$scheme://" . self::SHOWCASE_HOST + . '/resumable/upload/v1beta1/files:upload?sid='; + $this->assertStringStartsWith($expectedUrlPrefix, $callbackUrl); + } + + public function testNonFatalErrorOnStartRecovery() + { + $payload = 'data with transient 503 error on start'; + $headers = [ + 'X-Goog-Test-Scenario' => 'non_fatal_error_on_start', + 'X-Goog-Test-Scenario-Config' => json_encode([ + 'error_code' => 503, + 'failure_count' => 1, + 'action_after_failures' => 'succeed' + ]) + ]; + + $result = $this->createClientAndUpload($payload, null, $headers); + $this->assertInstanceOf(UploadMediaResponse::class, $result); + } + + public function testNonFatalErrorOnChunkUploadRecovery() + { + $payload = str_repeat('a', 3000); // multiple chunks + $headers = [ + 'X-Goog-Test-Scenario' => 'non_fatal_error_on_chunk_upload', + 'X-Goog-Test-Scenario-Config' => json_encode([ + 'error_code' => 503, + 'failure_count' => 1, + 'after_offset' => 1024, + 'action_after_failures' => 'succeed' + ]) + ]; + + $result = $this->createClientAndUpload($payload, null, $headers); + $this->assertInstanceOf(UploadMediaResponse::class, $result); + } + + public function testNonFatalErrorOnQueryRecovery() + { + $payload = str_repeat('a', 3000); + $headers = [ + 'X-Goog-Test-Scenario' => 'non_fatal_error_on_query', + 'X-Goog-Test-Scenario-Config' => json_encode([ + 'error_code' => 503, + 'failure_count' => 1, + 'action_after_failures' => 'succeed' + ]) + ]; + + $result = $this->createClientAndUpload($payload, null, $headers); + $this->assertInstanceOf(UploadMediaResponse::class, $result); + } + + public function testChunkGranularityScenario() + { + $payload = str_repeat('b', 1024); + $headers = [ + 'X-Goog-Test-Scenario' => 'chunk_granularity' + ]; + + $result = $this->createClientAndUpload($payload, null, $headers); + $this->assertInstanceOf(UploadMediaResponse::class, $result); + } + + public function testFatalErrorOnStartThrowsException() + { + $payload = 'fatal error payload'; + $headers = [ + 'X-Goog-Test-Scenario' => 'fatal_error_on_start', + 'X-Goog-Test-Scenario-Config' => json_encode([ + 'error_code' => 403, + 'failure_count' => 1, + 'action_after_failures' => 'fail' + ]) + ]; + + $this->expectException(ApiException::class); + $this->expectExceptionCode(403); + + $this->createClientAndUpload($payload, null, $headers); + } +} diff --git a/Gax/tests/Conformance/metadata/V1Beta1/ResumableUpload.php b/Gax/tests/Conformance/metadata/V1Beta1/ResumableUpload.php new file mode 100644 index 000000000000..9c44f43718c2 --- /dev/null +++ b/Gax/tests/Conformance/metadata/V1Beta1/ResumableUpload.php @@ -0,0 +1,36 @@ +internalAddGeneratedFile( + ' +ï +.google/showcase/v1beta1/resumable_upload.protogoogle.showcase.v1beta1google/api/client.proto"" +UploadMediaRequest +name ( "1 +UploadMediaResponse +name (  +size (2¸ +ResumableUploadServiceŠ + UploadMedia+.google.showcase.v1beta1.UploadMediaRequest,.google.showcase.v1beta1.UploadMediaResponse" ‚Óä“"/v1beta1/files:upload:*ÊAlocalhost:7469Bq +com.google.showcase.v1beta1PZ4github.com/googleapis/gapic-showcase/server/genprotoêGoogle::Showcase::V1beta1bproto3' + , true); + + static::$is_initialized = true; + } +} + diff --git a/Gax/tests/Conformance/src/V1beta1/Client/ResumableUploadServiceClient.php b/Gax/tests/Conformance/src/V1beta1/Client/ResumableUploadServiceClient.php new file mode 100644 index 000000000000..7c7f44258fe6 --- /dev/null +++ b/Gax/tests/Conformance/src/V1beta1/Client/ResumableUploadServiceClient.php @@ -0,0 +1,204 @@ + self::SERVICE_NAME, + 'apiEndpoint' => self::SERVICE_ADDRESS . ':' . self::DEFAULT_SERVICE_PORT, + 'clientConfig' => __DIR__ . '/../resources/resumable_upload_service_client_config.json', + 'descriptorsConfigPath' => __DIR__ . '/../resources/resumable_upload_service_descriptor_config.php', + 'gcpApiConfigPath' => __DIR__ . '/../resources/resumable_upload_service_grpc_config.json', + 'credentialsConfig' => [ + 'defaultScopes' => self::$serviceScopes, + ], + 'transportConfig' => [ + 'rest' => [ + 'restClientConfigPath' => __DIR__ . '/../resources/resumable_upload_service_rest_client_config.php', + ], + ], + ]; + } + + /** + * Constructor. + * + * @param array|ClientOptions $options { + * Optional. Options for configuring the service API wrapper. + * + * @type string $apiEndpoint + * The address of the API remote host. May optionally include the port, formatted + * as ":". Default 'localhost:7469:443'. + * @type FetchAuthTokenInterface|CredentialsWrapper $credentials + * This option should only be used with a pre-constructed + * {@see FetchAuthTokenInterface} or {@see CredentialsWrapper} object. Note that + * when one of these objects are provided, any settings in $credentialsConfig will + * be ignored. + * **Important**: If you are providing a path to a credentials file, or a decoded + * credentials file as a PHP array, this usage is now DEPRECATED. Providing an + * unvalidated credential configuration to Google APIs can compromise the security + * of your systems and data. It is recommended to create the credentials explicitly + * ``` + * use Google\Auth\Credentials\ServiceAccountCredentials; + * use Google\Showcase\V1beta1\ResumableUploadServiceClient; + * $creds = new ServiceAccountCredentials($scopes, $json); + * $options = new ResumableUploadServiceClient(['credentials' => $creds]); + * ``` + * {@see + * https://cloud.google.com/docs/authentication/external/externally-sourced-credentials} + * @type array $credentialsConfig + * Options used to configure credentials, including auth token caching, for the + * client. For a full list of supporting configuration options, see + * {@see \Google\ApiCore\CredentialsWrapper::build()} . + * @type bool $disableRetries + * Determines whether or not retries defined by the client configuration should be + * disabled. Defaults to `false`. + * @type string|array $clientConfig + * Client method configuration, including retry settings. This option can be either + * a path to a JSON file, or a PHP array containing the decoded JSON data. By + * default this settings points to the default client config file, which is + * provided in the resources folder. + * @type string|TransportInterface $transport + * The transport used for executing network requests. May be either the string + * `rest` or `grpc`. Defaults to `grpc` if gRPC support is detected on the system. + * *Advanced usage*: Additionally, it is possible to pass in an already + * instantiated {@see \Google\ApiCore\Transport\TransportInterface} object. Note + * that when this object is provided, any settings in $transportConfig, and any + * $apiEndpoint setting, will be ignored. + * @type array $transportConfig + * Configuration options that will be used to construct the transport. Options for + * each supported transport type should be passed in a key for that transport. For + * example: + * $transportConfig = [ + * 'grpc' => [...], + * 'rest' => [...], + * ]; + * See the {@see \Google\ApiCore\Transport\GrpcTransport::build()} and + * {@see \Google\ApiCore\Transport\RestTransport::build()} methods for the + * supported options. + * @type callable $clientCertSource + * A callable which returns the client cert as a string. This can be used to + * provide a certificate and private key to the transport layer for mTLS. + * @type false|LoggerInterface $logger + * A PSR-3 compliant logger. If set to false, logging is disabled, ignoring the + * 'GOOGLE_SDK_PHP_LOGGING' environment flag + * @type string $universeDomain + * The service domain for the client. Defaults to 'googleapis.com'. + * } + * + * @throws ValidationException + * + * @experimental + */ + public function __construct(array|ClientOptions $options = []) + { + $clientOptions = $this->buildClientOptions($options); + $this->setClientOptions($clientOptions); + $this->resumableUploadClient = $this->createResumableUploadClient($clientOptions); + } + + /** Handles execution of the async variants for each documented method. */ + public function __call($method, $args) + { + if (substr($method, -5) !== 'Async') { + trigger_error('Call to undefined method ' . __CLASS__ . "::$method()", E_USER_ERROR); + } + + array_unshift($args, substr($method, 0, -5)); + return call_user_func_array([$this, 'startAsyncCall'], $args); + } + + /** + * A method with media_upload annotation enabled. + * + * @param UploadMediaRequest $request A request to house fields associated with the call. + * @param array $callOptions { + * Optional. + * + * @type array $headers + * Optional. Key-value array of custom HTTP headers to include with the initial + * upload request. + * @type int $timeoutMillis + * Optional. The timeout in milliseconds for the initial start call. + * @type RetrySettings|array $retrySettings + * Optional. Retry settings to use for the initial start call. + * } + * + * @return ResumableUpload + * + * @throws ApiException Thrown if the API call fails. + * + * @experimental + */ + public function uploadMedia(UploadMediaRequest $request, array $callOptions = []): ResumableUpload + { + return $this->startApiCall('UploadMedia', $request, $callOptions); + } +} diff --git a/Gax/tests/Conformance/src/V1beta1/UploadMediaRequest.php b/Gax/tests/Conformance/src/V1beta1/UploadMediaRequest.php new file mode 100644 index 000000000000..8937f1fedb0b --- /dev/null +++ b/Gax/tests/Conformance/src/V1beta1/UploadMediaRequest.php @@ -0,0 +1,58 @@ +google.showcase.v1beta1.UploadMediaRequest + */ +class UploadMediaRequest extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field string name = 1; + */ + protected $name = ''; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type string $name + * } + */ + public function __construct($data = NULL) { + \GPBMetadata\Google\Showcase\V1Beta1\ResumableUpload::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field string name = 1; + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Generated from protobuf field string name = 1; + * @param string $var + * @return $this + */ + public function setName($var) + { + GPBUtil::checkString($var, True); + $this->name = $var; + + return $this; + } + +} + diff --git a/Gax/tests/Conformance/src/V1beta1/UploadMediaResponse.php b/Gax/tests/Conformance/src/V1beta1/UploadMediaResponse.php new file mode 100644 index 000000000000..4197ff150b6b --- /dev/null +++ b/Gax/tests/Conformance/src/V1beta1/UploadMediaResponse.php @@ -0,0 +1,85 @@ +google.showcase.v1beta1.UploadMediaResponse + */ +class UploadMediaResponse extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field string name = 1; + */ + protected $name = ''; + /** + * Generated from protobuf field int64 size = 2; + */ + protected $size = 0; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type string $name + * @type int|string $size + * } + */ + public function __construct($data = NULL) { + \GPBMetadata\Google\Showcase\V1Beta1\ResumableUpload::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field string name = 1; + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Generated from protobuf field string name = 1; + * @param string $var + * @return $this + */ + public function setName($var) + { + GPBUtil::checkString($var, True); + $this->name = $var; + + return $this; + } + + /** + * Generated from protobuf field int64 size = 2; + * @return int|string + */ + public function getSize() + { + return $this->size; + } + + /** + * Generated from protobuf field int64 size = 2; + * @param int|string $var + * @return $this + */ + public function setSize($var) + { + GPBUtil::checkInt64($var); + $this->size = $var; + + return $this; + } + +} + diff --git a/Gax/tests/Conformance/src/V1beta1/gapic_metadata.json b/Gax/tests/Conformance/src/V1beta1/gapic_metadata.json index 2bedd20aaef0..bd2333066941 100644 --- a/Gax/tests/Conformance/src/V1beta1/gapic_metadata.json +++ b/Gax/tests/Conformance/src/V1beta1/gapic_metadata.json @@ -8,7 +8,7 @@ "Compliance": { "clients": { "grpc": { - "libraryClient": "ComplianceGapicClient", + "libraryClient": "ComplianceClient", "rpcs": { "GetEnum": { "methods": [ @@ -67,7 +67,7 @@ "Echo": { "clients": { "grpc": { - "libraryClient": "EchoGapicClient", + "libraryClient": "EchoClient", "rpcs": { "Block": { "methods": [ @@ -132,7 +132,7 @@ "Identity": { "clients": { "grpc": { - "libraryClient": "IdentityGapicClient", + "libraryClient": "IdentityClient", "rpcs": { "CreateUser": { "methods": [ @@ -166,7 +166,7 @@ "Messaging": { "clients": { "grpc": { - "libraryClient": "MessagingGapicClient", + "libraryClient": "MessagingClient", "rpcs": { "Connect": { "methods": [ @@ -242,10 +242,24 @@ } } }, + "ResumableUploadService": { + "clients": { + "grpc": { + "libraryClient": "ResumableUploadServiceClient", + "rpcs": { + "UploadMedia": { + "methods": [ + "uploadMedia" + ] + } + } + } + } + }, "SequenceService": { "clients": { "grpc": { - "libraryClient": "SequenceServiceGapicClient", + "libraryClient": "SequenceServiceClient", "rpcs": { "AttemptSequence": { "methods": [ @@ -284,7 +298,7 @@ "Testing": { "clients": { "grpc": { - "libraryClient": "TestingGapicClient", + "libraryClient": "TestingClient", "rpcs": { "CreateSession": { "methods": [ diff --git a/Gax/tests/Conformance/src/V1beta1/resources/resumable_upload_service_client_config.json b/Gax/tests/Conformance/src/V1beta1/resources/resumable_upload_service_client_config.json new file mode 100644 index 000000000000..1808811c6766 --- /dev/null +++ b/Gax/tests/Conformance/src/V1beta1/resources/resumable_upload_service_client_config.json @@ -0,0 +1,37 @@ +{ + "interfaces": { + "google.showcase.v1beta1.ResumableUploadService": { + "retry_codes": { + "no_retry_codes": [], + "no_retry_1_codes": [] + }, + "retry_params": { + "no_retry_params": { + "initial_retry_delay_millis": 0, + "retry_delay_multiplier": 0.0, + "max_retry_delay_millis": 0, + "initial_rpc_timeout_millis": 0, + "rpc_timeout_multiplier": 1.0, + "max_rpc_timeout_millis": 0, + "total_timeout_millis": 0 + }, + "no_retry_1_params": { + "initial_retry_delay_millis": 0, + "retry_delay_multiplier": 0.0, + "max_retry_delay_millis": 0, + "initial_rpc_timeout_millis": 5000, + "rpc_timeout_multiplier": 1.0, + "max_rpc_timeout_millis": 5000, + "total_timeout_millis": 5000 + } + }, + "methods": { + "UploadMedia": { + "timeout_millis": 5000, + "retry_codes_name": "no_retry_1_codes", + "retry_params_name": "no_retry_1_params" + } + } + } + } +} diff --git a/Gax/tests/Conformance/src/V1beta1/resources/resumable_upload_service_descriptor_config.php b/Gax/tests/Conformance/src/V1beta1/resources/resumable_upload_service_descriptor_config.php new file mode 100644 index 000000000000..58b0b3fd0922 --- /dev/null +++ b/Gax/tests/Conformance/src/V1beta1/resources/resumable_upload_service_descriptor_config.php @@ -0,0 +1,32 @@ + [ + 'google.showcase.v1beta1.ResumableUploadService' => [ + 'UploadMedia' => [ + 'callType' => \Google\ApiCore\Call::RESUMABLE_UPLOAD_CALL, + 'responseType' => 'Google\Showcase\V1beta1\UploadMediaResponse', + ], + ], + ], +]; diff --git a/Gax/tests/Conformance/src/V1beta1/resources/resumable_upload_service_rest_client_config.php b/Gax/tests/Conformance/src/V1beta1/resources/resumable_upload_service_rest_client_config.php new file mode 100644 index 000000000000..0225bffdc1e0 --- /dev/null +++ b/Gax/tests/Conformance/src/V1beta1/resources/resumable_upload_service_rest_client_config.php @@ -0,0 +1,34 @@ + [ + 'google.showcase.v1beta1.ResumableUploadService' => [ + 'UploadMedia' => [ + 'method' => 'post', + 'uriTemplate' => '/v1beta1/files:upload', + 'body' => '*', + ], + ], + ], + 'numericEnums' => true, +]; diff --git a/Gax/tests/Unit/GapicClientTraitTest.php b/Gax/tests/Unit/GapicClientTraitTest.php index 7c0df631ed16..845e06fd5d93 100644 --- a/Gax/tests/Unit/GapicClientTraitTest.php +++ b/Gax/tests/Unit/GapicClientTraitTest.php @@ -706,6 +706,15 @@ public function startAsyncCallExceptions() ], 'not supported for async execution' ], + [ + [ + 'Method' => [ + 'callType' => Call::RESUMABLE_UPLOAD_CALL, + 'responseType' => 'Google\Longrunning\Operation' + ] + ], + 'not supported for async execution' + ], ]; } diff --git a/Gax/tests/Unit/ResumableUpload/ResumableUploadClientTest.php b/Gax/tests/Unit/ResumableUpload/ResumableUploadClientTest.php new file mode 100644 index 000000000000..3c8325eef06f --- /dev/null +++ b/Gax/tests/Unit/ResumableUpload/ResumableUploadClientTest.php @@ -0,0 +1,621 @@ +httpHandler)($request, $options); + } + public function buildRequest( + string $method, + ?Message $message = null, + array $headers = [] + ): RequestInterface { + return $this->requestBuilder->build($method, $message, $headers); + } + }; + } + + public function testClientCreationAndInitialization() + { + $httpHandler = function () { + }; + $credentialsWrapper = $this->prophesize(CredentialsWrapper::class)->reveal(); + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class)->reveal(); + $client = new ResumableUploadClient( + $this->createStubTransport($requestBuilder, $httpHandler), + $credentialsWrapper + ); + + $ref = new \ReflectionClass($client); + $transportProp = $ref->getProperty('transport'); + $this->assertInstanceOf(ResumableUploadTransportInterface::class, $transportProp->getValue($client)); + $this->assertSame($credentialsWrapper, $ref->getProperty('credentialsWrapper')->getValue($client)); + + $call = new Call('v1/test:create', Timestamp::class, new Timestamp(), [], Call::RESUMABLE_UPLOAD_CALL); + $upload = new ResumableUpload($client, $call); + $this->assertInstanceOf(ResumableUpload::class, $upload); + + $uploadRef = new \ReflectionClass($upload); + $this->assertSame($client, $uploadRef->getProperty('resumableUploadClient')->getValue($upload)); + } + + public function testStartUploadRendersWildcardsUsingRequestBuilder() + { + $requests = []; + $httpHandler = function ($request, $options = []) use (&$requests) { + $requests[] = $request; + if (count($requests) === 1) { + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'active', + 'X-Goog-Upload-URL' => 'https://upload.url/123' + ])); + } + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'final' + ], '"1970-01-01T00:00:00Z"')); + }; + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class); + $message = new Timestamp(); + $requestBuilder->build(Argument::any(), Argument::any(), Argument::any()) + ->shouldBeCalledOnce() + ->willReturn(new \GuzzleHttp\Psr7\Request( + 'POST', + 'https://test.googleapis.com/v24/customers/12345/youTubeVideoUploads:create' + )); + + $client = new ResumableUploadClient( + $this->createStubTransport($requestBuilder->reveal(), $httpHandler), + $this->prophesize(CredentialsWrapper::class)->reveal(), + [], + '/resumable/upload' + ); + + $call = new Call( + 'Google.Cloud.Example.V1.ExampleService/CreateYouTubeVideoUpload', + Timestamp::class, + $message, + [], + Call::RESUMABLE_UPLOAD_CALL + ); + $upload = new ResumableUpload($client, $call); + $stream = Utils::streamFor('hello'); + $upload->startUpload($stream); + + $this->assertCount(2, $requests); + $this->assertSame( + 'https://test.googleapis.com/resumable/upload/v24/customers/12345/youTubeVideoUploads:create', + (string) $requests[0]->getUri() + ); + } + + public function testStartUploadWithoutRequestMessageThrowsException() + { + $this->expectException(\Google\ApiCore\ValidationException::class); + $this->expectExceptionMessage('A Call with request message is required when starting a new resumable upload.'); + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class)->reveal(); + $httpHandler = function () { + }; + $client = new ResumableUploadClient( + $this->createStubTransport($requestBuilder, $httpHandler), + $this->prophesize(CredentialsWrapper::class)->reveal() + ); + $call = new Call('v1/test:create', Timestamp::class, null, [], Call::RESUMABLE_UPLOAD_CALL); + $upload = new ResumableUpload($client, $call); + $client->startUpload($upload, Utils::streamFor('hello'), $call); + } + + public function testStartUploadWithCredentialsWrapperAddsAuthorizationHeaders() + { + $requests = []; + $httpHandler = function ($request, $options = []) use (&$requests) { + $requests[] = $request; + if (count($requests) === 1) { + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'active', + 'X-Goog-Upload-URL' => 'https://upload.url/123' + ])); + } + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'final' + ], '"1970-01-01T00:00:00Z"')); + }; + + $credentialsWrapper = $this->prophesize(CredentialsWrapper::class); + $credentialsWrapper->getAuthorizationHeaderCallback(Argument::any()) + ->willReturn(function () { + return ['authorization' => ['Bearer test-token-123']]; + }); + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class); + $message = new Timestamp(); + $requestBuilder->build(Argument::any(), Argument::any(), Argument::any()) + ->shouldBeCalledOnce() + ->willReturn(new \GuzzleHttp\Psr7\Request( + 'POST', + 'https://test.googleapis.com/v24/customers/12345/youTubeVideoUploads:create' + )); + + $client = new ResumableUploadClient( + $this->createStubTransport($requestBuilder->reveal(), $httpHandler), + $credentialsWrapper->reveal(), + [], + '/resumable/upload' + ); + + $call = new Call( + 'Google.Cloud.Example.V1.ExampleService/CreateYouTubeVideoUpload', + Timestamp::class, + $message, + [], + Call::RESUMABLE_UPLOAD_CALL + ); + $upload = new ResumableUpload($client, $call); + $stream = Utils::streamFor('hello'); + $upload->startUpload($stream); + + $this->assertCount(2, $requests); + $this->assertSame('Bearer test-token-123', $requests[0]->getHeaderLine('authorization')); + $this->assertSame('Bearer test-token-123', $requests[1]->getHeaderLine('authorization')); + } + + public function testStartUploadWithTimeoutMillisFirstCallOnly() + { + $capturedOptions = []; + $httpHandler = function ($request, $options = []) use (&$capturedOptions) { + $capturedOptions[] = $options; + if (count($capturedOptions) === 1) { + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'active', + 'X-Goog-Upload-URL' => 'https://upload.url/123' + ])); + } + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'final' + ], '"1970-01-01T00:00:00Z"')); + }; + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class); + $requestBuilder->build(Argument::any(), Argument::any(), Argument::any()) + ->willReturn(new \GuzzleHttp\Psr7\Request('POST', 'https://test.googleapis.com/test')); + $client = new ResumableUploadClient( + $this->createStubTransport($requestBuilder->reveal(), $httpHandler), + $this->prophesize(CredentialsWrapper::class)->reveal() + ); + + $call = new Call('test.method', Timestamp::class, new Timestamp(), [], Call::RESUMABLE_UPLOAD_CALL); + $upload = new ResumableUpload($client, $call, [ + 'timeoutMillis' => 5000 + ]); + $upload->startUpload(Utils::streamFor('hello')); + + $this->assertCount(2, $capturedOptions); + $this->assertSame(5, $capturedOptions[0]['timeout']); + $this->assertArrayNotHasKey('timeout', $capturedOptions[1]); + } + + public function testStartUploadWithRetrySettingsFirstCallOnly() + { + $capturedOptions = []; + $httpHandler = function ($request, $options = []) use (&$capturedOptions) { + $capturedOptions[] = $options; + if (count($capturedOptions) === 1) { + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'active', + 'X-Goog-Upload-URL' => 'https://upload.url/123' + ])); + } + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'final' + ], '"1970-01-01T00:00:00Z"')); + }; + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class); + $requestBuilder->build(Argument::any(), Argument::any(), Argument::any()) + ->willReturn(new \GuzzleHttp\Psr7\Request('POST', 'https://test.googleapis.com/test')); + $client = new ResumableUploadClient( + $this->createStubTransport($requestBuilder->reveal(), $httpHandler), + $this->prophesize(CredentialsWrapper::class)->reveal() + ); + + $retrySettings = ['maxRetries' => 3]; + $call = new Call('test.method', Timestamp::class, new Timestamp(), [], Call::RESUMABLE_UPLOAD_CALL); + $upload = new ResumableUpload($client, $call, [ + 'retrySettings' => $retrySettings + ]); + $upload->startUpload(Utils::streamFor('hello')); + + $this->assertCount(2, $capturedOptions); + $this->assertInstanceOf(RetrySettings::class, $capturedOptions[0]['retrySettings'] ?? null); + $this->assertSame(3, $capturedOptions[0]['retrySettings']->getMaxRetries()); + $this->assertArrayNotHasKey('retrySettings', $capturedOptions[1]); + } + + public function testStartUploadWithRetrySettingsRetriesInitialRequest() + { + $requests = []; + $httpHandler = function ($request, $options = []) use (&$requests) { + $requests[] = $request; + if (count($requests) === 1) { + // Return a 503 to trigger RetryMiddleware on the initial start call + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(503)); + } + if (count($requests) === 2) { + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'active', + 'X-Goog-Upload-URL' => 'https://upload.url/123' + ])); + } + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'final' + ], '"1970-01-01T00:00:00Z"')); + }; + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class); + $requestBuilder->build(Argument::any(), Argument::any(), Argument::any()) + ->willReturn(new \GuzzleHttp\Psr7\Request('POST', 'https://test.googleapis.com/test')); + $client = new ResumableUploadClient( + $this->createStubTransport($requestBuilder->reveal(), $httpHandler), + $this->prophesize(CredentialsWrapper::class)->reveal() + ); + + $call = new Call('test.method', Timestamp::class, new Timestamp(), [], Call::RESUMABLE_UPLOAD_CALL); + $upload = new ResumableUpload($client, $call, [ + 'retrySettings' => [ + 'maxRetries' => 2, + 'initialRetryDelayMillis' => 1, + 'maxRetryDelayMillis' => 1, + 'retryableCodes' => [\Google\ApiCore\ApiStatus::UNAVAILABLE] + ] + ]); + $upload->startUpload(Utils::streamFor('hello')); + + $this->assertCount(3, $requests); + } + + public function testStartUploadWithTotalTimeoutMillisExceeded() + { + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Resumable upload total timeout exceeded.'); + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class)->reveal(); + $client = new ResumableUploadClient($this->createStubTransport($requestBuilder, function () { + }), $this->prophesize(CredentialsWrapper::class)->reveal()); + + $call = new Call('test.method', Timestamp::class, new Timestamp(), [], Call::RESUMABLE_UPLOAD_CALL); + $upload = new ResumableUpload($client, $call); + $upload->startUpload(Utils::streamFor('hello'), [ + 'totalTimeoutMillis' => -10000 // expired timeout in ms + ]); + } + + public function testStartUploadWithCustomHeaders() + { + $requests = []; + $httpHandler = function ($request, $options = []) use (&$requests) { + $requests[] = $request; + if (count($requests) === 1) { + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'active', + 'X-Goog-Upload-URL' => 'https://upload.url/123' + ])); + } + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'final' + ], '"1970-01-01T00:00:00Z"')); + }; + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class); + $requestBuilder->build(Argument::any(), Argument::any(), Argument::any())->will(function ($args) { + $path = $args[0]; + $headers = $args[2] ?? []; + return new \GuzzleHttp\Psr7\Request('POST', 'https://test.googleapis.com/' . $path, $headers); + }); + $client = new ResumableUploadClient( + $this->createStubTransport($requestBuilder->reveal(), $httpHandler), + $this->prophesize(CredentialsWrapper::class)->reveal() + ); + + $call = new Call('test.method', Timestamp::class, new Timestamp(), [], Call::RESUMABLE_UPLOAD_CALL); + $upload = new ResumableUpload($client, $call, [ + 'headers' => ['X-Custom-Header' => 'custom-value'] + ]); + $upload->startUpload(Utils::streamFor('hello')); + + $this->assertCount(2, $requests); + $this->assertSame('custom-value', $requests[0]->getHeaderLine('X-Custom-Header')); + $this->assertSame('', $requests[1]->getHeaderLine('X-Custom-Header')); + } + + public function testStartUploadSendsConstructorHeadersOnInitialRequestOnly() + { + $requests = []; + $httpHandler = function ($request, $options = []) use (&$requests) { + $requests[] = $request; + if (count($requests) === 1) { + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'active', + 'X-Goog-Upload-URL' => 'https://upload.url/123' + ])); + } + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'final' + ], '"1970-01-01T00:00:00Z"')); + }; + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class); + $requestBuilder->build(Argument::any(), Argument::any(), Argument::any())->will(function ($args) { + $path = $args[0]; + $headers = $args[2] ?? []; + return new \GuzzleHttp\Psr7\Request('POST', 'https://test.googleapis.com/' . $path, $headers); + }); + $client = new ResumableUploadClient( + $this->createStubTransport($requestBuilder->reveal(), $httpHandler), + $this->prophesize(CredentialsWrapper::class)->reveal(), + headers: ['x-goog-api-client' => 'test-agent/1.0'] + ); + + $call = new Call('test.method', Timestamp::class, new Timestamp(), [], Call::RESUMABLE_UPLOAD_CALL); + $upload = new ResumableUpload($client, $call); + $upload->startUpload(Utils::streamFor('hello')); + + $this->assertCount(2, $requests); + $this->assertSame('test-agent/1.0', $requests[0]->getHeaderLine('x-goog-api-client')); + $this->assertSame('', $requests[1]->getHeaderLine('x-goog-api-client')); + } + + public function testStartUploadRecoversFromPreviousBufferWithoutSeeking() + { + $requests = []; + $httpHandler = function ($request, $options = []) use (&$requests) { + $requests[] = $request; + if (count($requests) === 1) { + // Request 0: start + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'active', + 'X-Goog-Upload-URL' => 'https://upload.url/123', + 'X-Goog-Upload-Chunk-Granularity' => '1' + ])); + } + if (count($requests) === 2) { + // Request 1: upload first chunk (11 bytes) + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'active' + ])); + } + if (count($requests) === 3) { + // Request 2: upload second chunk fails with 308 Resume Incomplete + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(308)); + } + if (count($requests) === 4) { + // Request 3: query recovery offset returns 5 (within previous chunk buffer [0..11]) + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'active', + 'X-Goog-Upload-Size-Received' => '5' + ])); + } + // Request 4: re-upload from offset 5 (`t-chunksecond-chunk`) + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'final' + ], '"1970-01-01T00:00:00Z"')); + }; + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class); + $requestBuilder->build(Argument::any(), Argument::any(), Argument::any()) + ->willReturn(new \GuzzleHttp\Psr7\Request('POST', 'https://test.googleapis.com/test')); + $client = new ResumableUploadClient( + $this->createStubTransport($requestBuilder->reveal(), $httpHandler), + $this->prophesize(CredentialsWrapper::class)->reveal() + ); + + $call = new Call('test.method', Timestamp::class, new Timestamp(), [], Call::RESUMABLE_UPLOAD_CALL); + $upload = new ResumableUpload($client, $call, [ + 'chunkSize' => 11 + ]); + + // Mock an unseekable stream using Prophecy to verify recovery happens in memory without seeking + $unseekableStream = $this->prophesize(StreamInterface::class); + $unseekableStream->seek(Argument::any())->shouldNotBeCalled(); + $unseekableStream->isSeekable()->willReturn(false); + $unseekableStream->getSize()->willReturn(null); + $unseekableStream->tell()->willReturn(11); + $unseekableStream->read(Argument::any())->willReturn('first-chunk', 'second-chun'); + $unseekableStream->eof()->willReturn(false); + + $result = $upload->startUpload($unseekableStream->reveal()); + + $this->assertInstanceOf(Timestamp::class, $result); + $this->assertCount(5, $requests); + $this->assertSame('5', $requests[4]->getHeaderLine('X-Goog-Upload-Offset')); + $this->assertSame('-chunksecond-chun', (string) $requests[4]->getBody()); + } + + public function testStartUploadRecoveryThrowsExceptionForUnseekableStreamOutsideBuffer() + { + $this->expectException(\Google\ApiCore\ValidationException::class); + $this->expectExceptionMessage( + 'Cannot recover resumable upload: the server confirmed offset 100, which falls outside the buffered ' . + 'chunks, and the provided data stream is not seekable.' + ); + + $requests = []; + $httpHandler = function ($request, $options = []) use (&$requests) { + $requests[] = $request; + if (count($requests) === 1) { + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'active', + 'X-Goog-Upload-URL' => 'https://upload.url/123' + ])); + } + if (count($requests) === 2) { + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(308)); + } + // Server returns offset 100 which falls outside memory buffer + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'active', + 'X-Goog-Upload-Size-Received' => '100' + ])); + }; + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class); + $requestBuilder->build(Argument::any(), Argument::any(), Argument::any()) + ->willReturn(new \GuzzleHttp\Psr7\Request('POST', 'https://test.googleapis.com/test')); + $client = new ResumableUploadClient( + $this->createStubTransport($requestBuilder->reveal(), $httpHandler), + $this->prophesize(CredentialsWrapper::class)->reveal() + ); + + $call = new Call('test.method', Timestamp::class, new Timestamp(), [], Call::RESUMABLE_UPLOAD_CALL); + $upload = new ResumableUpload($client, $call, ['chunkSize' => 10]); + + $unseekableStream = $this->prophesize(StreamInterface::class); + $unseekableStream->seek(Argument::any())->shouldNotBeCalled(); + $unseekableStream->isSeekable()->willReturn(false); + $unseekableStream->getSize()->willReturn(null); + $unseekableStream->tell()->willReturn(10); + $unseekableStream->read(Argument::any())->willReturn('first-chun', 'second-chu'); + $unseekableStream->eof()->willReturn(false); + + $upload->startUpload($unseekableStream->reveal()); + } + + public function testStartUploadWithoutCredentialsThrowsTypeError() + { + $this->expectException(\TypeError::class); + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class)->reveal(); + $httpHandler = function () { + }; + + new ResumableUploadClient($requestBuilder, $httpHandler, null); + } + + public function testStartUploadInitialFailureThrowsOriginalExceptionWithoutQueryingEmptyUrl() + { + $this->expectException(ApiException::class); + $this->expectExceptionMessage('HTTP error 400'); + + $requests = []; + $httpHandler = function ($request, $options = []) use (&$requests) { + $requests[] = $request; + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(400, [], 'Bad Request')); + }; + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class); + $requestBuilder->build(Argument::any(), Argument::any(), Argument::any())->willReturn( + new \GuzzleHttp\Psr7\Request('POST', 'https://test.googleapis.com/test') + ); + $client = new ResumableUploadClient( + $this->createStubTransport($requestBuilder->reveal(), $httpHandler), + $this->prophesize(CredentialsWrapper::class)->reveal() + ); + + $call = new Call('test.method', Timestamp::class, new Timestamp(), [], Call::RESUMABLE_UPLOAD_CALL); + $upload = new ResumableUpload($client, $call); + + try { + $upload->startUpload(Utils::streamFor('hello')); + } finally { + $this->assertCount(1, $requests); + } + } + + public function testHeadersSentOnlyOnInitialStartRequest() + { + $requests = []; + $httpHandler = function ($request, $options = []) use (&$requests) { + $requests[] = $request; + if (count($requests) === 1) { + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'active', + 'X-Goog-Upload-URL' => 'https://upload.url/123' + ])); + } + return \GuzzleHttp\Promise\Create::promiseFor(new \GuzzleHttp\Psr7\Response(200, [ + 'X-Goog-Upload-Status' => 'final' + ], '"1970-01-01T00:00:00Z"')); + }; + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class); + $requestBuilder->build(Argument::any(), Argument::any(), Argument::any())->will(function ($args) { + $headers = $args[2] ?? []; + return new \GuzzleHttp\Psr7\Request('POST', 'https://test.googleapis.com/' . $args[0], $headers); + }); + + $client = new ResumableUploadClient( + $this->createStubTransport($requestBuilder->reveal(), $httpHandler), + $this->prophesize(CredentialsWrapper::class)->reveal() + ); + + $call = new Call('test.method', Timestamp::class, new Timestamp(), [], Call::RESUMABLE_UPLOAD_CALL); + $upload = new ResumableUpload($client, $call); + + $client->startUpload($upload, Utils::streamFor('hello'), $call, [ + 'headers' => ['X-Initial-Custom-Header' => 'secret-value'] + ]); + + $this->assertCount(2, $requests); + $this->assertEquals('start', $requests[0]->getHeaderLine('X-Goog-Upload-Command')); + $this->assertEquals('secret-value', $requests[0]->getHeaderLine('X-Initial-Custom-Header')); + $this->assertEquals('upload, finalize', $requests[1]->getHeaderLine('X-Goog-Upload-Command')); + $this->assertEquals('', $requests[1]->getHeaderLine('X-Initial-Custom-Header')); + } +} diff --git a/Gax/tests/Unit/ResumableUpload/ResumableUploadStateTest.php b/Gax/tests/Unit/ResumableUpload/ResumableUploadStateTest.php new file mode 100644 index 000000000000..33cf829006ba --- /dev/null +++ b/Gax/tests/Unit/ResumableUpload/ResumableUploadStateTest.php @@ -0,0 +1,243 @@ +prepareBuffer($stream); + + $this->assertSame('hello worl', $state->buffer); + $this->assertFalse($state->isEof); + } + + public function testPrepareBufferNoopWhenBufferIsNotNull() + { + $state = new ResumableUploadState( + 10, + null, + null, + 'starting' + ); + $state->buffer = 'existing data'; + + $stream = Utils::streamFor('new data'); + $state->prepareBuffer($stream); + + $this->assertSame('existing data', $state->buffer); + } + + public function testPrepareBufferRoundsToChunkGranularity() + { + $state = new ResumableUploadState( + 300000, + null, + null, + 'starting' + ); + $state->chunkGranularity = 262144; // 256KB granularity + + $stream = Utils::streamFor(str_repeat('a', 400000)); + $state->prepareBuffer($stream); + + $this->assertSame(262144, strlen($state->buffer)); + } + + public function testPrepareBufferSeeksStreamWhenPositionDiffersFromCommittedOffset() + { + $state = new ResumableUploadState( + 5, + null, + null, + 'starting' + ); + $state->committedOffset = 3; + + $stream = Utils::streamFor('abcdefghij'); + $state->prepareBuffer($stream); + + $this->assertSame('defgh', $state->buffer); + } + + public function testPrepareBufferThrowsValidationExceptionWhenStreamNotSeekableAndPositionDiffers() + { + $state = new ResumableUploadState( + 5, + null, + null, + 'starting' + ); + $state->committedOffset = 3; + + $stream = $this->createMock(StreamInterface::class); + $stream->method('tell')->willReturn(0); + $stream->method('isSeekable')->willReturn(false); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Cannot read from stream at offset 3'); + $state->prepareBuffer($stream); + } + + public function testCommitBufferUpdatesOffsetsAndClearsBuffer() + { + $state = new ResumableUploadState( + 10, + null, + null, + 'transmitting' + ); + $state->committedOffset = 5; + $state->buffer = '12345'; + + $state->commitBuffer(); + + $this->assertSame('12345', $state->previousBuffer); + $this->assertSame(5, $state->previousOffset); + $this->assertSame(10, $state->committedOffset); + $this->assertNull($state->buffer); + } + + public function testReconcileRecoveryOffsetSlicesCurrentBuffer() + { + $state = new ResumableUploadState( + 10, + null, + 'https://upload.url', + 'recovery' + ); + $state->committedOffset = 10; + $state->buffer = 'abcdefghij'; // bytes 10-19 + + $stream = Utils::streamFor(str_repeat('x', 30)); + $state->reconcileRecoveryOffset(14, $stream, 3); + + $this->assertSame('efghij', $state->buffer); + $this->assertSame(14, $state->committedOffset); + $this->assertSame(0, $state->recoveryAttempts); + $this->assertSame(14, $state->lastRecoveryOffset); + } + + public function testReconcileRecoveryOffsetSlicesPreviousAndCurrentBuffer() + { + $state = new ResumableUploadState( + 10, + null, + 'https://upload.url', + 'recovery' + ); + $state->previousOffset = 10; + $state->previousBuffer = 'abcde'; // bytes 10-14 + $state->committedOffset = 15; + $state->buffer = 'fghij'; // bytes 15-19 + + $stream = Utils::streamFor(str_repeat('x', 30)); + $state->reconcileRecoveryOffset(12, $stream, 3); + + $this->assertSame('cdefghij', $state->buffer); + $this->assertSame(12, $state->committedOffset); + } + + public function testReconcileRecoveryOffsetSeeksStreamWhenOutsideBuffers() + { + $state = new ResumableUploadState( + 10, + null, + 'https://upload.url', + 'recovery' + ); + $state->committedOffset = 20; + $state->buffer = 'abcde'; // bytes 20-24 + + $stream = Utils::streamFor(str_repeat('x', 100)); + $state->reconcileRecoveryOffset(50, $stream, 3); + + $this->assertNull($state->buffer); + $this->assertSame(50, $state->committedOffset); + $this->assertSame(50, $stream->tell()); + } + + public function testReconcileRecoveryOffsetThrowsValidationExceptionWhenStreamNotSeekableAndOutsideBuffers() + { + $state = new ResumableUploadState( + 10, + null, + 'https://upload.url', + 'recovery' + ); + $state->committedOffset = 20; + $state->buffer = 'abcde'; + + $stream = $this->createMock(StreamInterface::class); + $stream->method('isSeekable')->willReturn(false); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Cannot recover resumable upload: the server confirmed offset 50'); + $state->reconcileRecoveryOffset(50, $stream, 3); + } + + public function testReconcileRecoveryOffsetExhaustsAttemptsWhenOffsetUnchanged() + { + $state = new ResumableUploadState( + 10, + null, + 'https://upload.url', + 'recovery' + ); + $state->committedOffset = 10; + $state->buffer = 'abcde'; + $state->lastRecoveryOffset = 10; + $state->recoveryAttempts = 2; + + $stream = Utils::streamFor(str_repeat('x', 30)); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Exhausted recovery attempts with unchanged offset'); + $state->reconcileRecoveryOffset(10, $stream, 3); + } +} diff --git a/Gax/tests/Unit/ResumableUpload/ResumableUploadTest.php b/Gax/tests/Unit/ResumableUpload/ResumableUploadTest.php new file mode 100644 index 000000000000..5153cf8fc8d3 --- /dev/null +++ b/Gax/tests/Unit/ResumableUpload/ResumableUploadTest.php @@ -0,0 +1,265 @@ +httpHandler)($request, $options); + } + public function buildRequest( + string $method, + ?Message $message = null, + array $headers = [] + ): RequestInterface { + return $this->requestBuilder->build($method, $message, $headers); + } + }; + } + + public function testInitializationAndReflection() + { + $httpHandler = function () { + }; + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class)->reveal(); + $client = new ResumableUploadClient( + $this->createStubTransport($requestBuilder, $httpHandler), + $this->prophesize(CredentialsWrapper::class)->reveal() + ); + + $call = new Call('v1/test:create', Timestamp::class, new Timestamp(), [], Call::RESUMABLE_UPLOAD_CALL); + $upload = new ResumableUpload($client, $call, [ + 'chunkSize' => 1024, + 'progressCallback' => function (int $bytes) { + } + ]); + + $ref = new \ReflectionClass($upload); + $clientProp = $ref->getProperty('resumableUploadClient'); + $this->assertSame($client, $clientProp->getValue($upload)); + + $callProp = $ref->getProperty('call'); + $this->assertSame($call, $callProp->getValue($upload)); + } + + public function testProgressCallbackReceivesUploadObject() + { + $httpHandler = $this->createMockHttpHandler([ + new Response(200, ['X-Goog-Upload-Status' => 'active', 'X-Goog-Upload-URL' => 'https://upload.url/123']), + new Response(200, ['X-Goog-Upload-Status' => 'final'], '"1970-01-01T00:00:00Z"') + ]); + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class); + $requestBuilder->build(Argument::any(), Argument::any(), Argument::any())->will(function ($args) { + $path = $args[0]; + $headers = $args[2] ?? []; + return new \GuzzleHttp\Psr7\Request('POST', 'https://test.googleapis.com/' . $path, $headers); + }); + $client = new ResumableUploadClient( + $this->createStubTransport($requestBuilder->reveal(), $httpHandler), + $this->prophesize(CredentialsWrapper::class)->reveal() + ); + $callbackUpload = null; + $call = new \Google\ApiCore\Call( + 'v1/test:create', + Timestamp::class, + new Timestamp(), + [], + \Google\ApiCore\Call::RESUMABLE_UPLOAD_CALL + ); + $upload = new ResumableUpload($client, $call); + + $stream = Utils::streamFor('hello world'); + $upload->startUpload($stream, [ + 'progressCallback' => function (int $bytes, ResumableUpload $u) use (&$callbackUpload) { + $callbackUpload = $u; + } + ]); + + $this->assertSame($upload, $callbackUpload); + $this->assertSame('https://upload.url/123', $callbackUpload->getUploadUrl()); + } + + public function testStartUploadDelegation() + { + $requests = []; + $httpHandler = $this->createMockHttpHandler([ + new Response(200, ['X-Goog-Upload-Status' => 'active', 'X-Goog-Upload-URL' => 'https://upload.url/123']), + new Response(200, ['X-Goog-Upload-Status' => 'final'], '"1970-01-01T03:25:45Z"') + ], $requests); + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class); + $requestBuilder->build(Argument::any(), Argument::any(), Argument::any())->will(function ($args) { + $path = $args[0]; + $headers = $args[2] ?? []; + return new \GuzzleHttp\Psr7\Request('POST', 'https://test.googleapis.com/' . $path, $headers); + }); + $client = new ResumableUploadClient( + $this->createStubTransport($requestBuilder->reveal(), $httpHandler), + $this->prophesize(CredentialsWrapper::class)->reveal() + ); + $call = new \Google\ApiCore\Call( + 'v1/test:create', + Timestamp::class, + new Timestamp(), + [], + \Google\ApiCore\Call::RESUMABLE_UPLOAD_CALL + ); + $upload = new ResumableUpload($client, $call); + + $stream = Utils::streamFor('hello world'); + $result = $upload->startUpload($stream); + + $this->assertInstanceOf(Timestamp::class, $result); + $this->assertEquals(12345, $result->getSeconds()); + $this->assertCount(2, $requests); + $this->assertEquals('POST', $requests[0]->getMethod()); + $this->assertEquals('start', $requests[0]->getHeaderLine('X-Goog-Upload-Command')); + $this->assertEquals('upload, finalize', $requests[1]->getHeaderLine('X-Goog-Upload-Command')); + $this->assertEquals('hello world', (string) $requests[1]->getBody()); + $this->assertEquals('https://upload.url/123', $upload->getUploadUrl()); + } + + public function testCustomHeadersOnlySentOnInitialStartRequest() + { + $requests = []; + $httpHandler = $this->createMockHttpHandler([ + new Response(200, ['X-Goog-Upload-Status' => 'active', 'X-Goog-Upload-URL' => 'https://upload.url/123']), + new Response(200, ['X-Goog-Upload-Status' => 'final'], '"1970-01-01T03:25:45Z"') + ], $requests); + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class); + $requestBuilder->build(Argument::any(), Argument::any(), Argument::any())->will(function ($args) { + $path = $args[0]; + $headers = $args[2] ?? []; + return new \GuzzleHttp\Psr7\Request('POST', 'https://test.googleapis.com/' . $path, $headers); + }); + $client = new ResumableUploadClient( + $this->createStubTransport($requestBuilder->reveal(), $httpHandler), + $this->prophesize(CredentialsWrapper::class)->reveal() + ); + $call = new \Google\ApiCore\Call( + 'v1/test:create', + Timestamp::class, + new Timestamp(), + [], + \Google\ApiCore\Call::RESUMABLE_UPLOAD_CALL + ); + $upload = new ResumableUpload($client, $call, [ + 'headers' => ['X-Custom-Start-Header' => 'initial-only-value'] + ]); + + $stream = Utils::streamFor('hello world'); + $upload->startUpload($stream); + + $this->assertCount(2, $requests); + $this->assertEquals('start', $requests[0]->getHeaderLine('X-Goog-Upload-Command')); + $this->assertEquals('initial-only-value', $requests[0]->getHeaderLine('X-Custom-Start-Header')); + $this->assertEquals('upload, finalize', $requests[1]->getHeaderLine('X-Goog-Upload-Command')); + $this->assertEquals('', $requests[1]->getHeaderLine('X-Custom-Start-Header')); + } + + public function testUploadUrlTrackingAndResume() + { + $requests = []; + $httpHandler = $this->createMockHttpHandler([ + new Response(200, ['X-Goog-Upload-Status' => 'active', 'X-Goog-Upload-Size-Received' => '5']), + new Response(200, ['X-Goog-Upload-Status' => 'final'], '"1970-01-01T00:16:39Z"') + ], $requests); + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class)->reveal(); + $client = new ResumableUploadClient( + $this->createStubTransport($requestBuilder, $httpHandler), + $this->prophesize(CredentialsWrapper::class)->reveal() + ); + $call = new Call('test.method', Timestamp::class, null, [], Call::RESUMABLE_UPLOAD_CALL); + $upload = new ResumableUpload($client, $call, [], 'https://upload.url/session123'); + $this->assertEquals('https://upload.url/session123', $upload->getUploadUrl()); + + $stream = Utils::streamFor('hello world'); + $result = $upload->startUpload($stream); + + $this->assertInstanceOf(Timestamp::class, $result); + $this->assertEquals(999, $result->getSeconds()); + $this->assertCount(2, $requests); + $this->assertEquals('query', $requests[0]->getHeaderLine('X-Goog-Upload-Command')); + $this->assertEquals('upload, finalize', $requests[1]->getHeaderLine('X-Goog-Upload-Command')); + $this->assertEquals(' world', (string) $requests[1]->getBody()); + } + + public function testInvalidInitializationThrowsException() + { + $this->expectException(\TypeError::class); + + $requestBuilder = $this->prophesize(\Google\ApiCore\RequestBuilder::class)->reveal(); + $client = new ResumableUploadClient($this->createStubTransport($requestBuilder, function () { + }), $this->prophesize(CredentialsWrapper::class)->reveal()); + new ResumableUpload($client, null); + } + + private function createMockHttpHandler(array $responses, ?array &$requests = []): callable + { + return function ($request, $options = []) use (&$responses, &$requests) { + $requests[] = $request; + $response = array_shift($responses); + if ($response instanceof \Exception) { + return Create::rejectionFor($response); + } + return Create::promiseFor($response); + }; + } +} diff --git a/Gax/tests/Unit/ResumableUpload/ResumableUploadTraitTest.php b/Gax/tests/Unit/ResumableUpload/ResumableUploadTraitTest.php new file mode 100644 index 000000000000..8b46027275ce --- /dev/null +++ b/Gax/tests/Unit/ResumableUpload/ResumableUploadTraitTest.php @@ -0,0 +1,112 @@ +prophesize(CredentialsWrapper::class)->reveal(); + + $client = new class($credentialsWrapper, ['httpHandler' => $httpHandler]) { + use GapicClientTrait; + use ResumableUploadTrait; + + public function setDescriptors(array $descriptors): void + { + $this->descriptors = $descriptors; + } + + public function __construct(?CredentialsWrapper $credentialsWrapper, array $options) + { + $this->credentialsWrapper = $credentialsWrapper; + $options['transportConfig']['rest']['restClientConfigPath'] = __DIR__ + . '/../testdata/resources/test_service_rest_client_config.php'; + $this->resumableUploadClient = $this->createResumableUploadClient($options); + } + + public function getResumableUploadClient(): ResumableUploadClient + { + return $this->resumableUploadClient; + } + }; + $client->setDescriptors([ + 'CreateYouTubeVideoUpload' => [ + 'callType' => Call::RESUMABLE_UPLOAD_CALL, + 'responseType' => Timestamp::class, + ], + ]); + + $uploadClient = $client->getResumableUploadClient(); + $this->assertInstanceOf(ResumableUploadClient::class, $uploadClient); + $clientRef = new \ReflectionClass($uploadClient); + $transport = $clientRef->getProperty('transport')->getValue($uploadClient); + $this->assertInstanceOf(ResumableUploadTransportInterface::class, $transport); + $this->assertSame( + $credentialsWrapper, + $clientRef->getProperty('credentialsWrapper')->getValue($uploadClient) + ); + + $resumed = $client->resumeUpload( + 'https://upload.url/session123', + 'createYouTubeVideoUpload', + ['timeoutMillis' => 5000] + ); + $this->assertInstanceOf(ResumableUpload::class, $resumed); + + $ref = new \ReflectionClass($resumed); + $clientProp = $ref->getProperty('resumableUploadClient'); + $this->assertSame($client->getResumableUploadClient(), $clientProp->getValue($resumed)); + $optionsProp = $ref->getProperty('callOptions'); + $this->assertSame( + ['timeoutMillis' => 5000, 'uploadUrl' => 'https://upload.url/session123', 'headers' => []], + $optionsProp->getValue($resumed) + ); + $callProp = $ref->getProperty('call'); + $this->assertSame(Timestamp::class, $callProp->getValue($resumed)->getDecodeType()); + } +} diff --git a/Gax/tests/Unit/TestTrait.php b/Gax/tests/Unit/TestTrait.php index 04b2fd97acec..70ecd94e5a1a 100644 --- a/Gax/tests/Unit/TestTrait.php +++ b/Gax/tests/Unit/TestTrait.php @@ -138,9 +138,9 @@ private static function autoloadTestdata(string $dir, string $namespace = __NAME } // add mocks to autoloader - $loader = file_exists(__DIR__ . '/../../../vendor/autoload.php') - ? require __DIR__ . '/../../../vendor/autoload.php' - : require __DIR__ . '/../../vendor/autoload.php'; + $loader = file_exists(__DIR__ . '/../../vendor/autoload.php') + ? require __DIR__ . '/../../vendor/autoload.php' + : require __DIR__ . '/../../../vendor/autoload.php'; $loader->addPsr4($namespace . '\\', __DIR__ . '/testdata/' . $dir); } diff --git a/Gax/tests/Unit/Transport/RestTransportTest.php b/Gax/tests/Unit/Transport/RestTransportTest.php index b4daa40c6713..fb0de054d360 100644 --- a/Gax/tests/Unit/Transport/RestTransportTest.php +++ b/Gax/tests/Unit/Transport/RestTransportTest.php @@ -38,6 +38,7 @@ use Google\ApiCore\Call; use Google\ApiCore\CredentialsWrapper; use Google\ApiCore\RequestBuilder; +use Google\ApiCore\ResumableUpload\ResumableUploadTransportInterface; use Google\ApiCore\Testing\MockRequest; use Google\ApiCore\Testing\MockResponse; use Google\ApiCore\Tests\Unit\TestTrait; @@ -585,4 +586,99 @@ public function testNonArrayAuthorizationHeaderThrowsException() $this->getTransport() ->startUnaryCall($this->call, $options); } + + public function testImplementsResumableUploadTransportInterface() + { + $transport = $this->getTransport(); + $this->assertInstanceOf(ResumableUploadTransportInterface::class, $transport); + } + + public function testSendRawRequest() + { + $expectedRequest = new Request('POST', 'http://www.example.com/resumable/upload', ['foo' => 'bar'], 'body'); + $expectedOptions = ['timeout' => 30]; + $expectedResponse = new Response(200, ['header' => 'val'], 'response body'); + + $httpHandler = function ( + RequestInterface $request, + array $options = [] + ) use ( + $expectedRequest, + $expectedOptions, + $expectedResponse + ) { + $this->assertSame($expectedRequest, $request); + $this->assertEquals($expectedOptions, $options); + return $expectedResponse; + }; + + $transport = $this->getTransport($httpHandler); + $response = $transport->sendRawRequest($expectedRequest, $expectedOptions); + + $this->assertSame($expectedResponse, $response); + } + + public function testSendRawRequestWithDefaultOptions() + { + $expectedRequest = new Request('GET', 'http://www.example.com/status'); + $expectedResponse = new Response(200, [], 'ok'); + + $httpHandler = function ( + RequestInterface $request, + array $options = [] + ) use ( + $expectedRequest, + $expectedResponse + ) { + $this->assertSame($expectedRequest, $request); + $this->assertEquals([], $options); + return $expectedResponse; + }; + + $transport = $this->getTransport($httpHandler); + $response = $transport->sendRawRequest($expectedRequest); + + $this->assertSame($expectedResponse, $response); + } + + public function testBuildRequest() + { + $method = 'v1/test:create'; + $message = new MockRequest(); + $headers = ['custom-header' => ['value1']]; + $expectedRequest = new Request('POST', 'http://www.example.com/v1/test:create', $headers); + + $requestBuilder = $this->prophesize(RequestBuilder::class); + $requestBuilder->build($method, $message, $headers) + ->shouldBeCalledOnce() + ->willReturn($expectedRequest); + + $transport = new RestTransport( + $requestBuilder->reveal(), + HttpHandlerFactory::build() + ); + + $actualRequest = $transport->buildRequest($method, $message, $headers); + $this->assertSame($expectedRequest, $actualRequest); + } + + public function testBuildRequestWithDefaultHeaders() + { + $method = 'v1/test:get'; + $message = new MockRequest(); + $expectedRequest = new Request('GET', 'http://www.example.com/v1/test:get'); + + $requestBuilder = $this->prophesize(RequestBuilder::class); + $requestBuilder->build($method, $message, []) + ->shouldBeCalledOnce() + ->willReturn($expectedRequest); + + $transport = new RestTransport( + $requestBuilder->reveal(), + HttpHandlerFactory::build() + ); + + $actualRequest = $transport->buildRequest($method, $message); + $this->assertSame($expectedRequest, $actualRequest); + } }