diff --git a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/Client.java b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/Client.java index b046c04bae..b538d61038 100644 --- a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/Client.java +++ b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/Client.java @@ -43,7 +43,9 @@ public abstract class Client implements Closeable { private final ClientInterceptor interceptor; private final IdentityResolvers identityResolvers; private final RetryStrategy retryStrategy; + private final ClientCallDecorator callDecorator; + @SuppressWarnings("unchecked") protected Client(Builder builder) { ClientConfig.Builder configBuilder = builder.configBuilder(); this.config = configBuilder.build(); @@ -61,6 +63,7 @@ protected Client(Builder builder) { if (retryStrategy instanceof Claimable c) { c.claim(this); } + this.callDecorator = (ClientCallDecorator) this.config.callDecorator(); } /** @@ -77,6 +80,24 @@ protected O call( I input, ApiOperation operation, RequestOverrideConfig overrideConfig + ) { + if (callDecorator != null) { + return callDecorator.apply( + this, + operation, + input, + overrideConfig, + overrideConfig == null ? null : overrideConfig.context(), + this::doCall); + } + + return this.doCall(input, operation, overrideConfig); + } + + private O doCall( + I input, + ApiOperation operation, + RequestOverrideConfig overrideConfig ) { ProtocolEventStreamWriter> eventStreamWriter = null; if (operation.inputEventBuilderSupplier() != null) { @@ -367,7 +388,7 @@ public B retryScope(String retryScope) { * plugin class. Plugins are applied in a sorted {@link ClientPlugin.Phase} and insertion order. * * @see ClientConfig.Builder#pluginPredicate() - * @see ClientConfig.Builder#addPlugin(ClientPlugin) + * @see ClientConfig.Builder#addPlugin(ClientPlugin) * @param plugin Plugin to add. * @return the builder. */ @@ -417,6 +438,18 @@ public boolean test(ClientPlugin plugin) { }.and(configBuilder.pluginPredicate())); } + /** + * Sets the decorator to wrap client call execution. + * + * @param callDecorator the client call decorator. + * @return the builder. + */ + @SuppressWarnings("unchecked") + public B callDecorator(ClientCallDecorator callDecorator) { + configBuilder.callDecorator(callDecorator); + return (B) this; + } + /** * Creates the client. * diff --git a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientCallDecorator.java b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientCallDecorator.java new file mode 100644 index 0000000000..81b5fcf9ac --- /dev/null +++ b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientCallDecorator.java @@ -0,0 +1,44 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.core; + +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.java.core.schema.ApiOperation; +import software.amazon.smithy.java.core.schema.SerializableStruct; + +/** + * A decorator that wraps client call execution, allowing cross-cutting behavior to be applied + * around operation invocations. + * + *

Implementations can inspect or modify the input, add logging, metrics, caching, or other + * concerns before delegating to the next invoker in the chain. + * + * @param the client type this decorator is applied to. + */ +@FunctionalInterface +public interface ClientCallDecorator { + /** + * Applies decoration logic around a client call. + * + * @param client the client instance making the call. + * @param operation the API operation being invoked. + * @param input the operation input. + * @param overrideConfig optional per-request configuration overrides, or {@code null}. + * @param overrideContext optional per-request context overrides, or {@code null}. + * @param next the next invoker in the chain to delegate the actual call to. + * @param the input type. + * @param the output type. + * @return the operation output. + */ + O apply( + C client, + ApiOperation operation, + I input, + RequestOverrideConfig overrideConfig, + Context overrideContext, + ClientCallInvoker next + ); +} diff --git a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientCallInvoker.java b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientCallInvoker.java new file mode 100644 index 0000000000..dde95e301c --- /dev/null +++ b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientCallInvoker.java @@ -0,0 +1,34 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.core; + +import software.amazon.smithy.java.core.schema.ApiOperation; +import software.amazon.smithy.java.core.schema.SerializableStruct; + +/** + * Invokes a client operation call with the given input and configuration. + * + *

This functional interface represents the core call execution logic that a + * {@link ClientCallDecorator} delegates to after applying its decoration. + */ +@FunctionalInterface +public interface ClientCallInvoker { + /** + * Invokes the operation. + * + * @param input the operation input. + * @param operation the API operation being invoked. + * @param overrideConfig optional per-request configuration overrides, or {@code null}. + * @param the input type. + * @param the output type. + * @return the operation output. + */ + O invoke( + I input, + ApiOperation operation, + RequestOverrideConfig overrideConfig + ); +} diff --git a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientConfig.java b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientConfig.java index 3dc5e84fd3..1dc8a89535 100644 --- a/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientConfig.java +++ b/client/client-core/src/main/java/software/amazon/smithy/java/client/core/ClientConfig.java @@ -53,6 +53,7 @@ public final class ClientConfig { private final RetryStrategy retryStrategy; private final String retryScope; private final Set> appliedPluginClasses; + private final ClientCallDecorator callDecorator; private ClientConfig(Builder builder) { // Collect and apply plugins, updating builder.appliedPluginClasses as we go @@ -95,6 +96,7 @@ private ClientConfig(Builder builder) { this.context = Context.unmodifiableCopy(builder.context); this.service = Objects.requireNonNull(builder.service, "Missing required service schema"); + this.callDecorator = builder.callDecorator; } private static List collectPlugins( @@ -223,6 +225,10 @@ String retryScope() { return retryScope; } + ClientCallDecorator callDecorator() { + return callDecorator; + } + /** * Create a new builder to build {@link ClientConfig}. * @@ -322,6 +328,7 @@ public static final class Builder { private final Map, ClientPlugin> plugins = new LinkedHashMap<>(); // Mutable set that tracks which plugin classes have been applied to this builder private final Set> appliedPluginClasses = new HashSet<>(); + private ClientCallDecorator callDecorator; public Builder() { plugins.put(DefaultPlugin.class, DefaultPlugin.INSTANCE); @@ -659,6 +666,17 @@ public Builder addPluginPredicate(Predicate pluginPredicate) { return pluginPredicate(this.pluginPredicate.and(pluginPredicate)); } + /** + * Sets the decorator to wrap client call execution. + * + * @param callDecorator the client call decorator. + * @return the builder. + */ + public Builder callDecorator(ClientCallDecorator callDecorator) { + this.callDecorator = callDecorator; + return this; + } + /** * Get the plugin predicate of the builder. * diff --git a/client/client-core/src/test/java/software/amazon/smithy/java/client/core/ClientTest.java b/client/client-core/src/test/java/software/amazon/smithy/java/client/core/ClientTest.java index ebcd68ae61..41921eaf03 100644 --- a/client/client-core/src/test/java/software/amazon/smithy/java/client/core/ClientTest.java +++ b/client/client-core/src/test/java/software/amazon/smithy/java/client/core/ClientTest.java @@ -36,6 +36,9 @@ import software.amazon.smithy.java.client.http.plugins.HttpChecksumPlugin; import software.amazon.smithy.java.client.http.plugins.RequestCompressionPlugin; import software.amazon.smithy.java.client.http.plugins.UserAgentPlugin; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.java.core.schema.ApiOperation; +import software.amazon.smithy.java.core.schema.SerializableStruct; import software.amazon.smithy.java.core.serde.document.Document; import software.amazon.smithy.java.dynamicclient.DynamicClient; import software.amazon.smithy.java.dynamicclient.plugins.DetectProtocolPlugin; @@ -301,4 +304,41 @@ public void readBeforeExecution(InputHook hook) { c.call("GetSprocket"); } + + @Test + public void setsCallDecorator() throws URISyntaxException { + var queue = new MockQueue(); + queue.enqueue(HttpResponse.create().setStatusCode(200).toUnmodifiable()); + + DynamicClient c = DynamicClient.builder() + .model(MODEL) + .serviceId(SERVICE) + .protocol(new RestJsonClientProtocol(SERVICE)) + .addPlugin(MockPlugin.builder().addQueue(queue).build()) + .addPlugin(config -> config.callDecorator(new ClientCallDecorator() { + @Override + public O apply( + DynamicClient client, + ApiOperation operation, + I input, + RequestOverrideConfig overrideConfig, + Context overrideContext, + ClientCallInvoker next + ) { + assertThat(overrideContext.get(ClientContext.APPLICATION_ID), equalTo("foo")); + throw new IllegalStateException("Prevent calling the service"); + } + })) + .authSchemeResolver(AuthSchemeResolver.NO_AUTH) + .endpointResolver(EndpointResolver.staticEndpoint(new URI("http://localhost"))) + .build(); + + Assertions.assertThrows(IllegalStateException.class, () -> { + c.call("GetSprocket", + Document.ofObject(new HashMap<>()), + RequestOverrideConfig.builder() + .putConfig(ClientContext.APPLICATION_ID, "foo") + .build()); + }, "Prevent calling the service"); + } }