2929 */
3030package com .google .api .gax .httpjson ;
3131
32+ import com .google .api .client .http .ByteArrayContent ;
33+ import com .google .api .client .http .EmptyContent ;
34+ import com .google .api .client .http .HttpContent ;
3235import com .google .api .client .http .HttpMethods ;
3336import com .google .api .core .ApiFuture ;
3437import com .google .api .core .InternalApi ;
3538import com .google .api .core .SettableApiFuture ;
39+ import com .google .api .gax .resumable .ChunkUploadRequest ;
40+ import com .google .api .gax .resumable .ChunkUploadResponse ;
3641import com .google .api .gax .resumable .ResumableUploadClient ;
3742import com .google .api .gax .resumable .ResumableUploadSession ;
3843import com .google .api .gax .resumable .StartUploadRequest ;
@@ -67,14 +72,20 @@ public final class HttpJsonResumableUploadClient implements ResumableUploadClien
6772
6873 private static final String UPLOAD_PROTOCOL_HEADER = "X-Goog-Upload-Protocol" ;
6974 private static final String UPLOAD_COMMAND_HEADER = "X-Goog-Upload-Command" ;
75+ private static final String UPLOAD_OFFSET_HEADER = "X-Goog-Upload-Offset" ;
7076 private static final String UPLOAD_URL_HEADER = "X-Goog-Upload-URL" ;
7177 private static final String UPLOAD_GRANULARITY_HEADER = "X-Goog-Upload-Chunk-Granularity" ;
78+ private static final String UPLOAD_STATUS_HEADER = "X-Goog-Upload-Status" ;
79+ private static final String UPLOAD_SIZE_RECEIVED_HEADER = "X-Goog-Upload-Size-Received" ;
80+ private static final String STATUS_FINAL = "final" ;
7281
7382 private static final Map <String , List <String >> START_UPLOAD_HEADERS =
7483 ImmutableMap .of (
7584 UPLOAD_PROTOCOL_HEADER , ImmutableList .of ("resumable" ),
7685 UPLOAD_COMMAND_HEADER , ImmutableList .of ("start" ));
7786
87+ private static final PathTemplate PATH_TEMPLATE = PathTemplate .create ("{+path}" );
88+
7889 private static final ApiMethodDescriptor <StartUploadRequest , String > START_UPLOAD_DESCRIPTOR =
7990 ApiMethodDescriptor .<StartUploadRequest , String >newBuilder ()
8091 .setFullMethodName ("ResumableUpload/StartUpload" )
@@ -99,7 +110,46 @@ public String getPath(StartUploadRequest request) {
99110
100111 @ Override
101112 public PathTemplate getPathTemplate () {
102- return PathTemplate .create ("{+path}" );
113+ return PATH_TEMPLATE ;
114+ }
115+ })
116+ .setResponseParser (StringHttpResponseParser .create ())
117+ .build ();
118+
119+ private static final ApiMethodDescriptor <ChunkUploadRequest , String > UPLOAD_CHUNK_DESCRIPTOR =
120+ ApiMethodDescriptor .<ChunkUploadRequest , String >newBuilder ()
121+ .setFullMethodName ("ResumableUpload/UploadChunk" )
122+ .setHttpMethod (HttpMethods .POST )
123+ .setType (ApiMethodDescriptor .MethodType .UNARY )
124+ .setRequestFormatter (
125+ new HttpRequestFormatter <ChunkUploadRequest >() {
126+ @ Override
127+ public Map <String , List <String >> getQueryParamNames (ChunkUploadRequest request ) {
128+ return Collections .emptyMap ();
129+ }
130+
131+ @ Override
132+ public String getRequestBody (ChunkUploadRequest request ) {
133+ return "" ;
134+ }
135+
136+ @ Override
137+ public HttpContent getHttpContent (ChunkUploadRequest request ) {
138+ if (!request .getPayload ().isEmpty ()) {
139+ return new ByteArrayContent (
140+ "application/octet-stream" , request .getPayload ().toByteArray ());
141+ }
142+ return new EmptyContent ();
143+ }
144+
145+ @ Override
146+ public String getPath (ChunkUploadRequest request ) {
147+ return request .getUploadUrl ();
148+ }
149+
150+ @ Override
151+ public PathTemplate getPathTemplate () {
152+ return PATH_TEMPLATE ;
103153 }
104154 })
105155 .setResponseParser (StringHttpResponseParser .create ())
@@ -141,6 +191,45 @@ public ApiFuture<ResumableUploadSession> futureCall(
141191 };
142192 }
143193
194+ @ Override
195+ public UnaryCallable <ChunkUploadRequest , ChunkUploadResponse > uploadChunkCallable () {
196+ return new UnaryCallable <ChunkUploadRequest , ChunkUploadResponse >() {
197+ @ Override
198+ public ApiFuture <ChunkUploadResponse > futureCall (
199+ ChunkUploadRequest request , @ Nullable ApiCallContext inputContext ) {
200+ Preconditions .checkNotNull (request );
201+ String command ;
202+ if (request .isFinal ()) {
203+ command = !request .getPayload ().isEmpty () ? "upload, finalize" : "finalize" ;
204+ } else {
205+ command = "upload" ;
206+ }
207+ Map <String , List <String >> chunkHeaders =
208+ ImmutableMap .of (
209+ UPLOAD_COMMAND_HEADER ,
210+ ImmutableList .of (command ),
211+ UPLOAD_OFFSET_HEADER ,
212+ ImmutableList .of (String .valueOf (request .getOffset ())));
213+
214+ HttpJsonCallContext context =
215+ (HttpJsonCallContext )
216+ HttpJsonCallContext .createDefault ()
217+ .nullToSelf (clientContext .getDefaultCallContext ())
218+ .merge (inputContext )
219+ .withExtraHeaders (chunkHeaders );
220+
221+ HttpJsonClientCall <ChunkUploadRequest , String > clientCall =
222+ HttpJsonClientCalls .newCall (UPLOAD_CHUNK_DESCRIPTOR , context );
223+
224+ SettableApiFuture <ChunkUploadResponse > future = SettableApiFuture .create ();
225+ HttpJsonClientCalls .startUnaryCall (
226+ clientCall , request , context , new ChunkUploadResponseListener (request , future ));
227+
228+ return future ;
229+ }
230+ };
231+ }
232+
144233 private static class StartUploadResponseListener extends HttpJsonClientCall .Listener <String > {
145234
146235 private final SettableApiFuture <ResumableUploadSession > future ;
@@ -190,16 +279,7 @@ public void onClose(int statusCode, HttpJsonMetadata trailers) {
190279 /* retryable= */ false ));
191280 }
192281 } else {
193- Throwable cause = trailers != null ? trailers .getException () : null ;
194- ApiException apiException =
195- cause != null
196- ? API_EXCEPTION_FACTORY .create (cause )
197- : ApiExceptionFactory .createException (
198- "Failed to start upload with status code: " + statusCode ,
199- /* cause= */ null ,
200- HttpJsonStatusCode .of (statusCode ),
201- /* retryable= */ false );
202- future .setException (apiException );
282+ future .setException (createApiException (statusCode , trailers , "Failed to start upload" ));
203283 }
204284 } catch (Throwable t ) {
205285 future .setException (
@@ -211,4 +291,112 @@ public void onClose(int statusCode, HttpJsonMetadata trailers) {
211291 }
212292 }
213293 }
294+
295+ private static class ChunkUploadResponseListener extends HttpJsonClientCall .Listener <String > {
296+
297+ private final ChunkUploadRequest request ;
298+ private final SettableApiFuture <ChunkUploadResponse > future ;
299+ private boolean hasUploadStatusHeader = false ;
300+ private boolean isComplete = false ;
301+ private long committedOffset = -1L ;
302+ private String responseBody = "" ;
303+
304+ ChunkUploadResponseListener (
305+ ChunkUploadRequest request , SettableApiFuture <ChunkUploadResponse > future ) {
306+ this .request = request ;
307+ this .future = future ;
308+ }
309+
310+ @ Override
311+ public void onHeaders (HttpJsonMetadata responseHeaders ) {
312+ Map <String , Object > headers = responseHeaders .getHeaders ();
313+
314+ String statusStr = HttpHeadersUtils .getFirstHeader (headers , UPLOAD_STATUS_HEADER );
315+ if (statusStr != null ) {
316+ this .hasUploadStatusHeader = true ;
317+ if (STATUS_FINAL .equalsIgnoreCase (statusStr )) {
318+ this .isComplete = true ;
319+ }
320+ }
321+
322+ String sizeReceivedStr =
323+ HttpHeadersUtils .getFirstHeader (headers , UPLOAD_SIZE_RECEIVED_HEADER );
324+ if (!Strings .isNullOrEmpty (sizeReceivedStr )) {
325+ try {
326+ this .committedOffset = Long .parseLong (sizeReceivedStr );
327+ } catch (NumberFormatException ignored ) {
328+ // Ignore invalid/malformed size received header and fall back to local offset
329+ // calculation.
330+ }
331+ }
332+ }
333+
334+ @ Override
335+ public void onMessage (@ Nullable String message ) {
336+ if (message != null ) {
337+ this .responseBody = message ;
338+ }
339+ }
340+
341+ @ Override
342+ public void onClose (int statusCode , HttpJsonMetadata trailers ) {
343+ try {
344+ if (statusCode >= 200 && statusCode < 300 ) {
345+ if (!hasUploadStatusHeader ) {
346+ future .setException (
347+ ApiExceptionFactory .createException (
348+ "Upload chunk response did not contain valid X-Goog-Upload-Status header" ,
349+ /* cause= */ null ,
350+ HttpJsonStatusCode .of (StatusCode .Code .INTERNAL ),
351+ /* retryable= */ false ));
352+ return ;
353+ }
354+ long confirmedOffset =
355+ committedOffset >= 0
356+ ? committedOffset
357+ : request .getOffset () + request .getPayload ().size ();
358+ future .set (
359+ ChunkUploadResponse .create (
360+ confirmedOffset , isComplete , isComplete ? responseBody : "" ));
361+ } else {
362+ future .setException (createApiException (statusCode , trailers , "Failed to upload chunk" ));
363+ }
364+ } catch (Throwable t ) {
365+ future .setException (
366+ ApiExceptionFactory .createException (
367+ "Internal error processing upload chunk response" ,
368+ t ,
369+ HttpJsonStatusCode .of (StatusCode .Code .INTERNAL ),
370+ /* retryable= */ false ));
371+ }
372+ }
373+ }
374+
375+ private static boolean isUploadFinal (HttpJsonMetadata responseHeaders ) {
376+ String statusStr =
377+ HttpHeadersUtils .getFirstHeader (responseHeaders .getHeaders (), UPLOAD_STATUS_HEADER );
378+ return STATUS_FINAL .equalsIgnoreCase (statusStr );
379+ }
380+
381+ private static ApiException createApiException (
382+ int statusCode , @ Nullable HttpJsonMetadata trailers , String actionDescription ) {
383+ Throwable cause = trailers != null ? trailers .getException () : null ;
384+ if (cause != null ) {
385+ ApiException apiException = API_EXCEPTION_FACTORY .create (cause );
386+ if (trailers != null && isUploadFinal (trailers )) {
387+ return ApiExceptionFactory .createException (
388+ apiException .getMessage (),
389+ apiException .getCause (),
390+ apiException .getStatusCode (),
391+ /* retryable= */ false ,
392+ apiException .getErrorDetails ());
393+ }
394+ return apiException ;
395+ }
396+ return ApiExceptionFactory .createException (
397+ actionDescription + " with status code: " + statusCode ,
398+ /* cause= */ null ,
399+ HttpJsonStatusCode .of (statusCode ),
400+ /* retryable= */ false );
401+ }
214402}
0 commit comments