Skip to content

Commit 863bb37

Browse files
Fix scope and limitkey path components in client
1 parent 2a4a915 commit 863bb37

2 files changed

Lines changed: 117 additions & 39 deletions

File tree

client-kotlin/src/test/kotlin/dev/restate/client/kotlin/ScopedClientTest.kt

Lines changed: 96 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,6 @@ import kotlinx.coroutines.test.runTest
3333
import org.assertj.core.api.Assertions.assertThat
3434
import org.junit.jupiter.api.Test
3535

36-
// --- Test service definitions (interfaces so the JDK proxy can be used, no bytebuddy needed) ---
37-
3836
@Service
3937
@Name("Greeter")
4038
private interface Greeter {
@@ -45,6 +43,8 @@ private interface Greeter {
4543
@Name("Counter")
4644
private interface Counter {
4745
@Exclusive suspend fun add(value: Int): Int
46+
47+
@Exclusive suspend fun label(name: String): String
4848
}
4949

5050
@Workflow
@@ -53,22 +53,24 @@ private interface SignupWorkflow {
5353
@Workflow suspend fun run(email: String): String
5454
}
5555

56-
/**
57-
* A [Client] test double built on top of [BaseClient] (so all the handle factory methods come for
58-
* free). It records the last [Request] that the API produced and returns canned responses without
59-
* hitting the network.
60-
*/
61-
private class RecordingClient :
56+
private object NoHeaders : ResponseHead.Headers {
57+
override fun get(key: String): String? = null
58+
59+
override fun keys(): Set<String> = emptySet()
60+
61+
override fun toLowercaseMap(): Map<String, String> = emptyMap()
62+
}
63+
64+
private class RequestRecordingClient(private val mockResponse: Any? = null) :
6265
BaseClient(URI.create("http://localhost:8080"), KotlinSerializationSerdeFactory(), null) {
6366

6467
var lastRequest: Request<*, *>? = null
6568
var lastDelay: Duration? = null
66-
var cannedResponse: Any? = null
6769

6870
override fun <Req, Res> callAsync(request: Request<Req, Res>): CompletableFuture<Response<Res>> {
6971
lastRequest = request
7072
@Suppress("UNCHECKED_CAST")
71-
return CompletableFuture.completedFuture(Response(200, NoHeaders, cannedResponse as Res))
73+
return CompletableFuture.completedFuture(Response(200, NoHeaders, mockResponse as Res))
7274
}
7375

7476
override fun <Req, Res> sendAsync(
@@ -83,9 +85,7 @@ private class RecordingClient :
8385

8486
override fun attachAsync(options: RequestOptions): CompletableFuture<Response<Res>> {
8587
@Suppress("UNCHECKED_CAST")
86-
return CompletableFuture.completedFuture(
87-
Response(200, NoHeaders, cannedResponse as Res)
88-
)
88+
return CompletableFuture.completedFuture(Response(200, NoHeaders, mockResponse as Res))
8989
}
9090

9191
override fun getOutputAsync(
@@ -97,7 +97,6 @@ private class RecordingClient :
9797
)
9898
}
9999

100-
// Never reached: callAsync/sendAsync are overridden to short-circuit the network.
101100
override fun <Res> doPostRequest(
102101
target: URI,
103102
headers: Stream<Map.Entry<String, String>>,
@@ -110,29 +109,47 @@ private class RecordingClient :
110109
headers: Stream<Map.Entry<String, String>>,
111110
responseMapper: ResponseMapper<Res>,
112111
): CompletableFuture<Res> = throw UnsupportedOperationException()
112+
}
113113

114-
private object NoHeaders : ResponseHead.Headers {
115-
override fun get(key: String): String? = null
114+
private class UriRecordingClient :
115+
BaseClient(URI.create("http://localhost:8080"), KotlinSerializationSerdeFactory(), null) {
116116

117-
override fun keys(): Set<String> = emptySet()
117+
var lastUri: URI? = null
118118

119-
override fun toLowercaseMap(): Map<String, String> = emptyMap()
119+
override fun <Res> doPostRequest(
120+
target: URI,
121+
headers: Stream<Map.Entry<String, String>>,
122+
payload: Slice,
123+
responseMapper: ResponseMapper<Res>,
124+
): CompletableFuture<Res> {
125+
lastUri = target
126+
val path = target.path
127+
val isSend = path.endsWith("/send") || path.contains("/send/")
128+
val body =
129+
if (isSend) Slice.wrap("""{"invocationId":"inv-123","status":"Accepted"}""")
130+
else Slice.wrap("\"ok\"")
131+
return CompletableFuture.completedFuture(responseMapper.mapResponse(200, NoHeaders, body))
120132
}
133+
134+
override fun <Res> doGetRequest(
135+
target: URI,
136+
headers: Stream<Map.Entry<String, String>>,
137+
responseMapper: ResponseMapper<Res>,
138+
): CompletableFuture<Res> = throw UnsupportedOperationException()
121139
}
122140

123141
class ScopedClientTest {
124142

125143
@Test
126144
fun `scope returns the platform ScopedClient, not a Kotlin-specific type`() {
127145
// Guards against the regression where a Kotlin Client.scope extension shadowed the Java member.
128-
val client = RecordingClient()
146+
val client = RequestRecordingClient()
129147
assertThat(client.scope("tenant-1")).isInstanceOf(ScopedClient::class.java)
130148
}
131149

132150
@Test
133151
fun `scoped service proxy routes the call within the scope`() = runTest {
134-
val client = RecordingClient()
135-
client.cannedResponse = "Hello Alice"
152+
val client = RequestRecordingClient("Hello Alice")
136153

137154
val response = client.scope("tenant-1").service<Greeter>().greet("Alice")
138155

@@ -147,8 +164,7 @@ class ScopedClientTest {
147164

148165
@Test
149166
fun `scoped virtualObject proxy carries scope and key`() = runTest {
150-
val client = RecordingClient()
151-
client.cannedResponse = 42
167+
val client = RequestRecordingClient(42)
152168

153169
val response = client.scope("tenant-1").virtualObject<Counter>("my-key").add(1)
154170

@@ -163,8 +179,7 @@ class ScopedClientTest {
163179

164180
@Test
165181
fun `scoped toService builder forwards idempotencyKey and limitKey`() = runTest {
166-
val client = RecordingClient()
167-
client.cannedResponse = "Hi Bob"
182+
val client = RequestRecordingClient("Hi Bob")
168183

169184
val response =
170185
client
@@ -189,8 +204,7 @@ class ScopedClientTest {
189204

190205
@Test
191206
fun `toService builder forwards limitKey even without a scope`() = runTest {
192-
val client = RecordingClient()
193-
client.cannedResponse = "Hi Carol"
207+
val client = RequestRecordingClient("Hi Carol")
194208

195209
client.toService<Greeter>().request { greet("Carol") }.options { limitKey = "limit-2" }.call()
196210

@@ -201,8 +215,7 @@ class ScopedClientTest {
201215

202216
@Test
203217
fun `scoped toWorkflow builder submits within the scope`() = runTest {
204-
val client = RecordingClient()
205-
client.cannedResponse = "queued"
218+
val client = RequestRecordingClient("queued")
206219

207220
val sendResponse =
208221
client
@@ -219,4 +232,58 @@ class ScopedClientTest {
219232
assertThat(request.target.handler).isEqualTo("run")
220233
assertThat(request.request).isEqualTo("a@b.com")
221234
}
235+
236+
@Test
237+
fun `scoped call encodes scope in the path`() = runTest {
238+
val client = UriRecordingClient()
239+
240+
client.scope("tenant-1").toService<Greeter>().request { greet("A") }.call()
241+
242+
assertThat(client.lastUri!!.path).isEqualTo("/restate/scope/tenant-1/call/Greeter/greet")
243+
assertThat(client.lastUri!!.query).isNull()
244+
}
245+
246+
@Test
247+
fun `scoped keyed send encodes scope, key and send verb in the path`() = runTest {
248+
val client = UriRecordingClient()
249+
250+
client.scope("tenant-1").toVirtualObject<Counter>("my-key").request { label("x") }.send()
251+
252+
assertThat(client.lastUri!!.path).isEqualTo("/restate/scope/tenant-1/send/Counter/my-key/label")
253+
}
254+
255+
@Test
256+
fun `scoped send with limit key uses the limit-key query param`() = runTest {
257+
val client = UriRecordingClient()
258+
259+
client
260+
.scope("tenant-1")
261+
.toService<Greeter>()
262+
.request { greet("A") }
263+
.options { limitKey = "api-key/user42" }
264+
.send()
265+
266+
assertThat(client.lastUri!!.path).isEqualTo("/restate/scope/tenant-1/send/Greeter/greet")
267+
// The runtime reads "limit-key" (kebab-case), not "limitKey".
268+
assertThat(client.lastUri!!.query).isEqualTo("limit-key=api-key/user42")
269+
}
270+
271+
@Test
272+
fun `unscoped call keeps the unversioned path`() = runTest {
273+
val client = UriRecordingClient()
274+
275+
client.toService<Greeter>().request { greet("A") }.call()
276+
277+
assertThat(client.lastUri!!.path).isEqualTo("/Greeter/greet")
278+
assertThat(client.lastUri!!.query).isNull()
279+
}
280+
281+
@Test
282+
fun `unscoped send keeps the unversioned path`() = runTest {
283+
val client = UriRecordingClient()
284+
285+
client.toService<Greeter>().request { greet("A") }.send()
286+
287+
assertThat(client.lastUri!!.path).isEqualTo("/Greeter/greet/send")
288+
}
222289
}

client/src/main/java/dev/restate/client/base/BaseClient.java

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -437,22 +437,33 @@ private String targetToURI(Target target) {
437437

438438
private URI toRequestURI(
439439
Target target, boolean isSend, @Nullable Duration delay, @Nullable String limitKey) {
440-
StringBuilder builder = new StringBuilder(targetToURI(target));
441-
if (isSend) {
442-
builder.append("/send");
443-
}
444-
String separator = "?";
440+
StringBuilder builder = new StringBuilder();
445441
if (target.getScope() != null) {
442+
// Scoped requests must use the versioned ingress API, which carries the scope in the path:
443+
// /restate/scope/{scopeKey}/{call|send}/{service}[/{key}]/{handler}
444+
// The scope is NOT accepted as a query parameter by the runtime.
446445
builder
447-
.append(separator)
448-
.append("scope=")
449-
.append(URLEncoder.encode(target.getScope(), StandardCharsets.UTF_8));
450-
separator = "&";
446+
.append("/restate/scope/")
447+
.append(URLEncoder.encode(target.getScope(), StandardCharsets.UTF_8))
448+
.append(isSend ? "/send" : "/call")
449+
.append(targetToURI(target));
450+
} else {
451+
// Unscoped requests use the unversioned ingress API:
452+
// /{service}[/{key}]/{handler}[/send]
453+
// TODO eventually drop this branch and always use the versioned /restate/{call|send}/... API.
454+
builder.append(targetToURI(target));
455+
if (isSend) {
456+
builder.append("/send");
457+
}
451458
}
459+
460+
String separator = "?";
452461
if (limitKey != null) {
462+
// The runtime reads the limit key from the "limit-key" query param (or the
463+
// x-restate-limit-key header); it requires a scope to be set.
453464
builder
454465
.append(separator)
455-
.append("limitKey=")
466+
.append("limit-key=")
456467
.append(URLEncoder.encode(limitKey, StandardCharsets.UTF_8));
457468
separator = "&";
458469
}

0 commit comments

Comments
 (0)