Skip to content

Commit 8e33b86

Browse files
committed
feat(gax): implement uploadChunk in HttpJsonResumableUploadClient
1 parent 8ebae80 commit 8e33b86

6 files changed

Lines changed: 742 additions & 13 deletions

File tree

sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java

Lines changed: 190 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
import com.google.api.core.ApiFuture;
3434
import com.google.api.core.InternalApi;
3535
import com.google.api.core.SettableApiFuture;
36+
import com.google.api.gax.resumable.ChunkUploadRequest;
37+
import com.google.api.gax.resumable.ChunkUploadResponse;
3638
import com.google.api.gax.resumable.ResumableUploadClient;
3739
import com.google.api.gax.resumable.ResumableUploadSession;
3840
import com.google.api.gax.resumable.StartUploadRequest;
@@ -67,14 +69,20 @@ public final class HttpJsonResumableUploadClient implements ResumableUploadClien
6769

6870
private static final String UPLOAD_PROTOCOL_HEADER = "X-Goog-Upload-Protocol";
6971
private static final String UPLOAD_COMMAND_HEADER = "X-Goog-Upload-Command";
72+
private static final String UPLOAD_OFFSET_HEADER = "X-Goog-Upload-Offset";
7073
private static final String UPLOAD_URL_HEADER = "X-Goog-Upload-URL";
7174
private static final String UPLOAD_GRANULARITY_HEADER = "X-Goog-Upload-Chunk-Granularity";
75+
private static final String UPLOAD_STATUS_HEADER = "X-Goog-Upload-Status";
76+
private static final String UPLOAD_SIZE_RECEIVED_HEADER = "X-Goog-Upload-Size-Received";
77+
private static final String STATUS_FINAL = "final";
7278

7379
private static final Map<String, List<String>> START_UPLOAD_HEADERS =
7480
ImmutableMap.of(
7581
UPLOAD_PROTOCOL_HEADER, ImmutableList.of("resumable"),
7682
UPLOAD_COMMAND_HEADER, ImmutableList.of("start"));
7783

84+
private static final PathTemplate PATH_TEMPLATE = PathTemplate.create("{+path}");
85+
7886
private static final ApiMethodDescriptor<StartUploadRequest, String> START_UPLOAD_DESCRIPTOR =
7987
ApiMethodDescriptor.<StartUploadRequest, String>newBuilder()
8088
.setFullMethodName("ResumableUpload/StartUpload")
@@ -99,7 +107,37 @@ public String getPath(StartUploadRequest request) {
99107

100108
@Override
101109
public PathTemplate getPathTemplate() {
102-
return PathTemplate.create("{+path}");
110+
return PATH_TEMPLATE;
111+
}
112+
})
113+
.setResponseParser(StringHttpResponseParser.create())
114+
.build();
115+
116+
private static final ApiMethodDescriptor<ChunkUploadRequest, String> UPLOAD_CHUNK_DESCRIPTOR =
117+
ApiMethodDescriptor.<ChunkUploadRequest, String>newBuilder()
118+
.setFullMethodName("ResumableUpload/UploadChunk")
119+
.setHttpMethod(HttpMethods.POST)
120+
.setType(ApiMethodDescriptor.MethodType.UNARY)
121+
.setRequestFormatter(
122+
new ResumableUploadChunkRequestFormatter<ChunkUploadRequest>() {
123+
@Override
124+
public Map<String, List<String>> getQueryParamNames(ChunkUploadRequest request) {
125+
return Collections.emptyMap();
126+
}
127+
128+
@Override
129+
public byte[] getBinaryRequestBody(ChunkUploadRequest request) {
130+
return request.getPayload().toByteArray();
131+
}
132+
133+
@Override
134+
public String getPath(ChunkUploadRequest request) {
135+
return request.getUploadUrl();
136+
}
137+
138+
@Override
139+
public PathTemplate getPathTemplate() {
140+
return PATH_TEMPLATE;
103141
}
104142
})
105143
.setResponseParser(StringHttpResponseParser.create())
@@ -141,6 +179,46 @@ public ApiFuture<ResumableUploadSession> futureCall(
141179
};
142180
}
143181

