Skip to content

Commit 68cadaf

Browse files
committed
feat(gax): add ResumableUploadClient SPI and types
1 parent a2f2378 commit 68cadaf

4 files changed

Lines changed: 317 additions & 0 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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.resumable;
31+
32+
import com.google.api.core.InternalApi;
33+
import com.google.api.gax.rpc.UnaryCallable;
34+
35+
/** Client interface for executing low-level resumable upload operations. */
36+
@InternalApi
37+
public interface ResumableUploadClient {
38+
39+
/** Returns a {@link UnaryCallable} to initiate a resumable upload session. */
40+
UnaryCallable<StartUploadRequest, ResumableUploadSession> startUploadCallable();
41+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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.resumable;
31+
32+
import com.google.api.core.InternalApi;
33+
import com.google.auto.value.AutoValue;
34+
35+
/** Represents the session metadata returned after starting a resumable upload. */
36+
@InternalApi
37+
@AutoValue
38+
public abstract class ResumableUploadSession {
39+
40+
private static final long DEFAULT_CHUNK_GRANULARITY = 1L;
41+
42+
/** Returns the server-provided URL to which data uploads are directed. */
43+
public abstract String getUploadUrl();
44+
45+
/**
46+
* Returns the server-mandated chunk granularity in bytes.
47+
*
48+
* <p>When specified by the server (via {@code X-Goog-Upload-Chunk-Granularity}), intermediate
49+
* upload chunks must have a size and offset that are an exact multiple of this value (the final
50+
* chunk may be smaller). If not specified by the server, this defaults to 1 byte, indicating no
51+
* alignment or granularity requirements apply.
52+
*
53+
* @return the chunk granularity in bytes
54+
*/
55+
public abstract long getChunkGranularity();
56+
57+
/**
58+
* Creates a {@link ResumableUploadSession} with the specified upload URL and default chunk
59+
* granularity.
60+
*
61+
* @param uploadUrl the upload session URL
62+
* @return a new {@link ResumableUploadSession} instance
63+
*/
64+
public static ResumableUploadSession create(String uploadUrl) {
65+
return create(uploadUrl, DEFAULT_CHUNK_GRANULARITY);
66+
}
67+
68+
/**
69+
* Creates a {@link ResumableUploadSession} with the specified upload URL and chunk granularity.
70+
*
71+
* @param uploadUrl the upload session URL
72+
* @param chunkGranularity the chunk granularity in bytes; if &le; 0, 1 is used to indicate no
73+
* alignment or granularity requirements apply.
74+
* @return a new {@link ResumableUploadSession} instance
75+
*/
76+
public static ResumableUploadSession create(String uploadUrl, long chunkGranularity) {
77+
return new AutoValue_ResumableUploadSession(
78+
uploadUrl, chunkGranularity > 0 ? chunkGranularity : DEFAULT_CHUNK_GRANULARITY);
79+
}
80+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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.resumable;
31+
32+
import com.google.api.core.InternalApi;
33+
import com.google.auto.value.AutoValue;
34+
import com.google.common.collect.ImmutableList;
35+
import com.google.common.collect.ImmutableMap;
36+
import java.util.Collections;
37+
import java.util.List;
38+
import java.util.Map;
39+
import javax.annotation.Nullable;
40+
41+
/** Request parameters for initiating a resumable upload session. */
42+
@InternalApi
43+
@AutoValue
44+
public abstract class StartUploadRequest {
45+
46+
/** Returns the URL path to append to the service endpoint. */
47+
public abstract String getPath();
48+
49+
/** Returns the optional initial JSON request payload. */
50+
@Nullable
51+
public abstract String getJsonPayload();
52+
53+
/** Returns the query parameters for the initiation request. */
54+
public abstract Map<String, List<String>> getQueryParams();
55+
56+
public abstract Builder toBuilder();
57+
58+
public static Builder builder() {
59+
return new AutoValue_StartUploadRequest.Builder().setQueryParams(Collections.emptyMap());
60+
}
61+
62+
/**
63+
* Convenience factory for creating a {@link StartUploadRequest} with only a target path.
64+
*
65+
* @param path the resource upload path
66+
* @return a new {@link StartUploadRequest} instance
67+
*/
68+
public static StartUploadRequest create(String path) {
69+
return builder().setPath(path).build();
70+
}
71+
72+
@AutoValue.Builder
73+
public abstract static class Builder {
74+
public abstract Builder setPath(String path);
75+
76+
public abstract Builder setJsonPayload(String jsonPayload);
77+
78+
public abstract Builder setQueryParams(Map<String, List<String>> queryParams);
79+
80+
abstract Map<String, List<String>> getQueryParams();
81+
82+
abstract String getPath();
83+
84+
abstract StartUploadRequest autoBuild();
85+
86+
public StartUploadRequest build() {
87+
if (getPath() != null && getPath().startsWith("/")) {
88+
setPath(getPath().substring(1));
89+
}
90+
91+
Map<String, List<String>> params = getQueryParams();
92+
if (params != null && !params.isEmpty()) {
93+
ImmutableMap.Builder<String, List<String>> mapBuilder = ImmutableMap.builder();
94+
for (Map.Entry<String, List<String>> entry : params.entrySet()) {
95+
mapBuilder.put(entry.getKey(), ImmutableList.copyOf(entry.getValue()));
96+
}
97+
setQueryParams(mapBuilder.build());
98+
} else {
99+
setQueryParams(Collections.emptyMap());
100+
}
101+
102+
return autoBuild();
103+
}
104+
}
105+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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.resumable;
31+
32+
import static com.google.common.truth.Truth.assertThat;
33+
import static org.junit.jupiter.api.Assertions.assertThrows;
34+
35+
import java.util.ArrayList;
36+
import java.util.Collections;
37+
import java.util.HashMap;
38+
import java.util.List;
39+
import java.util.Map;
40+
import org.junit.jupiter.api.Test;
41+
42+
class ResumableUploadTypesTest {
43+
44+
private static final String UPLOAD_URL = "https://storage.googleapis.com/upload/session/12345";
45+
46+
@Test
47+
void session_normalizesInvalidChunkGranularityToDefault() {
48+
assertThat(ResumableUploadSession.create(UPLOAD_URL).getChunkGranularity()).isEqualTo(1L);
49+
assertThat(ResumableUploadSession.create(UPLOAD_URL, 0).getChunkGranularity()).isEqualTo(1L);
50+
assertThat(ResumableUploadSession.create(UPLOAD_URL, -100L).getChunkGranularity())
51+
.isEqualTo(1L);
52+
assertThat(ResumableUploadSession.create(UPLOAD_URL, 256 * 1024L).getChunkGranularity())
53+
.isEqualTo(256 * 1024L);
54+
}
55+
56+
@Test
57+
void startUploadRequest_guaranteesImmutabilityAndBuilderSupport() {
58+
StartUploadRequest requestWithLeadingSlash = StartUploadRequest.create("/v1/upload");
59+
assertThat(requestWithLeadingSlash.getPath()).isEqualTo("v1/upload");
60+
assertThat(requestWithLeadingSlash.getJsonPayload()).isNull();
61+
62+
Map<String, List<String>> mutableParams = new HashMap<>();
63+
List<String> mutableList = new ArrayList<>();
64+
mutableList.add("value1");
65+
mutableParams.put("key1", mutableList);
66+
67+
StartUploadRequest request =
68+
StartUploadRequest.builder()
69+
.setPath("/v1/upload")
70+
.setJsonPayload("{}")
71+
.setQueryParams(mutableParams)
72+
.build();
73+
assertThat(request.getPath()).isEqualTo("v1/upload");
74+
75+
// Mutate source map and list after construction
76+
mutableParams.put("key2", Collections.singletonList("value2"));
77+
mutableList.add("value2");
78+
79+
assertThat(request.getQueryParams()).hasSize(1);
80+
assertThat(request.getQueryParams().get("key1")).containsExactly("value1");
81+
assertThrows(
82+
UnsupportedOperationException.class,
83+
() -> request.getQueryParams().put("key3", Collections.singletonList("value3")));
84+
85+
StartUploadRequest mutatedFromBuilder =
86+
request.toBuilder().setPath("/v2/upload").setJsonPayload("{\"updated\":true}").build();
87+
assertThat(mutatedFromBuilder.getPath()).isEqualTo("v2/upload");
88+
assertThat(mutatedFromBuilder.getJsonPayload()).isEqualTo("{\"updated\":true}");
89+
assertThat(mutatedFromBuilder.getQueryParams()).hasSize(1);
90+
}
91+
}

0 commit comments

Comments
 (0)