diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java index 3af6442a5ead..4ff875aaa310 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java @@ -30,6 +30,7 @@ package com.google.api.gax.httpjson; import com.google.api.core.BetaApi; +import com.google.api.core.InternalApi; import com.google.api.core.ObsoleteApi; import com.google.api.gax.longrunning.OperationSnapshot; import com.google.api.gax.resumable.ResumableUploadClient; @@ -44,6 +45,7 @@ import com.google.api.gax.rpc.ResumableUploadCallable; import com.google.api.gax.rpc.ServerStreamingCallSettings; import com.google.api.gax.rpc.ServerStreamingCallable; +import com.google.api.gax.rpc.StubSettings; import com.google.api.gax.rpc.UnaryCallSettings; import com.google.api.gax.rpc.UnaryCallable; import com.google.api.gax.tracing.ApiTracerContext; @@ -245,6 +247,43 @@ ResumableUploadCallable createResumableUploadCallable( return Callables.resumableUpload(uploadClient, callSettings, clientContext); } + /** + * Returns {@code true} if the given {@link StubSettings} has a transport configuration that can + * be used or bridged to HTTP/JSON for resumable uploads, or {@code false} if it contains + * non-HTTP/JSON transport customizations that cannot be bridged. Designed for use by generated + * code. + */ + @BetaApi + @InternalApi + public static boolean isResumableUploadSupported(StubSettings settings) { + return ResumableUploadTransportValidator.isSupported(settings); + } + + /** + * Creates a {@link ResumableUploadCallable} that synchronously throws an {@link + * IllegalStateException} when invoked because the client's transport configuration is + * incompatible with HTTP/JSON resumable uploads. Designed for use by generated code. + */ + @BetaApi + @InternalApi + public static + ResumableUploadCallable createFailingResumableUploadCallable() { + return ResumableUploadTransportValidator.createFailingCallable(); + } + + /** + * Creates a {@link ResumableUploadCallable} that synchronously throws an {@link + * IllegalStateException} with the given cause when invoked because the HTTP/JSON client context + * required for resumable uploads could not be created. Designed for use by generated code. + */ + @BetaApi + @InternalApi + public static + ResumableUploadCallable createFailingResumableUploadCallable( + Throwable cause) { + return ResumableUploadTransportValidator.createFailingCallable(cause); + } + static ApiTracerContext getApiTracerContext(ApiMethodDescriptor methodDescriptor) { return ApiTracerContext.newBuilder() .setFullMethodName(methodDescriptor.getFullMethodName()) diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ResumableUploadTransportValidator.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ResumableUploadTransportValidator.java new file mode 100644 index 000000000000..1df461ff3832 --- /dev/null +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ResumableUploadTransportValidator.java @@ -0,0 +1,115 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.httpjson; + +import com.google.api.gax.rpc.ApiCallContext; +import com.google.api.gax.rpc.FixedTransportChannelProvider; +import com.google.api.gax.rpc.InputStreamSupplier; +import com.google.api.gax.rpc.ResumableUploadCallable; +import com.google.api.gax.rpc.ResumableUploadFuture; +import com.google.api.gax.rpc.ResumableUploadOptions; +import com.google.api.gax.rpc.StubSettings; +import com.google.api.gax.rpc.TransportChannelProvider; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Helper to detect and surface deferred failures for transport configurations on {@link + * StubSettings} that cannot be bridged to HTTP/JSON for resumable uploads. + */ +@NullMarked +final class ResumableUploadTransportValidator { + + private static final String UNSUPPORTED_TRANSPORT_MESSAGE = + "Resumable uploads are not supported with this gRPC transport configuration. Resumable" + + " uploads run over HTTP/JSON using only the endpoint, credentials, and headers" + + " configured on the service Settings; configure them there instead of on the" + + " TransportChannelProvider or a pre-built channel."; + + private static final String CLIENT_CONTEXT_FAILURE_MESSAGE = + "Failed to initialize the HTTP/JSON client context required for resumable uploads." + + " Resumable uploads run over HTTP/JSON using only the endpoint, credentials, and" + + " headers configured on the service Settings; create the client or stub from Settings" + + " rather than a ClientContext."; + + private ResumableUploadTransportValidator() {} + + static boolean isSupported(StubSettings settings) { + TransportChannelProvider provider = settings.getTransportChannelProvider(); + if (HttpJsonTransportChannel.getHttpJsonTransportName().equals(provider.getTransportName())) { + return true; + } + // gRPC stubs bridge resumable uploads to HTTP/JSON using only the headers, + // credentials, and endpoint configured on StubSettings. The provider is only compatible if it + // does not wrap a pre-built channel (FixedTransportChannelProvider) and still expects all + // three settings to be supplied by StubSettings. + return !(provider instanceof FixedTransportChannelProvider) + && provider.needsHeaders() + && provider.needsCredentials() + && provider.needsEndpoint(); + } + + static + ResumableUploadCallable createFailingCallable() { + return new FailingResumableUploadCallable<>(UNSUPPORTED_TRANSPORT_MESSAGE, null); + } + + static ResumableUploadCallable createFailingCallable( + Throwable cause) { + return new FailingResumableUploadCallable<>(CLIENT_CONTEXT_FAILURE_MESSAGE, cause); + } + + private static final class FailingResumableUploadCallable + extends ResumableUploadCallable { + + private final String message; + private final @Nullable Throwable cause; + + private FailingResumableUploadCallable(String message, @Nullable Throwable cause) { + this.message = message; + this.cause = cause; + } + + @Override + public ResumableUploadFuture futureCall( + RequestT request, + InputStreamSupplier payload, + @Nullable ApiCallContext context, + @Nullable ResumableUploadOptions options) { + throw new IllegalStateException(message, cause); + } + + @Override + public ResumableUploadFuture resumeCall( + String sessionUrl, InputStreamSupplier payload, @Nullable ResumableUploadOptions options) { + throw new IllegalStateException(message, cause); + } + } +} diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ResumableUploadTransportValidatorTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ResumableUploadTransportValidatorTest.java new file mode 100644 index 000000000000..bbd94586085c --- /dev/null +++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ResumableUploadTransportValidatorTest.java @@ -0,0 +1,124 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.httpjson; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import com.google.api.gax.rpc.FixedTransportChannelProvider; +import com.google.api.gax.rpc.InputStreamSupplier; +import com.google.api.gax.rpc.ResumableUploadCallable; +import com.google.api.gax.rpc.StubSettings; +import com.google.api.gax.rpc.TransportChannel; +import com.google.api.gax.rpc.TransportChannelProvider; +import com.google.api.gax.rpc.testing.FakeStubSettings; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +class ResumableUploadTransportValidatorTest { + + @Test + void testIsSupported() throws IOException { + TransportChannelProvider httpJsonProvider = + InstantiatingHttpJsonChannelProvider.newBuilder().build(); + assertThat( + HttpJsonCallableFactory.isResumableUploadSupported( + settingsWithProvider(httpJsonProvider))) + .isTrue(); + assertThat(HttpJsonCallableFactory.isResumableUploadSupported(grpcSettings(true, true, true))) + .isTrue(); + + assertThat(HttpJsonCallableFactory.isResumableUploadSupported(grpcSettings(false, true, true))) + .isFalse(); + assertThat(HttpJsonCallableFactory.isResumableUploadSupported(grpcSettings(true, false, true))) + .isFalse(); + assertThat(HttpJsonCallableFactory.isResumableUploadSupported(grpcSettings(true, true, false))) + .isFalse(); + + TransportChannel grpcChannel = + mock(TransportChannel.class, withSettings().withoutAnnotations()); + when(grpcChannel.getTransportName()).thenReturn("grpc"); + assertThat( + HttpJsonCallableFactory.isResumableUploadSupported( + settingsWithProvider(FixedTransportChannelProvider.create(grpcChannel)))) + .isFalse(); + } + + @Test + void testCreateFailingCallable() { + ResumableUploadCallable callable = + HttpJsonCallableFactory.createFailingResumableUploadCallable(); + InputStreamSupplier payload = () -> new ByteArrayInputStream(new byte[0]); + + assertThrows( + IllegalStateException.class, () -> callable.futureCall("request", payload, null, null)); + assertThrows( + IllegalStateException.class, + () -> callable.resumeCall("https://example.com/session", payload, null)); + } + + @Test + void testCreateFailingCallableWithCause() { + IOException cause = new IOException("no credentials"); + ResumableUploadCallable callable = + HttpJsonCallableFactory.createFailingResumableUploadCallable(cause); + InputStreamSupplier payload = () -> new ByteArrayInputStream(new byte[0]); + + IllegalStateException futureCallException = + assertThrows( + IllegalStateException.class, () -> callable.futureCall("request", payload, null, null)); + assertThat(futureCallException).hasCauseThat().isSameInstanceAs(cause); + IllegalStateException resumeCallException = + assertThrows( + IllegalStateException.class, + () -> callable.resumeCall("https://example.com/session", payload, null)); + assertThat(resumeCallException).hasCauseThat().isSameInstanceAs(cause); + } + + private static StubSettings settingsWithProvider(TransportChannelProvider provider) + throws IOException { + return FakeStubSettings.newBuilder().setTransportChannelProvider(provider).build(); + } + + private static StubSettings grpcSettings( + boolean needsHeaders, boolean needsCredentials, boolean needsEndpoint) throws IOException { + TransportChannelProvider provider = + mock(TransportChannelProvider.class, withSettings().withoutAnnotations()); + when(provider.getTransportName()).thenReturn("grpc"); + when(provider.needsHeaders()).thenReturn(needsHeaders); + when(provider.needsCredentials()).thenReturn(needsCredentials); + when(provider.needsEndpoint()).thenReturn(needsEndpoint); + return settingsWithProvider(provider); + } +}