Skip to content

Commit bbcdbb0

Browse files
committed
feat(gax): implement uploadChunk in HttpJsonResumableUploadClient
1 parent d41b2ef commit bbcdbb0

6 files changed

Lines changed: 771 additions & 12 deletions

File tree

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

Lines changed: 204 additions & 12 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.rpc.ApiCallContext;
@@ -41,10 +43,14 @@
4143
import com.google.api.gax.rpc.ClientContext;
4244
import com.google.api.gax.rpc.StatusCode;
4345
import com.google.api.gax.rpc.UnaryCallable;
46+
import com.google.api.pathtemplate.PathTemplate;
4447
import com.google.common.base.Preconditions;
4548
import com.google.common.base.Strings;
4649
import com.google.common.collect.ImmutableList;
4750
import com.google.common.collect.ImmutableMap;
51+
import java.io.ByteArrayInputStream;
52+
import java.io.InputStream;
53+
import java.nio.charset.StandardCharsets;
4854
import java.util.Collections;
4955
import java.util.List;
5056
import 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

Comments
 (0)