Skip to content

Commit e43a3fa

Browse files
committed
feat(gax): implement startUpload in HttpJsonResumableUploadClient
1 parent 0229e07 commit e43a3fa

2 files changed

Lines changed: 487 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)