182+
@Override
183+
public UnaryCallable<ChunkUploadRequest, ChunkUploadResponse> uploadChunkCallable() {
184+
return new UnaryCallable<ChunkUploadRequest, ChunkUploadResponse>() {
185+
@Override
186+
public ApiFuture<ChunkUploadResponse> futureCall(
187+
ChunkUploadRequest request, @Nullable ApiCallContext inputContext) {
188+
Preconditions.checkNotNull(request);
189+
boolean isPayloadEmpty = request.getPayload().isEmpty();
190+
String command;
191+
if (request.isFinal()) {
192+
command = !isPayloadEmpty ? "upload, finalize" : "finalize";
193+
} else {
194+
command = "upload";
195+
}
196+
Map<String, List<String>> chunkHeaders =
197+
ImmutableMap.of(
198+
UPLOAD_COMMAND_HEADER,
199+
ImmutableList.of(command),
200+
UPLOAD_OFFSET_HEADER,
201+
ImmutableList.of(String.valueOf(request.getOffset())));
202+
203+
HttpJsonCallContext context =
204+
(HttpJsonCallContext)
205+
HttpJsonCallContext.createDefault()
206+
.nullToSelf(clientContext.getDefaultCallContext())
207+
.merge(inputContext)
208+
.withExtraHeaders(chunkHeaders);
209+
210+
HttpJsonClientCall<ChunkUploadRequest, String> clientCall =
211+
HttpJsonClientCalls.newCall(UPLOAD_CHUNK_DESCRIPTOR, context);
212+
213+
SettableApiFuture<ChunkUploadResponse> future = SettableApiFuture.create();
214+
HttpJsonClientCalls.startUnaryCall(
215+
clientCall, request, context, new ChunkUploadResponseListener(request, future));
216+
217+
return future;
218+
}
219+
};
220+
}
221+
144222
private static class StartUploadResponseListener extends HttpJsonClientCall.Listener<String> {
145223

146224
private final SettableApiFuture<ResumableUploadSession> future;
@@ -194,25 +272,124 @@ public void onClose(int statusCode, HttpJsonMetadata trailers) {
194272
/* retryable= */ false));
195273
}
196274
} else {
197-
Throwable cause = trailers.getException();
198-
ApiException apiException =
199-
cause != null
200-
? API_EXCEPTION_FACTORY.create(cause)
201-
: ApiExceptionFactory.createException(
202-
"Failed to start upload with status code: " + statusCode,
203-
/* cause= */ null,
204-
HttpJsonStatusCode.of(statusCode),
205-
/* retryable= */ false);
206-
future.setException(apiException);
275+
future.setException(createApiException(statusCode, trailers, "Failed to start upload"));
207276
}
208-
} catch (Throwable t) {
277+
} catch (Exception e) {
209278
future.setException(
210279
ApiExceptionFactory.createException(
211280
"Internal error processing start upload response",
212-
t,
281+
e,
213282
HttpJsonStatusCode.of(StatusCode.Code.INTERNAL),
214283
/* retryable= */ false));
215284
}
216285
}
217286
}
287+
288+
private static class ChunkUploadResponseListener extends HttpJsonClientCall.Listener<String> {
289+
290+
private final ChunkUploadRequest request;
291+
private final SettableApiFuture<ChunkUploadResponse> future;
292+
private boolean hasUploadStatusHeader = false;
293+
private boolean isComplete = false;
294+
private long committedOffset = -1L;
295+
private String responseBody = "";
296+
297+
ChunkUploadResponseListener(
298+
ChunkUploadRequest request, SettableApiFuture<ChunkUploadResponse> future) {
299+
this.request = request;
300+
this.future = future;
301+
}
302+
303+
@Override
304+
public void onHeaders(HttpJsonMetadata responseHeaders) {
305+
Map<String, Object> headers = responseHeaders.getHeaders();
306+
307+
String statusStr = HttpHeadersUtils.getSingleHeader(headers, UPLOAD_STATUS_HEADER);
308+
if (statusStr != null) {
309+
this.hasUploadStatusHeader = true;
310+
if (STATUS_FINAL.equalsIgnoreCase(statusStr)) {
311+
this.isComplete = true;
312+
}
313+
}
314+
315+
String sizeReceivedStr =
316+
HttpHeadersUtils.getSingleHeader(headers, UPLOAD_SIZE_RECEIVED_HEADER);
317+
if (!Strings.isNullOrEmpty(sizeReceivedStr)) {
318+
try {
319+
this.committedOffset = Long.parseLong(sizeReceivedStr);
320+
} catch (NumberFormatException ignored) {
321+
// Ignore invalid/malformed size received header and fall back to local offset
322+
// calculation.
323+
}
324+
}
325+
}
326+
327+
@Override
328+
public void onMessage(@Nullable String message) {
329+
if (message != null) {
330+
this.responseBody = message;
331+
}
332+
}
333+
334+
@Override
335+
public void onClose(int statusCode, HttpJsonMetadata trailers) {
336+
try {
337+
if (statusCode >= 200 && statusCode < 300) {
338+
if (!hasUploadStatusHeader) {
339+
future.setException(
340+
ApiExceptionFactory.createException(
341+
"Upload chunk response did not contain valid X-Goog-Upload-Status header",
342+
/* cause= */ null,
343+
HttpJsonStatusCode.of(StatusCode.Code.INTERNAL),
344+
/* retryable= */ false));
345+
return;
346+
}
347+
long confirmedOffset =
348+
committedOffset >= 0
349+
? committedOffset
350+
: request.getOffset() + request.getPayload().size();
351+
future.set(
352+
ChunkUploadResponse.create(
353+
confirmedOffset, isComplete, isComplete ? responseBody : ""));
354+
} else {
355+
future.setException(createApiException(statusCode, trailers, "Failed to upload chunk"));
356+
}
357+
} catch (Exception e) {
358+
future.setException(
359+
ApiExceptionFactory.createException(
360+
"Internal error processing upload chunk response",
361+
e,
362+
HttpJsonStatusCode.of(StatusCode.Code.INTERNAL),
363+
/* retryable= */ false));
364+
}
365+
}
366+
}
367+
368+
private static boolean isUploadFinal(HttpJsonMetadata responseHeaders) {
369+
String statusStr =
370+
HttpHeadersUtils.getSingleHeader(responseHeaders.getHeaders(), UPLOAD_STATUS_HEADER);
371+
return STATUS_FINAL.equalsIgnoreCase(statusStr);
372+
}
373+
374+
private static ApiException createApiException(
375+
int statusCode, @Nullable HttpJsonMetadata trailers, String actionDescription) {
376+
Throwable cause = trailers != null ? trailers.getException() : null;
377+
if (cause != null) {
378+
ApiException apiException = API_EXCEPTION_FACTORY.create(cause);
379+
if (isUploadFinal(trailers)) {
380+
return ApiExceptionFactory.createException(
381+
apiException.getMessage(),
382+
apiException.getCause(),
383+
apiException.getStatusCode(),
384+
/* retryable= */ false,
385+
apiException.getErrorDetails());
386+
}
387+
return apiException;
388+
}
389+
return ApiExceptionFactory.createException(
390+
actionDescription + " with status code: " + statusCode,
391+
/* cause= */ null,
392+
HttpJsonStatusCode.of(statusCode),
393+
/* retryable= */ false);
394+
}
218395
}

0 commit comments

Comments
 (0)