|
| 1 | +# Per-operation configuration |
| 2 | + |
| 3 | +* **Type**: Design |
| 4 | +* **Author(s)**: Luc Talatinian, Ian Smith Botsford, Aaron Todd, Matas Lauzadis |
| 5 | + |
| 6 | +# Abstract |
| 7 | + |
| 8 | +Service clients are configured at creation time and use the same configuration for every operation for the lifetime of |
| 9 | +the client. It is desirable to enable overriding a subset of client configuration for one or more operations (e.g. using |
| 10 | +different HTTP settings, logging mode, etc.). This document covers the design and implementation of |
| 11 | +the API for overriding service client configuration on a per-operation basis, and outlines several rejected design |
| 12 | +alternatives. |
| 13 | + |
| 14 | +# Design |
| 15 | + |
| 16 | +All code samples in this document assume the following basic client interface. The client has an arbitrary number of |
| 17 | +operations, the declarations of which are omitted. |
| 18 | + |
| 19 | +This particular client has two config fields: a logging mode and an HTTP client to be used to make requests. Note that |
| 20 | +HTTP clients (`HttpClientEngine`) in the smithy-kotlin runtime implement `Closeable`. |
| 21 | + |
| 22 | +```kotlin |
| 23 | +interface ServiceClient : SdkClient { |
| 24 | + companion object { |
| 25 | + fun invoke(block: Config.Builder.() -> Unit): ServiceClient { /* returns instance of implementation class DefaultServiceClient */ } |
| 26 | + } |
| 27 | + |
| 28 | + class Config private constructor(builder: Builder) { |
| 29 | + val logMode: SdkLogMode = builder.logMode ?: SdkLogMode.Default |
| 30 | + val httpEngine: HttpClientEngine = builder.httpEngine |
| 31 | + |
| 32 | + class Builder { |
| 33 | + var logMode: SdkLogMode? = null |
| 34 | + val httpEngine: HttpClientEngine? = null |
| 35 | + fun build(): Config = Config(this) |
| 36 | + } |
| 37 | + } |
| 38 | + |
| 39 | + // suspend fun operationA, operationB, etc... |
| 40 | +} |
| 41 | + |
| 42 | +class DefaultServiceClient(config: ServiceClient.Config) : ServiceClient { /* ... */ } |
| 43 | +``` |
| 44 | + |
| 45 | +Client interfaces have a `withConfig` extension, which takes a config builder and returns a new client with |
| 46 | +any fields specified in the builder applied to its config. |
| 47 | + |
| 48 | +```kotlin |
| 49 | +fun ServiceClient.withConfig(block: ServiceClient.Config.Builder.() -> Unit): ServiceClient { /* ... */ } |
| 50 | +``` |
| 51 | + |
| 52 | +The client returned from `withConfig` exists independently of the original. It has its own lifetime and should be closed |
| 53 | +when no longer needed. Any runtime-managed closeable resources will be kept alive until all clients using them have been |
| 54 | +closed. |
| 55 | + |
| 56 | +The `withConfig` method is _more efficient_ than creating multiple clients from scratch (either directly from explicit |
| 57 | +`Config` or via `fromEnvironment`) when using runtime-managed `Closeable` resources such as the default HTTP engine. |
| 58 | +Instances which use these defaults are tracked across clients as they are created, and the underlying `Closeable` is |
| 59 | +only fully closed when the last remaining client using it is closed. |
| 60 | + |
| 61 | +Sample usage: |
| 62 | +```kotlin |
| 63 | +suspend fun main() { |
| 64 | + val client = ServiceClient { } |
| 65 | + |
| 66 | + client.operationA { /* ... */ } |
| 67 | + |
| 68 | + client.withConfig { |
| 69 | + logMode = SdkLogMode.LogRequestWithBody |
| 70 | + }.use { // for one-off or explicitly-scoped operation(s), a use {} block is most convenient |
| 71 | + it.operationA { /* ... */ } |
| 72 | + } |
| 73 | + |
| 74 | + launch { |
| 75 | + doBackgroundRoutine(client.withConfig { |
| 76 | + logMode = SdkLogMode.LogResponse |
| 77 | + }) |
| 78 | + |
| 79 | + doBackgroundRoutine2(client.withConfig { |
| 80 | + httpEngine = YourHttpClientEngineImpl() |
| 81 | + }) |
| 82 | + } |
| 83 | + |
| 84 | + // application continues... |
| 85 | +} |
| 86 | +``` |
| 87 | + |
| 88 | +Some observations about the above sample: |
| 89 | +* Since no explicit HTTP engine is passed to construction of the initial client, the SDK creates its own "managed" default. |
| 90 | +* The first use of `withConfig` and `use` demonstrates how to make basic one-off calls. The default HTTP engine is |
| 91 | + shared with the new client. When the service client is closed as part of the `use { }` call, the runtime recognizes |
| 92 | + that others are still holding onto its HTTP engine and so it isn't fully closed. |
| 93 | +* The client passed to `doBackgroundRoutine` shares the default HTTP engine as well. Both clients be closed at any time |
| 94 | + in either order—the managed HTTP engine will only be closed when both clients using it are closed. |
| 95 | +* The user passes their own HTTP engine implementation in the `doBackgroundRoutine2` call, and thus is responsible for |
| 96 | + closing it separately from the client. |
| 97 | + |
| 98 | +# Rejected alternatives |
| 99 | + |
| 100 | +## Alt1: optional config on operation calls |
| 101 | + |
| 102 | +Operation methods have the ability to directly override config. There are a number of ways to implement this alternative: |
| 103 | +```kotlin |
| 104 | +interface ServiceClient : SdkClient { |
| 105 | + // alt1.1: defaulted param |
| 106 | + suspend fun operationA(input: OperationARequest, config: Config? = null): OperationAResponse |
| 107 | + |
| 108 | + // alt1.2: Trailing lambda builder |
| 109 | + suspend fun operationB(input: OperationBRequest, block: Config.Builder.() -> Unit = {}): OperationBResponse |
| 110 | + |
| 111 | + class Config { |
| 112 | + // alt1.1 is enhanced by a copy() method on client config |
| 113 | + fun copy(builder: Builder.() -> Unit): Config { |
| 114 | + // return new instance with overrides applied, sharing managed closeables like in the accepted design |
| 115 | + } |
| 116 | + } |
| 117 | +} |
| 118 | +``` |
| 119 | + |
| 120 | +Sample usage: |
| 121 | +```kotlin |
| 122 | +suspend fun main() { |
| 123 | + val client = ServiceClient { /* ... */ } |
| 124 | + |
| 125 | + val requestA = OperationARequest { /* ... */ } |
| 126 | + client.operationA(requestA) |
| 127 | + client.operationA(requestA, client.config.copy { |
| 128 | + logMode = SdkLogMode.LogRequestWithBody |
| 129 | + }) |
| 130 | + |
| 131 | + val requestB = OperationBRequest { /* ... */ } |
| 132 | + client.operationB(requestB) |
| 133 | + client.operationB { |
| 134 | + // existing builder extension - build request here |
| 135 | + } |
| 136 | + client.operationB(requestB) { |
| 137 | + logMode = SdkLogMode.LogResponseWithBody |
| 138 | + } |
| 139 | +} |
| 140 | +``` |
| 141 | + |
| 142 | +Rejected because: |
| 143 | + |
| 144 | +* Both introduce operation signature bloat. |
| 145 | +* Neither mix well with the adopted builder-based overloads that the SDK supports. Mixed usage of the two may adversely |
| 146 | + impact an author's ability to produce readable and maintainable code. |
| 147 | +* For alt1.1 specifically, the API design may misguide the caller into constructing a new config rather than performing |
| 148 | + a copy on the existing client config, and doing so could duplicate the creation of expensive resources (e.g. HTTP |
| 149 | + engine). |
| 150 | + |
| 151 | +## Alt2: request object builder |
| 152 | + |
| 153 | +Request builders have an SDK-specific config override call (proposed name `withConfig` used in this sample). Client |
| 154 | +implementations retrieve that builder and apply it to the config on specific requests. |
| 155 | +```kotlin |
| 156 | +// showing definition of sample client operationA input |
| 157 | +class OperationARequest private constructor(builder: Builder) { |
| 158 | + val requestValue: Int? = builder.requestValue |
| 159 | + // ... |
| 160 | + class Builder { |
| 161 | + var requestValue: Int? = null |
| 162 | + // ... |
| 163 | + fun withConfig(configBuilder: ServiceClient.Config.Builder.() -> Unit) { /* ... */ } |
| 164 | + } |
| 165 | +} |
| 166 | +``` |
| 167 | + |
| 168 | +Sample usage: |
| 169 | +```kotlin |
| 170 | +suspend fun main() { |
| 171 | + ServiceClient().use { |
| 172 | + it.operationA { |
| 173 | + requestValue = 1 |
| 174 | + } |
| 175 | + it.operationA { |
| 176 | + requestValue = 2 |
| 177 | + withConfig { |
| 178 | + logMode = SdkLogMode.LogRequestWithBody |
| 179 | + } |
| 180 | + } |
| 181 | + } |
| 182 | +} |
| 183 | +``` |
| 184 | + |
| 185 | +Rejected because: |
| 186 | + |
| 187 | +* Adding custom un-modeled fields to (Smithy) structures introduces the possibility of collision with future model |
| 188 | + updates. |
| 189 | +* Affords less flexibility. A caller may wish to structure a series of operations as a function which accepts an |
| 190 | + arbitrarily configured client—this route removes the possibility for that separation, forcing the caller to hardcode |
| 191 | + config overrides in every call where it is required. |
| 192 | +* Complicates supporting "shape-only" (generate only client interface and input/output shapes, no implementation) |
| 193 | + codegen in the future, since the `withConfig` property requires additional mechanisms to be generated into the structs |
| 194 | + in order to be retrieved by the implemented operation. |
| 195 | + |
| 196 | +## Alt3: `withConfig(configBuilder, useBlock)` |
| 197 | + |
| 198 | +Expose `withConfig` like in the accepted design, except it takes a direct `use` block. The inner client is closed after |
| 199 | +the block is invoked and is therefore only valid within that block. |
| 200 | + |
| 201 | +```kotlin |
| 202 | +fun ServiceClient.withConfig( |
| 203 | + configBuilder: ServiceClient.Config.Builder.() -> Unit, |
| 204 | + useBlock: (ServiceClient) -> Unit, |
| 205 | +) { |
| 206 | + // ... |
| 207 | +} |
| 208 | +``` |
| 209 | + |
| 210 | +Sample usage: |
| 211 | +```kotlin |
| 212 | +suspend fun main() { |
| 213 | + val client = ServiceClient { } |
| 214 | + client.operationA { /* ... */ } |
| 215 | + |
| 216 | + client.withConfig({ |
| 217 | + logMode = SdkLogMode.LogRequestWithBody + SdkLogMode.LogResponseWithBody |
| 218 | + }) { |
| 219 | + client.operationA { /* ... */ } |
| 220 | + } |
| 221 | +} |
| 222 | +``` |
| 223 | + |
| 224 | +This alternative was rejected in favor of the proposed design since it accomplishes the same thing while expanding the |
| 225 | +scope of use. The SDK can allow the caller to save and pass around the returned client as long as the runtime is |
| 226 | +responsible with managed closeable resources that are shared throughout. The use of two lambda arguments was also deemed |
| 227 | +non-idiomatic. |
| 228 | + |
| 229 | +Additionally, the pattern enforced by this API (client only lives within `useBlock`) can still be expressed in the |
| 230 | +accepted design simply by making the `use { }` call on the client itself, as demonstrated in its example usage. |
| 231 | + |
| 232 | +# Revision history |
| 233 | + |
| 234 | +* 12/14/2022 - Created |
0 commit comments