Skip to content

Commit 16d875f

Browse files
committed
feat(gax): add ResumableUploadClient startUpload and HTTP/JSON implementation
1 parent a2f2378 commit 16d875f

6 files changed

Lines changed: 766 additions & 0 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
* * Neither the name of Google LLC nor the names of its
15+
* contributors may be used to endorse or promote products derived from
16+
* this software without specific prior written permission.
17+
*
18+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package com.google.api.gax.httpjson;
31+
32+
import com.google.api.client.http.HttpHeaders;
33+
import com.google.api.client.http.HttpMethods;
34+
import com.google.api.core.ApiFuture;
35+
import com.google.api.core.InternalApi;
36+
import com.google.api.core.SettableApiFuture;
37+
import com.google.api.gax.resumable.ResumableUploadClient;
38+
import com.google.api.gax.resumable.ResumableUploadSession;
39+
import com.google.api.gax.resumable.StartUploadRequest;
40+
import com.google.api.gax.rpc.ApiCallContext;
41+
import com.google.api.gax.rpc.ClientContext;
42+
import com.google.api.gax.rpc.UnaryCallable;
43+
import com.google.api.pathtemplate.PathTemplate;
44+
import com.google.common.base.Preconditions;
45+
import com.google.common.base.Strings;
46+
import java.util.Collections;
47+
import java.util.HashMap;
48+
import java.util.List;
49+
import java.util.Map;
50+
import javax.annotation.Nullable;
51+
52+
/**
53+
* Implementation of {@link ResumableUploadClient} using HTTP/JSON transport.
54+
*
55+
* <p>Executes the low-level HTTP wire calls for managing resumable upload sessions.
56+
*/
57+
@InternalApi
58+
public final class HttpJsonResumableUploadClient implements ResumableUploadClient {
59+
60+
private static final String UPLOAD_PROTOCOL_HEADER = "X-Goog-Upload-Protocol";
61+
private static final String UPLOAD_COMMAND_HEADER = "X-Goog-Upload-Command";
62+
private static final String UPLOAD_URL_HEADER = "X-Goog-Upload-URL";
63+
private static final String UPLOAD_GRANULARITY_HEADER = "X-Goog-Upload-Chunk-Granularity";
64+
65+
private static final ApiMethodDescriptor<StartUploadRequest, String> START_UPLOAD_DESCRIPTOR =
66+
ApiMethodDescriptor.<StartUploadRequest, String>newBuilder()
67+
.setFullMethodName("ResumableUpload/StartUpload")
68+
.setHttpMethod(HttpMethods.POST)
69+
.setType(ApiMethodDescriptor.MethodType.UNARY)
70+
.setRequestFormatter(
71+
new HttpRequestFormatter<StartUploadRequest>() {
72+
@Override
73+
public Map<String, List<String>> getQueryParamNames(StartUploadRequest request) {
74+
return request.getQueryParams();
75+
}
76+
77+
@Override
78+
public String getRequestBody(StartUploadRequest request) {
79+
return request.getJsonPayload();
80+
}
81+
82+
@Override
83+
public String getPath(StartUploadRequest request) {
84+
return request.getPath();
85+
}
86+
87+
@Override
88+
public PathTemplate getPathTemplate() {
89+
return PathTemplate.create("{+path}");
90+
}
91+
})
92+
.setResponseParser(StringHttpResponseParser.create())
93+
.build();
94+
95+
private final ClientContext clientContext;
96+
97+
public static HttpJsonResumableUploadClient create(ClientContext clientContext) {
98+
return new HttpJsonResumableUploadClient(clientContext);
99+
}
100+
101+
private HttpJsonResumableUploadClient(ClientContext clientContext) {
102+
this.clientContext = Preconditions.checkNotNull(clientContext);
103+
}
104+
105+
@Override
106+
public UnaryCallable<StartUploadRequest, ResumableUploadSession> startUploadCallable() {
107+
return new UnaryCallable<StartUploadRequest, ResumableUploadSession>() {
108+
@Override
109+
public ApiFuture<ResumableUploadSession> futureCall(
110+
StartUploadRequest request, ApiCallContext inputContext) {
111+
Preconditions.checkNotNull(request);
112+
HttpJsonCallContext context =
113+
HttpJsonCallContext.createDefault()
114+
.nullToSelf(clientContext.getDefaultCallContext())
115+
.merge(inputContext);
116+
117+
Map<String, List<String>> extraHeaders = new HashMap<>(context.getExtraHeaders());
118+
extraHeaders.putIfAbsent(UPLOAD_PROTOCOL_HEADER, Collections.singletonList("resumable"));
119+
extraHeaders.putIfAbsent(UPLOAD_COMMAND_HEADER, Collections.singletonList("start"));
120+
context = (HttpJsonCallContext) context.withExtraHeaders(extraHeaders);
121+
122+
HttpJsonClientCall<StartUploadRequest, String> clientCall =
123+
HttpJsonClientCalls.newCall(START_UPLOAD_DESCRIPTOR, context);
124+
125+
SettableApiFuture<ResumableUploadSession> future = SettableApiFuture.create();
126+
clientCall.start(
127+
new StartUploadResponseListener(future),
128+
HttpJsonClientCalls.getMetadataWithTraceContext(context));
129+
130+
try {
131+
clientCall.sendMessage(request);
132+
clientCall.halfClose();
133+
clientCall.request(2);
134+
} catch (Throwable sendError) {
135+
try {
136+
clientCall.cancel(null, sendError);
137+
} catch (Throwable ignored) {
138+
}
139+
throw sendError;
140+
}
141+
142+
return future;
143+
}
144+
};
145+
}
146+
147+
private static class StartUploadResponseListener extends HttpJsonClientCall.Listener<String> {
148+
149+
private final SettableApiFuture<ResumableUploadSession> future;
150+
@Nullable private String uploadUrl;
151+
private long chunkGranularity = 1L;
152+
153+
StartUploadResponseListener(SettableApiFuture<ResumableUploadSession> future) {
154+
this.future = future;
155+
}
156+
157+
@Override
158+
public void onHeaders(HttpJsonMetadata responseHeaders) {
159+
if (responseHeaders != null && responseHeaders.getHeaders() != null) {
160+
HttpHeaders headers;
161+
if (responseHeaders.getHeaders() instanceof HttpHeaders) {
162+
headers = (HttpHeaders) responseHeaders.getHeaders();
163+
} else {
164+
headers = new HttpHeaders();
165+
headers.putAll(responseHeaders.getHeaders());
166+
}
167+
168+
String url = headers.getFirstHeaderStringValue(UPLOAD_URL_HEADER);
169+
if (Strings.isNullOrEmpty(url)) {
170+
url = headers.getLocation();
171+
}
172+
if (!Strings.isNullOrEmpty(url)) {
173+
this.uploadUrl = url;
174+
}
175+
176+
String granularityStr = headers.getFirstHeaderStringValue(UPLOAD_GRANULARITY_HEADER);
177+
if (!Strings.isNullOrEmpty(granularityStr)) {
178+
try {
179+
this.chunkGranularity = Long.parseLong(granularityStr);
180+
} catch (NumberFormatException ignored) {
181+
this.chunkGranularity = 1L;
182+
}
183+
}
184+
}
185+
}
186+
187+
@Override
188+
public void onMessage(@Nullable String message) {}
189+
190+
@Override
191+
public void onClose(int statusCode, HttpJsonMetadata trailers) {
192+
if (statusCode >= 200 && statusCode < 300) {
193+
if (!Strings.isNullOrEmpty(uploadUrl)) {
194+
future.set(ResumableUploadSession.create(uploadUrl, chunkGranularity));
195+
} else {
196+
future.setException(
197+
new HttpJsonStatusRuntimeException(
198+
statusCode,
199+
"Start upload response did not contain upload session URL header",
200+
null));
201+
}
202+
} else {
203+
future.setException(
204+
trailers != null && trailers.getException() != null
205+
? trailers.getException()
206+
: new HttpJsonStatusRuntimeException(statusCode, "Failed to start upload", null));
207+
}
208+
}
209+
}
210+
}

0 commit comments

Comments
 (0)