Skip to content

Commit 2937dba

Browse files
committed
feat(gax): implement startUpload in HttpJsonResumableUploadClient
1 parent c07069b commit 2937dba

2 files changed

Lines changed: 487 additions & 0 deletions

File tree

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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.HttpMethods;
33+
import com.google.api.core.ApiFuture;
34+
import com.google.api.core.InternalApi;
35+
import com.google.api.core.SettableApiFuture;
36+
import com.google.api.gax.resumable.ResumableUploadClient;
37+
import com.google.api.gax.resumable.ResumableUploadSession;
38+
import com.google.api.gax.resumable.StartUploadRequest;
39+
import com.google.api.gax.rpc.ApiCallContext;
40+
import com.google.api.gax.rpc.ClientContext;
41+
import com.google.api.gax.rpc.UnaryCallable;
42+
import com.google.api.pathtemplate.PathTemplate;
43+
import com.google.common.base.Preconditions;
44+
import com.google.common.base.Strings;
45+
import java.util.Collections;
46+
import java.util.HashMap;
47+
import java.util.List;
48+
import java.util.Map;
49+
import org.jspecify.annotations.Nullable;
50+
51+
/**
52+
* Implementation of {@link ResumableUploadClient} using HTTP/JSON transport.
53+
*
54+
* <p>Executes the low-level HTTP wire calls for managing resumable upload sessions.
55+
*/
56+
@InternalApi
57+
public final class HttpJsonResumableUploadClient implements ResumableUploadClient {
58+
59+
private static final String UPLOAD_PROTOCOL_HEADER = "X-Goog-Upload-Protocol";
60+
private static final String UPLOAD_COMMAND_HEADER = "X-Goog-Upload-Command";
61+
private static final String UPLOAD_URL_HEADER = "X-Goog-Upload-URL";
62+
private static final String UPLOAD_GRANULARITY_HEADER = "X-Goog-Upload-Chunk-Granularity";
63+
64+
private static final ApiMethodDescriptor<StartUploadRequest, String> START_UPLOAD_DESCRIPTOR =
65+
ApiMethodDescriptor.<StartUploadRequest, String>newBuilder()
66+
.setFullMethodName("ResumableUpload/StartUpload")
67+
.setHttpMethod(HttpMethods.POST)
68+
.setType(ApiMethodDescriptor.MethodType.UNARY)
69+
.setRequestFormatter(
70+
new HttpRequestFormatter<StartUploadRequest>() {
71+
@Override
72+
public Map<String, List<String>> getQueryParamNames(StartUploadRequest request) {
73+
return request.getQueryParams();
74+
}
75+
76+
@Override
77+
public String getRequestBody(StartUploadRequest request) {
78+
return request.getJsonPayload();
79+
}
80+
81+
@Override
82+
public String getPath(StartUploadRequest request) {
83+
return request.getPath();
84+
}
85+
86+
@Override
87+
public PathTemplate getPathTemplate() {
88+
return PathTemplate.create("{+path}");
89+
}
90+
})
91+
.setResponseParser(StringHttpResponseParser.create())
92+
.build();
93+
94+
private final ClientContext clientContext;
95+
96+
public static HttpJsonResumableUploadClient create(ClientContext clientContext) {
97+
return new HttpJsonResumableUploadClient(clientContext);
98+
}
99+
100+
private HttpJsonResumableUploadClient(ClientContext clientContext) {
101+
this.clientContext = Preconditions.checkNotNull(clientContext);
102+
}
103+
104+
@Override
105+
public UnaryCallable<StartUploadRequest, ResumableUploadSession> startUploadCallable() {
106+
return new UnaryCallable<StartUploadRequest, ResumableUploadSession>() {
107+
@Override
108+
public ApiFuture<ResumableUploadSession> futureCall(
109+
StartUploadRequest request, ApiCallContext inputContext) {
110+
Preconditions.checkNotNull(request);
111+
HttpJsonCallContext context =
112+
HttpJsonCallContext.createDefault()
113+
.nullToSelf(clientContext.getDefaultCallContext())
114+
.merge(inputContext);
115+
116+
Map<String, List<String>> extraHeaders = new HashMap<>(context.getExtraHeaders());
117+
extraHeaders.putIfAbsent(UPLOAD_PROTOCOL_HEADER, Collections.singletonList("resumable"));
118+
extraHeaders.putIfAbsent(UPLOAD_COMMAND_HEADER, Collections.singletonList("start"));
119+
context = (HttpJsonCallContext) context.withExtraHeaders(extraHeaders);
120+
121+
HttpJsonClientCall<StartUploadRequest, String> clientCall =
122+
HttpJsonClientCalls.newCall(START_UPLOAD_DESCRIPTOR, context);
123+
124+
SettableApiFuture<ResumableUploadSession> future = SettableApiFuture.create();
125+
clientCall.start(
126+
new StartUploadResponseListener(future),
127+
HttpJsonClientCalls.getMetadataWithTraceContext(context));
128+
129+
try {
130+
clientCall.sendMessage(request);
131+
clientCall.halfClose();
132+
clientCall.request(2);
133+
} catch (Throwable sendError) {
134+
try {
135+
clientCall.cancel(null, sendError);
136+
} catch (Throwable ignored) {
137+
}
138+
throw sendError;
139+
}
140+
141+
return future;
142+
}
143+
};
144+
}
145+
146+
private static class StartUploadResponseListener extends HttpJsonClientCall.Listener<String> {
147+
148+
private final SettableApiFuture<ResumableUploadSession> future;
149+
@Nullable private String uploadUrl;
150+
private long chunkGranularity = 1L;
151+
152+
StartUploadResponseListener(SettableApiFuture<ResumableUploadSession> future) {
153+
this.future = future;
154+
}
155+
156+
@Override
157+
public void onHeaders(HttpJsonMetadata responseHeaders) {
158+
if (responseHeaders != null && responseHeaders.getHeaders() != null) {
159+
Map<String, Object> headers = responseHeaders.getHeaders();
160+
161+
String url = getFirstHeader(headers, UPLOAD_URL_HEADER);
162+
if (Strings.isNullOrEmpty(url)) {
163+
url = getFirstHeader(headers, "Location");
164+
}
165+
if (!Strings.isNullOrEmpty(url)) {
166+
this.uploadUrl = url;
167+
}
168+
169+
String granularityStr = getFirstHeader(headers, UPLOAD_GRANULARITY_HEADER);
170+
if (!Strings.isNullOrEmpty(granularityStr)) {
171+
try {
172+
this.chunkGranularity = Long.parseLong(granularityStr);
173+
} catch (NumberFormatException ignored) {
174+
this.chunkGranularity = 1L;
175+
}
176+
}
177+
}
178+
}
179+
180+
@Override
181+
public void onMessage(@Nullable String message) {}
182+
183+
@Override
184+
public void onClose(int statusCode, HttpJsonMetadata trailers) {
185+
if (statusCode >= 200 && statusCode < 300) {
186+
if (!Strings.isNullOrEmpty(uploadUrl)) {
187+
future.set(ResumableUploadSession.create(uploadUrl, chunkGranularity));
188+
} else {
189+
future.setException(
190+
new HttpJsonStatusRuntimeException(
191+
statusCode,
192+
"Start upload response did not contain upload session URL header",
193+
null));
194+
}
195+
} else {
196+
future.setException(
197+
trailers != null && trailers.getException() != null
198+
? trailers.getException()
199+
: new HttpJsonStatusRuntimeException(statusCode, "Failed to start upload", null));
200+
}
201+
}
202+
}
203+
204+
@Nullable
205+
private static String getFirstHeader(Map<String, Object> headers, String name) {
206+
for (Map.Entry<String, Object> entry : headers.entrySet()) {
207+
if (entry.getKey().equalsIgnoreCase(name)) {
208+
Object value = entry.getValue();
209+
if (value instanceof List) {
210+
List<?> list = (List<?>) value;
211+
return list.isEmpty() || list.get(0) == null ? null : list.get(0).toString();
212+
}
213+
return value != null ? value.toString() : null;
214+
}
215+
}
216+
return null;
217+
}
218+
}

0 commit comments

Comments
 (0)