Skip to content

Commit 2c0d034

Browse files
committed
feat(gax): implement startUpload in HttpJsonResumableUploadClient
1 parent 41f0a2e commit 2c0d034

5 files changed

Lines changed: 675 additions & 1 deletion

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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.BetaApi;
34+
import com.google.api.core.InternalApi;
35+
import com.google.api.gax.resumable.ResumableUploadClient;
36+
import com.google.api.gax.resumable.ResumableUploadSession;
37+
import com.google.api.gax.rpc.ClientContext;
38+
import com.google.api.gax.rpc.UnaryCallable;
39+
import com.google.common.base.Preconditions;
40+
import org.jspecify.annotations.NullMarked;
41+
42+
/**
43+
* Implementation of {@link ResumableUploadClient} using HTTP/JSON transport.
44+
*
45+
* <p>Executes the low-level HTTP wire calls for managing resumable upload sessions.
46+
*
47+
* @param <RequestT> request type for starting an upload
48+
* @param <ResponseT> response type of the upload method
49+
*/
50+
@NullMarked
51+
@BetaApi
52+
@InternalApi
53+
public final class HttpJsonResumableUploadClient<RequestT, ResponseT>
54+
implements ResumableUploadClient<RequestT, ResponseT> {
55+
56+
private final UnaryCallable<RequestT, ResumableUploadSession> startUploadCallable;
57+
58+
public static <RequestT, ResponseT> HttpJsonResumableUploadClient<RequestT, ResponseT> create(
59+
ClientContext clientContext, ApiMethodDescriptor<RequestT, ResponseT> methodDescriptor) {
60+
return new HttpJsonResumableUploadClient<>(clientContext, methodDescriptor);
61+
}
62+
63+
private HttpJsonResumableUploadClient(
64+
ClientContext clientContext, ApiMethodDescriptor<RequestT, ResponseT> methodDescriptor) {
65+
Preconditions.checkNotNull(clientContext);
66+
Preconditions.checkNotNull(methodDescriptor);
67+
68+
ApiMethodDescriptor<RequestT, String> startUploadDescriptor =
69+
ApiMethodDescriptor.<RequestT, String>newBuilder()
70+
.setFullMethodName(methodDescriptor.getFullMethodName())
71+
.setHttpMethod(HttpMethods.POST)
72+
.setType(ApiMethodDescriptor.MethodType.UNARY)
73+
.setRequestFormatter(methodDescriptor.getRequestFormatter())
74+
.setResponseParser(ResumableUploadResponseParser.create())
75+
.build();
76+
this.startUploadCallable =
77+
ResumableUploadStartCallable.create(clientContext, startUploadDescriptor);
78+
}
79+
80+
@Override
81+
public UnaryCallable<RequestT, ResumableUploadSession> startUploadCallable() {
82+
return startUploadCallable;
83+
}
84+
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
package com.google.api.gax.httpjson;
3232

3333
import org.jspecify.annotations.NullMarked;
34+
import org.jspecify.annotations.Nullable;
3435

3536
/**
3637
* HTTP status code in RuntimeException form, for propagating status code information via
@@ -42,7 +43,8 @@ public class HttpJsonStatusRuntimeException extends RuntimeException {
4243

4344
private final int statusCode;
4445

45-
public HttpJsonStatusRuntimeException(int statusCode, String message, Throwable cause) {
46+
public HttpJsonStatusRuntimeException(
47+
int statusCode, @Nullable String message, @Nullable Throwable cause) {
4648
super(message, cause);
4749
this.statusCode = statusCode;
4850
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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.core.AbstractApiFuture;
33+
import com.google.api.core.ApiFuture;
34+
import org.jspecify.annotations.NullMarked;
35+
36+
/**
37+
* An {@link ApiFuture} that cancels the underlying {@link HttpJsonClientCall} upon cancellation to
38+
* prevent connection leaks.
39+
*/
40+
@NullMarked
41+
class ResumableUploadHttpJsonFuture<T> extends AbstractApiFuture<T> {
42+
43+
private final HttpJsonClientCall<?, ?> call;
44+
45+
ResumableUploadHttpJsonFuture(HttpJsonClientCall<?, ?> call) {
46+
this.call = call;
47+
}
48+
49+
@Override
50+
protected void interruptTask() {
51+
call.cancel("Call was cancelled", null);
52+
}
53+
54+
@Override
55+
public boolean set(T value) {
56+
return super.set(value);
57+
}
58+
59+
@Override
60+
public boolean setException(Throwable throwable) {
61+
return super.setException(throwable);
62+
}
63+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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.core.ApiFuture;
33+
import com.google.api.gax.resumable.ResumableUploadSession;
34+
import com.google.api.gax.rpc.ApiCallContext;
35+
import com.google.api.gax.rpc.ApiExceptionFactory;
36+
import com.google.api.gax.rpc.ClientContext;
37+
import com.google.api.gax.rpc.StatusCode;
38+
import com.google.api.gax.rpc.UnaryCallable;
39+
import com.google.common.base.Preconditions;
40+
import com.google.common.base.Strings;
41+
import com.google.common.collect.ImmutableList;
42+
import com.google.common.collect.ImmutableMap;
43+
import java.util.Collections;
44+
import java.util.List;
45+
import java.util.Map;
46+
import org.jspecify.annotations.NullMarked;
47+
import org.jspecify.annotations.Nullable;
48+
49+
/** A {@link UnaryCallable} that initiates a resumable upload session. */
50+
@NullMarked
51+
class ResumableUploadStartCallable<RequestT>
52+
extends UnaryCallable<RequestT, ResumableUploadSession> {
53+
54+
private static final String UPLOAD_PROTOCOL_HEADER = "X-Goog-Upload-Protocol";
55+
private static final String UPLOAD_COMMAND_HEADER = "X-Goog-Upload-Command";
56+
private static final String UPLOAD_URL_HEADER = "X-Goog-Upload-URL";
57+
private static final String UPLOAD_GRANULARITY_HEADER = "X-Goog-Upload-Chunk-Granularity";
58+
59+
private static final Map<String, List<String>> START_UPLOAD_HEADERS =
60+
ImmutableMap.of(
61+
UPLOAD_PROTOCOL_HEADER, ImmutableList.of("resumable"),
62+
UPLOAD_COMMAND_HEADER, ImmutableList.of("start"));
63+
64+
private final ApiMethodDescriptor<RequestT, String> descriptor;
65+
private final ClientContext clientContext;
66+
67+
ResumableUploadStartCallable(
68+
ClientContext clientContext, ApiMethodDescriptor<RequestT, String> descriptor) {
69+
this.clientContext = Preconditions.checkNotNull(clientContext);
70+
this.descriptor = Preconditions.checkNotNull(descriptor);
71+
}
72+
73+
@Override
74+
public ApiFuture<ResumableUploadSession> futureCall(
75+
RequestT request, @Nullable ApiCallContext inputContext) {
76+
Preconditions.checkNotNull(request);
77+
HttpJsonCallContext context =
78+
(HttpJsonCallContext)
79+
HttpJsonCallContext.createDefault()
80+
.nullToSelf(clientContext.getDefaultCallContext())
81+
.merge(inputContext)
82+
.withExtraHeaders(START_UPLOAD_HEADERS);
83+
84+
HttpJsonClientCall<RequestT, String> clientCall =
85+
HttpJsonClientCalls.newCall(descriptor, context);
86+
87+
ResumableUploadHttpJsonFuture<ResumableUploadSession> future =
88+
new ResumableUploadHttpJsonFuture<>(clientCall);
89+
HttpJsonClientCalls.startUnaryCall(
90+
clientCall, request, context, new StartUploadResponseListener(future));
91+
92+
return future;
93+
}
94+
95+
static <RequestT> UnaryCallable<RequestT, ResumableUploadSession> create(
96+
ClientContext clientContext, ApiMethodDescriptor<RequestT, String> descriptor) {
97+
UnaryCallable<RequestT, ResumableUploadSession> rawCallable =
98+
new ResumableUploadStartCallable<>(clientContext, descriptor);
99+
UnaryCallable<RequestT, ResumableUploadSession> callable =
100+
new HttpJsonExceptionCallable<>(
101+
rawCallable,
102+
// Wire calls do not retry directly; retries are managed by ResumableUploadCallable.
103+
Collections.emptySet());
104+
return callable.withDefaultCallContext(clientContext.getDefaultCallContext());
105+
}
106+
107+
/** A listener that parses HTTP response headers to produce a {@link ResumableUploadSession}. */
108+
private static class StartUploadResponseListener extends HttpJsonClientCall.Listener<String> {
109+
private final ResumableUploadHttpJsonFuture<ResumableUploadSession> future;
110+
private long chunkGranularity = 1L;
111+
@Nullable private String uploadUrl;
112+
@Nullable private Throwable headerParsingException;
113+
114+
StartUploadResponseListener(ResumableUploadHttpJsonFuture<ResumableUploadSession> future) {
115+
this.future = future;
116+
}
117+
118+
@Override
119+
public void onHeaders(HttpJsonMetadata responseHeaders) {
120+
Map<String, Object> headers = responseHeaders.getHeaders();
121+
122+
String url = HttpHeadersUtils.getSingleHeader(headers, UPLOAD_URL_HEADER);
123+
if (!Strings.isNullOrEmpty(url)) {
124+
this.uploadUrl = url;
125+
}
126+
127+
String granularityStr = HttpHeadersUtils.getSingleHeader(headers, UPLOAD_GRANULARITY_HEADER);
128+
if (Strings.isNullOrEmpty(granularityStr)) {
129+
return;
130+
}
131+
132+
try {
133+
long parsed = Long.parseLong(granularityStr);
134+
if (parsed <= 0) {
135+
this.headerParsingException =
136+
ApiExceptionFactory.createException(
137+
"Start upload response contained non-positive chunk granularity header: "
138+
+ granularityStr,
139+
/* cause= */ null,
140+
HttpJsonStatusCode.of(StatusCode.Code.INTERNAL),
141+
/* retryable= */ false);
142+
} else {
143+
this.chunkGranularity = parsed;
144+
}
145+
} catch (NumberFormatException e) {
146+
this.headerParsingException =
147+
ApiExceptionFactory.createException(
148+
"Start upload response contained invalid chunk granularity header: "
149+
+ granularityStr,
150+
e,
151+
HttpJsonStatusCode.of(StatusCode.Code.INTERNAL),
152+
/* retryable= */ false);
153+
}
154+
}
155+
156+
@Override
157+
public void onMessage(@Nullable String message) {
158+
// Response body is not needed for startUpload; session URL is in headers.
159+
}
160+
161+
@Override
162+
public void onClose(int statusCode, HttpJsonMetadata trailers) {
163+
try {
164+
if (statusCode >= 200 && statusCode < 300) {
165+
if (headerParsingException != null) {
166+
future.setException(headerParsingException);
167+
return;
168+
}
169+
if (!Strings.isNullOrEmpty(uploadUrl)) {
170+
future.set(
171+
ResumableUploadSession.newBuilder()
172+
.setUploadUrl(uploadUrl)
173+
.setChunkGranularity(chunkGranularity)
174+
.build());
175+
} else {
176+
future.setException(
177+
ApiExceptionFactory.createException(
178+
"Start upload response did not contain upload session URL header",
179+
/* cause= */ null,
180+
HttpJsonStatusCode.of(StatusCode.Code.INTERNAL),
181+
/* retryable= */ false));
182+
}
183+
} else {
184+
Throwable cause = trailers.getException();
185+
future.setException(
186+
cause != null
187+
? cause
188+
: new HttpJsonStatusRuntimeException(
189+
statusCode, "Failed to start upload with status code: " + statusCode, null));
190+
}
191+
} catch (Throwable t) {
192+
future.setException(t);
193+
}
194+
}
195+
}
196+
}

0 commit comments

Comments
 (0)