Skip to content

Commit 2fcc256

Browse files
authored
misc: add design doc for per-op config (#770)
1 parent a250c3e commit 2fcc256

File tree

2 files changed

+239
-0
lines changed

2 files changed

+239
-0
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"id": "770693e4-ae47-4620-b1d3-22cc01254bb6",
3+
"type": "misc",
4+
"description": "Add design document for per-op config."
5+
}

docs/design/per-op-config.md

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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

Comments
 (0)