Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<Client> callDecorator;

@SuppressWarnings("unchecked")
protected Client(Builder<?, ?> builder) {
ClientConfig.Builder configBuilder = builder.configBuilder();
this.config = configBuilder.build();
Expand All @@ -61,6 +63,7 @@ protected Client(Builder<?, ?> builder) {
if (retryStrategy instanceof Claimable c) {
c.claim(this);
}
this.callDecorator = (ClientCallDecorator<Client>) this.config.callDecorator();
}

/**
Expand All @@ -77,6 +80,18 @@ protected <I extends SerializableStruct, O extends SerializableStruct> O call(
I input,
ApiOperation<I, O> operation,
RequestOverrideConfig overrideConfig
) {
if (callDecorator != null) {
return callDecorator.apply(this, operation, input, overrideConfig, this::doCall);
}

return this.doCall(input, operation, overrideConfig);
Comment thread
timocov marked this conversation as resolved.
}

private <I extends SerializableStruct, O extends SerializableStruct> O doCall(
I input,
ApiOperation<I, O> operation,
RequestOverrideConfig overrideConfig
) {
ProtocolEventStreamWriter<SerializableStruct, SerializableStruct, Frame<?>> eventStreamWriter = null;
if (operation.inputEventBuilderSupplier() != null) {
Expand Down Expand Up @@ -367,7 +382,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.
*/
Expand Down Expand Up @@ -417,6 +432,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<I> callDecorator) {
configBuilder.callDecorator(callDecorator);
return (B) this;
}

/**
* Creates the client.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* 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;

/**
* A decorator that wraps client call execution, allowing cross-cutting behavior to be applied
* around operation invocations.
*
* <p>Implementations can inspect or modify the input, add logging, metrics, caching, or other
* concerns before delegating to the next invoker in the chain.
*
* @param <C> the client type this decorator is applied to.
*/
@FunctionalInterface
public interface ClientCallDecorator<C> {
/**
* 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 next the next invoker in the chain to delegate the actual call to.
* @param <I> the input type.
* @param <O> the output type.
* @return the operation output.
*/
<I extends SerializableStruct, O extends SerializableStruct> O apply(
C client,
ApiOperation<I, O> operation,
I input,
RequestOverrideConfig overrideConfig,
ClientCallInvoker next
);
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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 <I> the input type.
* @param <O> the output type.
* @return the operation output.
*/
<I extends SerializableStruct, O extends SerializableStruct> O invoke(
I input,
ApiOperation<I, O> operation,
RequestOverrideConfig overrideConfig
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public final class ClientConfig {
private final RetryStrategy retryStrategy;
private final String retryScope;
private final Set<Class<? extends ClientPlugin>> appliedPluginClasses;
private final ClientCallDecorator<?> callDecorator;

private ClientConfig(Builder builder) {
// Collect and apply plugins, updating builder.appliedPluginClasses as we go
Expand Down Expand Up @@ -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<ClientPlugin> collectPlugins(
Expand Down Expand Up @@ -223,6 +225,10 @@ String retryScope() {
return retryScope;
}

ClientCallDecorator<?> callDecorator() {
return callDecorator;
}

/**
* Create a new builder to build {@link ClientConfig}.
*
Expand Down Expand Up @@ -322,6 +328,7 @@ public static final class Builder {
private final Map<Class<? extends ClientPlugin>, ClientPlugin> plugins = new LinkedHashMap<>();
// Mutable set that tracks which plugin classes have been applied to this builder
private final Set<Class<? extends ClientPlugin>> appliedPluginClasses = new HashSet<>();
private ClientCallDecorator<?> callDecorator;

public Builder() {
plugins.put(DefaultPlugin.class, DefaultPlugin.INSTANCE);
Expand Down Expand Up @@ -659,6 +666,17 @@ public Builder addPluginPredicate(Predicate<ClientPlugin> 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,12 @@ List<IdentityResolver<?>> identityResolvers() {
return identityResolvers;
}

Context context() {
/**
* Get the context of the override request config.
*
* @return the context.
*/
public Context context() {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One potential "issue" of this change I see is that call decorators now would be able to modify the context (so it is not a readonly), but maybe it is not an issue as the context is being exposed to hooks already where they could do the same before).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it currently kind of blurs the line between override and given context. Maybe we just add one more argument to the two interfaces and pass Context? It’s a lot of arguments, but seems necessary (and I want to avoid a kind of wrapper allocation for this since it’ll be a hot path).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about it and it felt like it might be a bit ambiguous as in hooks we have context that is "merged" (or whichever takes precedence) with client's default context, but here we're mostly interested in the override context? Maybe its just me.

Another thought, do you think we should keep the RequestOverrideConfig in the arguments of ClientCallDecorator and ClientCallInvoker despite the fact that you can't do anything with it outside of smithy-java other than pass it down? But on the other side removing it from there would require to create unnecessary wrapping around doCall method...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe its just me.

I guess thats fine since the implementation can always take client's current context and do its own logic for "overrides" if needed, at least for now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mtdowling pushed the changes in 1d6e17f and 669f55d

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. Gonna take a deeper look at this. Wondering if we can just not send RequestOverride somehow by resolving stuff before the decorator. Alternatively, expose ClientCall or a stripped down version of as an interface that exposes less than the internal ClientCall. Not sure.

return context;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
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.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;
Expand Down Expand Up @@ -301,4 +303,40 @@ 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<DynamicClient>() {
@Override
public <I extends SerializableStruct, O extends SerializableStruct> O apply(
DynamicClient client,
ApiOperation<I, O> operation,
I input,
RequestOverrideConfig overrideConfig,
ClientCallInvoker next
) {
assertThat(overrideConfig.context().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");
}
}