3333import com .google .api .core .ApiFuture ;
3434import com .google .api .core .InternalApi ;
3535import com .google .api .core .SettableApiFuture ;
36+ import com .google .api .gax .resumable .ChunkUploadRequest ;
37+ import com .google .api .gax .resumable .ChunkUploadResponse ;
3638import com .google .api .gax .resumable .ResumableUploadClient ;
3739import com .google .api .gax .resumable .ResumableUploadSession ;
3840import com .google .api .gax .rpc .ApiCallContext ;
4143import com .google .api .gax .rpc .ClientContext ;
4244import com .google .api .gax .rpc .StatusCode ;
4345import com .google .api .gax .rpc .UnaryCallable ;
46+ import com .google .api .pathtemplate .PathTemplate ;
4447import com .google .common .base .Preconditions ;
4548import com .google .common .base .Strings ;
4649import com .google .common .collect .ImmutableList ;
4750import com .google .common .collect .ImmutableMap ;
51+ import java .io .ByteArrayInputStream ;
52+ import java .io .InputStream ;
53+ import java .nio .charset .StandardCharsets ;
4854import java .util .Collections ;
4955import java .util .List ;
5056import java .util .Map ;
@@ -69,14 +75,49 @@ public final class HttpJsonResumableUploadClient<RequestT, ResponseT>
6975
7076 private static final String UPLOAD_PROTOCOL_HEADER = "X-Goog-Upload-Protocol" ;
7177 private static final String UPLOAD_COMMAND_HEADER = "X-Goog-Upload-Command" ;
78+ private static final String UPLOAD_OFFSET_HEADER = "X-Goog-Upload-Offset" ;
7279 private static final String UPLOAD_URL_HEADER = "X-Goog-Upload-URL" ;
7380 private static final String UPLOAD_GRANULARITY_HEADER = "X-Goog-Upload-Chunk-Granularity" ;
81+ private static final String UPLOAD_STATUS_HEADER = "X-Goog-Upload-Status" ;
82+ private static final String UPLOAD_SIZE_RECEIVED_HEADER = "X-Goog-Upload-Size-Received" ;
83+ private static final String STATUS_FINAL = "final" ;
7484
7585 private static final Map <String , List <String >> START_UPLOAD_HEADERS =
7686 ImmutableMap .of (
7787 UPLOAD_PROTOCOL_HEADER , ImmutableList .of ("resumable" ),
7888 UPLOAD_COMMAND_HEADER , ImmutableList .of ("start" ));
7989
90+ private static final PathTemplate PATH_TEMPLATE = PathTemplate .create ("{+path}" );
91+
92+ private static final ApiMethodDescriptor <ChunkUploadRequest , String > UPLOAD_CHUNK_DESCRIPTOR =
93+ ApiMethodDescriptor .<ChunkUploadRequest , String >newBuilder ()
94+ .setFullMethodName ("ResumableUpload/UploadChunk" )
95+ .setHttpMethod (HttpMethods .POST )
96+ .setType (ApiMethodDescriptor .MethodType .UNARY )
97+ .setRequestFormatter (
98+ new ResumableUploadChunkRequestFormatter <ChunkUploadRequest >() {
99+ @ Override
100+ public Map <String , List <String >> getQueryParamNames (ChunkUploadRequest request ) {
101+ return Collections .emptyMap ();
102+ }
103+
104+ @ Override
105+ public byte [] getBinaryRequestBody (ChunkUploadRequest request ) {
106+ return request .getPayload ().toByteArray ();
107+ }
108+
109+ @ Override
110+ public String getPath (ChunkUploadRequest request ) {
111+ return request .getUploadUrl ();
112+ }
113+
114+ @ Override
115+ public PathTemplate getPathTemplate () {
116+ return PATH_TEMPLATE ;
117+ }
118+ })
119+ .setResponseParser (ResumableUploadResponseParser .create ())
120+ .build ();
80121 private final ClientContext clientContext ;
81122 private final ApiMethodDescriptor <RequestT , String > startUploadDescriptor ;
82123 private final HttpResponseParser <ResponseT > responseParser ;
@@ -128,6 +169,49 @@ public ApiFuture<ResumableUploadSession> futureCall(
128169 };
129170 }
130171
172+ @ Override
173+ public UnaryCallable <ChunkUploadRequest , ChunkUploadResponse <ResponseT >> uploadChunkCallable () {
174+ return new UnaryCallable <ChunkUploadRequest , ChunkUploadResponse <ResponseT >>() {
175+ @ Override
176+ public ApiFuture <ChunkUploadResponse <ResponseT >> futureCall (
177+ ChunkUploadRequest request , @ Nullable ApiCallContext inputContext ) {
178+ Preconditions .checkNotNull (request );
179+ boolean isPayloadEmpty = request .getPayload ().isEmpty ();
180+ String command ;
181+ if (request .isFinal ()) {
182+ command = !isPayloadEmpty ? "upload, finalize" : "finalize" ;
183+ } else {
184+ command = "upload" ;
185+ }
186+ Map <String , List <String >> chunkHeaders =
187+ ImmutableMap .of (
188+ UPLOAD_COMMAND_HEADER ,
189+ ImmutableList .of (command ),
190+ UPLOAD_OFFSET_HEADER ,
191+ ImmutableList .of (String .valueOf (request .getOffset ())));
192+
193+ HttpJsonCallContext context =
194+ (HttpJsonCallContext )
195+ HttpJsonCallContext .createDefault ()
196+ .nullToSelf (clientContext .getDefaultCallContext ())
197+ .merge (inputContext )
198+ .withExtraHeaders (chunkHeaders );
199+
200+ HttpJsonClientCall <ChunkUploadRequest , String > clientCall =
201+ HttpJsonClientCalls .newCall (UPLOAD_CHUNK_DESCRIPTOR , context );
202+
203+ SettableApiFuture <ChunkUploadResponse <ResponseT >> future = SettableApiFuture .create ();
204+ HttpJsonClientCalls .startUnaryCall (
205+ clientCall ,
206+ request ,
207+ context ,
208+ new ChunkUploadResponseListener <>(request , future , responseParser ));
209+
210+ return future ;
211+ }
212+ };
213+ }
214+
131215 private static class StartUploadResponseListener extends HttpJsonClientCall .Listener <String > {
132216
133217 private final SettableApiFuture <ResumableUploadSession > future ;
@@ -181,25 +265,133 @@ public void onClose(int statusCode, HttpJsonMetadata trailers) {
181265 /* retryable= */ false ));
182266 }
183267 } else {
184- Throwable cause = trailers .getException ();
185- ApiException apiException =
186- cause != null
187- ? API_EXCEPTION_FACTORY .create (cause )
188- : ApiExceptionFactory .createException (
189- "Failed to start upload with status code: " + statusCode ,
190- /* cause= */ null ,
191- HttpJsonStatusCode .of (statusCode ),
192- /* retryable= */ false );
193- future .setException (apiException );
268+ future .setException (createApiException (statusCode , trailers , "Failed to start upload" ));
194269 }
195- } catch (Throwable t ) {
270+ } catch (Exception e ) {
196271 future .setException (
197272 ApiExceptionFactory .createException (
198273 "Internal error processing start upload response" ,
199- t ,
274+ e ,
275+ HttpJsonStatusCode .of (StatusCode .Code .INTERNAL ),
276+ /* retryable= */ false ));
277+ }
278+ }
279+ }
280+
281+ private static class ChunkUploadResponseListener <ResponseT >
282+ extends HttpJsonClientCall .Listener <String > {
283+
284+ private final ChunkUploadRequest request ;
285+ private final SettableApiFuture <ChunkUploadResponse <ResponseT >> future ;
286+ private final HttpResponseParser <ResponseT > responseParser ;
287+ private boolean hasUploadStatusHeader = false ;
288+ private boolean isComplete = false ;
289+ private long committedOffset = -1L ;
290+ private String responseBody = "" ;
291+
292+ ChunkUploadResponseListener (
293+ ChunkUploadRequest request ,
294+ SettableApiFuture <ChunkUploadResponse <ResponseT >> future ,
295+ HttpResponseParser <ResponseT > responseParser ) {
296+ this .request = request ;
297+ this .future = future ;
298+ this .responseParser = responseParser ;
299+ }
300+
301+ @ Override
302+ public void onHeaders (HttpJsonMetadata responseHeaders ) {
303+ Map <String , Object > headers = responseHeaders .getHeaders ();
304+
305+ String statusStr = HttpHeadersUtils .getSingleHeader (headers , UPLOAD_STATUS_HEADER );
306+ if (statusStr != null ) {
307+ this .hasUploadStatusHeader = true ;
308+ if (STATUS_FINAL .equalsIgnoreCase (statusStr )) {
309+ this .isComplete = true ;
310+ }
311+ }
312+
313+ String sizeReceivedStr =
314+ HttpHeadersUtils .getSingleHeader (headers , UPLOAD_SIZE_RECEIVED_HEADER );
315+ if (!Strings .isNullOrEmpty (sizeReceivedStr )) {
316+ try {
317+ this .committedOffset = Long .parseLong (sizeReceivedStr );
318+ } catch (NumberFormatException ignored ) {
319+ // Ignore invalid/malformed size received header and fall back to local offset
320+ // calculation.
321+ }
322+ }
323+ }
324+
325+ @ Override
326+ public void onMessage (@ Nullable String message ) {
327+ if (message != null ) {
328+ this .responseBody = message ;
329+ }
330+ }
331+
332+ @ Override
333+ public void onClose (int statusCode , HttpJsonMetadata trailers ) {
334+ try {
335+ if (statusCode >= 200 && statusCode < 300 ) {
336+ if (!hasUploadStatusHeader ) {
337+ future .setException (
338+ ApiExceptionFactory .createException (
339+ "Upload chunk response did not contain valid X-Goog-Upload-Status header" ,
340+ /* cause= */ null ,
341+ HttpJsonStatusCode .of (StatusCode .Code .INTERNAL ),
342+ /* retryable= */ false ));
343+ return ;
344+ }
345+ long confirmedOffset =
346+ committedOffset >= 0
347+ ? committedOffset
348+ : request .getOffset () + request .getPayload ().size ();
349+ ResponseT response = null ;
350+ if (isComplete ) {
351+ InputStream stream =
352+ new ByteArrayInputStream (responseBody .getBytes (StandardCharsets .UTF_8 ));
353+ response = responseParser .parse (stream );
354+ }
355+ future .set (ChunkUploadResponse .create (confirmedOffset , isComplete , response ));
356+ } else {
357+ future .setException (createApiException (statusCode , trailers , "Failed to upload chunk" ));
358+ }
359+ } catch (Exception e ) {
360+ future .setException (
361+ ApiExceptionFactory .createException (
362+ "Internal error processing upload chunk response" ,
363+ e ,
200364 HttpJsonStatusCode .of (StatusCode .Code .INTERNAL ),
201365 /* retryable= */ false ));
202366 }
203367 }
204368 }
369+
370+ private static boolean isUploadFinal (HttpJsonMetadata responseHeaders ) {
371+ String statusStr =
372+ HttpHeadersUtils .getSingleHeader (responseHeaders .getHeaders (), UPLOAD_STATUS_HEADER );
373+ return STATUS_FINAL .equalsIgnoreCase (statusStr );
374+ }
375+
376+ private static ApiException createApiException (
377+ int statusCode , @ Nullable HttpJsonMetadata trailers , String actionDescription ) {
378+ Throwable cause = trailers != null ? trailers .getException () : null ;
379+ if (cause != null ) {
380+ ApiException apiException = API_EXCEPTION_FACTORY .create (cause );
381+ if (isUploadFinal (trailers )) {
382+ return ApiExceptionFactory .createException (
383+ apiException .getMessage (),
384+ apiException .getCause (),
385+ apiException .getStatusCode (),
386+ /* retryable= */ false ,
387+ apiException .getErrorDetails ());
388+ }
389+ return apiException ;
390+ }
391+ return ApiExceptionFactory .createException (
392+ actionDescription + " with status code: " + statusCode ,
393+ /* cause= */ null ,
394+ HttpJsonStatusCode .of (statusCode ),
395+ /* retryable= */ false );
396+ }
205397}
0 commit comments