|
29 | 29 | */ |
30 | 30 | package com.google.api.gax.httpjson; |
31 | 31 |
|
| 32 | +import com.google.api.client.http.ByteArrayContent; |
| 33 | +import com.google.api.client.http.EmptyContent; |
| 34 | +import com.google.api.client.http.HttpContent; |
32 | 35 | import com.google.api.client.http.HttpMethods; |
33 | 36 | import com.google.api.core.ApiFuture; |
34 | 37 | import com.google.api.core.InternalApi; |
35 | 38 | import com.google.api.core.SettableApiFuture; |
| 39 | +import com.google.api.gax.resumable.ChunkUploadRequest; |
| 40 | +import com.google.api.gax.resumable.ChunkUploadResponse; |
36 | 41 | import com.google.api.gax.resumable.ResumableUploadClient; |
37 | 42 | import com.google.api.gax.resumable.ResumableUploadSession; |
38 | 43 | import com.google.api.gax.resumable.StartUploadRequest; |
@@ -67,8 +72,15 @@ public final class HttpJsonResumableUploadClient implements ResumableUploadClien |
67 | 72 |
|
68 | 73 | private static final String UPLOAD_PROTOCOL_HEADER = "X-Goog-Upload-Protocol"; |
69 | 74 | private static final String UPLOAD_COMMAND_HEADER = "X-Goog-Upload-Command"; |
| 75 | + private static final String UPLOAD_OFFSET_HEADER = "X-Goog-Upload-Offset"; |
70 | 76 | private static final String UPLOAD_URL_HEADER = "X-Goog-Upload-URL"; |
71 | 77 | 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"; |
| 81 | + |
| 82 | + /** HTTP status code 308 (Resume Incomplete in Google Scotty resumable upload protocol). */ |
| 83 | + private static final int HTTP_STATUS_RESUME_INCOMPLETE = 308; |
72 | 84 |
|
73 | 85 | private static final Map<String, List<String>> START_UPLOAD_HEADERS = |
74 | 86 | ImmutableMap.of( |
@@ -105,6 +117,45 @@ public PathTemplate getPathTemplate() { |
105 | 117 | .setResponseParser(StringHttpResponseParser.create()) |
106 | 118 | .build(); |
107 | 119 |
|
| 120 | + private static final ApiMethodDescriptor<ChunkUploadRequest, String> UPLOAD_CHUNK_DESCRIPTOR = |
| 121 | + ApiMethodDescriptor.<ChunkUploadRequest, String>newBuilder() |
| 122 | + .setFullMethodName("ResumableUpload/UploadChunk") |
| 123 | + .setHttpMethod(HttpMethods.POST) |
| 124 | + .setType(ApiMethodDescriptor.MethodType.UNARY) |
| 125 | + .setRequestFormatter( |
| 126 | + new HttpRequestFormatter<ChunkUploadRequest>() { |
| 127 | + @Override |
| 128 | + public Map<String, List<String>> getQueryParamNames(ChunkUploadRequest request) { |
| 129 | + return Collections.emptyMap(); |
| 130 | + } |
| 131 | + |
| 132 | + @Override |
| 133 | + public String getRequestBody(ChunkUploadRequest request) { |
| 134 | + return ""; |
| 135 | + } |
| 136 | + |
| 137 | + @Override |
| 138 | + public HttpContent getHttpContent(ChunkUploadRequest request) { |
| 139 | + if (!request.getPayload().isEmpty()) { |
| 140 | + return new ByteArrayContent( |
| 141 | + "application/octet-stream", request.getPayload().toByteArray()); |
| 142 | + } |
| 143 | + return new EmptyContent(); |
| 144 | + } |
| 145 | + |
| 146 | + @Override |
| 147 | + public String getPath(ChunkUploadRequest request) { |
| 148 | + return request.getUploadUrl(); |
| 149 | + } |
| 150 | + |
| 151 | + @Override |
| 152 | + public PathTemplate getPathTemplate() { |
| 153 | + return PathTemplate.create("{+path}"); |
| 154 | + } |
| 155 | + }) |
| 156 | + .setResponseParser(StringHttpResponseParser.create()) |
| 157 | + .build(); |
| 158 | + |
108 | 159 | private final ClientContext clientContext; |
109 | 160 |
|
110 | 161 | public static HttpJsonResumableUploadClient create(ClientContext clientContext) { |
@@ -141,6 +192,45 @@ public ApiFuture<ResumableUploadSession> futureCall( |
141 | 192 | }; |
142 | 193 | } |
143 | 194 |
|
| 195 | + @Override |
| 196 | + public UnaryCallable<ChunkUploadRequest, ChunkUploadResponse> uploadChunkCallable() { |
| 197 | + return new UnaryCallable<ChunkUploadRequest, ChunkUploadResponse>() { |
| 198 | + @Override |
| 199 | + public ApiFuture<ChunkUploadResponse> futureCall( |
| 200 | + ChunkUploadRequest request, @Nullable ApiCallContext inputContext) { |
| 201 | + Preconditions.checkNotNull(request); |
| 202 | + String command; |
| 203 | + if (request.isFinal()) { |
| 204 | + command = !request.getPayload().isEmpty() ? "upload, finalize" : "finalize"; |
| 205 | + } else { |
| 206 | + command = "upload"; |
| 207 | + } |
| 208 | + Map<String, List<String>> chunkHeaders = |
| 209 | + ImmutableMap.of( |
| 210 | + UPLOAD_COMMAND_HEADER, |
| 211 | + ImmutableList.of(command), |
| 212 | + UPLOAD_OFFSET_HEADER, |
| 213 | + ImmutableList.of(String.valueOf(request.getOffset()))); |
| 214 | + |
| 215 | + HttpJsonCallContext context = |
| 216 | + (HttpJsonCallContext) |
| 217 | + HttpJsonCallContext.createDefault() |
| 218 | + .nullToSelf(clientContext.getDefaultCallContext()) |
| 219 | + .merge(inputContext) |
| 220 | + .withExtraHeaders(chunkHeaders); |
| 221 | + |
| 222 | + HttpJsonClientCall<ChunkUploadRequest, String> clientCall = |
| 223 | + HttpJsonClientCalls.newCall(UPLOAD_CHUNK_DESCRIPTOR, context); |
| 224 | + |
| 225 | + SettableApiFuture<ChunkUploadResponse> future = SettableApiFuture.create(); |
| 226 | + HttpJsonClientCalls.startUnaryCall( |
| 227 | + clientCall, request, context, new ChunkUploadResponseListener(request, future)); |
| 228 | + |
| 229 | + return future; |
| 230 | + } |
| 231 | + }; |
| 232 | + } |
| 233 | + |
144 | 234 | private static class StartUploadResponseListener extends HttpJsonClientCall.Listener<String> { |
145 | 235 |
|
146 | 236 | private final SettableApiFuture<ResumableUploadSession> future; |
@@ -205,4 +295,81 @@ public void onClose(int statusCode, HttpJsonMetadata trailers) { |
205 | 295 | } |
206 | 296 | } |
207 | 297 | } |
| 298 | + |
| 299 | + private static class ChunkUploadResponseListener extends HttpJsonClientCall.Listener<String> { |
| 300 | + |
| 301 | + private final ChunkUploadRequest request; |
| 302 | + private final SettableApiFuture<ChunkUploadResponse> future; |
| 303 | + private boolean isComplete = false; |
| 304 | + private long committedOffset = -1L; |
| 305 | + private String responseBody = ""; |
| 306 | + |
| 307 | + ChunkUploadResponseListener( |
| 308 | + ChunkUploadRequest request, SettableApiFuture<ChunkUploadResponse> future) { |
| 309 | + this.request = request; |
| 310 | + this.future = future; |
| 311 | + } |
| 312 | + |
| 313 | + @Override |
| 314 | + public void onHeaders(HttpJsonMetadata responseHeaders) { |
| 315 | + Map<String, Object> headers = responseHeaders.getHeaders(); |
| 316 | + |
| 317 | + String statusStr = HttpHeadersUtils.getFirstHeader(headers, UPLOAD_STATUS_HEADER); |
| 318 | + if (STATUS_FINAL.equalsIgnoreCase(statusStr)) { |
| 319 | + this.isComplete = true; |
| 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 | + } |
| 329 | + } |
| 330 | + } |
| 331 | + |
| 332 | + @Override |
| 333 | + public void onMessage(@Nullable String message) { |
| 334 | + if (message != null) { |
| 335 | + this.responseBody = message; |
| 336 | + } |
| 337 | + } |
| 338 | + |
| 339 | + @Override |
| 340 | + public void onClose(int statusCode, HttpJsonMetadata trailers) { |
| 341 | + if ((statusCode >= 200 && statusCode < 300) |
| 342 | + || statusCode == HTTP_STATUS_RESUME_INCOMPLETE) { |
| 343 | + if (statusCode == HTTP_STATUS_RESUME_INCOMPLETE && committedOffset < 0) { |
| 344 | + future.setException( |
| 345 | + ApiExceptionFactory.createException( |
| 346 | + "Server returned 308 Resume Incomplete but the " |
| 347 | + + UPLOAD_SIZE_RECEIVED_HEADER |
| 348 | + + " header was missing or invalid", |
| 349 | + /* cause= */ null, |
| 350 | + HttpJsonStatusCode.of(statusCode), |
| 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 | + Throwable cause = trailers.getException(); |
| 363 | + ApiException apiException = |
| 364 | + cause != null |
| 365 | + ? API_EXCEPTION_FACTORY.create(cause) |
| 366 | + : ApiExceptionFactory.createException( |
| 367 | + "Failed to upload chunk with status code: " + statusCode, |
| 368 | + /* cause= */ null, |
| 369 | + HttpJsonStatusCode.of(statusCode), |
| 370 | + /* retryable= */ false); |
| 371 | + future.setException(apiException); |
| 372 | + } |
| 373 | + } |
| 374 | + } |
208 | 375 | } |
0 commit comments