Skip to content

Commit 6fbb654

Browse files
authored
Add support for calls to HTTP endpoints (#271)
1 parent f209422 commit 6fbb654

File tree

26 files changed

+3185
-26
lines changed

26 files changed

+3185
-26
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
## Unreleased
2+
* Add support for calls to HTTP endpoints ([#271](https://github.com/microsoft/durabletask-java/pull/271))
23
* Add getSuspendPostUri and getResumePostUri getters to HttpManagementPayload ([#264](https://github.com/microsoft/durabletask-java/pull/264))
34

45
## v1.8.0

azurefunctions/build.gradle

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,22 @@ version = '1.8.0'
1010
archivesBaseName = 'durabletask-azure-functions'
1111

1212
def protocVersion = '3.25.8'
13+
// Java 11 is used to compile and run all tests. Set the JDK_11 env var to your
14+
// local JDK 11 home directory, e.g. C:/Program Files/Java/openjdk-11.0.12_7/
15+
// If unset, falls back to the current JDK running Gradle.
16+
def rawJdkPath = System.env.JDK_11 ?: System.getProperty("java.home")
17+
// Handle case where JDK_11 points to an executable (e.g., .../bin/javac.exe)
18+
// instead of the JDK home directory — walk up to the JDK root.
19+
def PATH_TO_TEST_JAVA_RUNTIME = rawJdkPath
20+
if (rawJdkPath != null) {
21+
def f = new File(rawJdkPath)
22+
if (f.isFile()) {
23+
PATH_TO_TEST_JAVA_RUNTIME = f.parentFile.parentFile.absolutePath
24+
}
25+
}
26+
// Append .exe on Windows when invoking javac/java directly
27+
def isWindows = System.getProperty("os.name").toLowerCase().contains("win")
28+
def exeSuffix = isWindows ? ".exe" : ""
1329

1430
repositories {
1531
maven {
@@ -22,18 +38,30 @@ dependencies {
2238
implementation group: 'com.microsoft.azure.functions', name: 'azure-functions-java-library', version: '3.2.3'
2339
implementation "com.google.protobuf:protobuf-java:${protocVersion}"
2440
compileOnly "com.microsoft.azure.functions:azure-functions-java-spi:1.1.0"
41+
42+
// Test dependencies
43+
testImplementation 'org.mockito:mockito-core:5.21.0'
44+
testImplementation 'org.mockito:mockito-junit-jupiter:5.21.0'
2545
testImplementation platform('org.junit:junit-bom:5.14.2')
2646
testImplementation 'org.junit.jupiter:junit-jupiter'
2747
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
2848
}
2949

50+
sourceCompatibility = JavaVersion.VERSION_1_8
51+
targetCompatibility = JavaVersion.VERSION_1_8
52+
53+
compileTestJava {
54+
sourceCompatibility = JavaVersion.VERSION_11
55+
targetCompatibility = JavaVersion.VERSION_11
56+
options.fork = true
57+
options.forkOptions.executable = "${PATH_TO_TEST_JAVA_RUNTIME}/bin/javac${exeSuffix}"
58+
}
59+
3060
test {
3161
useJUnitPlatform()
62+
executable = "${PATH_TO_TEST_JAVA_RUNTIME}/bin/java${exeSuffix}"
3263
}
3364

34-
sourceCompatibility = JavaVersion.VERSION_1_8
35-
targetCompatibility = JavaVersion.VERSION_1_8
36-
3765
publishing {
3866
repositories {
3967
maven {
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
package com.microsoft.durabletask.azurefunctions;
4+
5+
import com.microsoft.durabletask.DurableHttp;
6+
import com.microsoft.durabletask.DurableHttpRequest;
7+
import com.microsoft.durabletask.DurableHttpResponse;
8+
import com.microsoft.durabletask.ManagedIdentityOptions;
9+
import com.microsoft.durabletask.ManagedIdentityTokenSource;
10+
import com.microsoft.durabletask.Task;
11+
import com.microsoft.durabletask.TaskOptions;
12+
import com.microsoft.durabletask.TaskOrchestrationContext;
13+
import org.junit.jupiter.api.DisplayName;
14+
import org.junit.jupiter.api.Test;
15+
import org.junit.jupiter.api.extension.ExtendWith;
16+
import org.mockito.ArgumentCaptor;
17+
import org.mockito.Mock;
18+
import org.mockito.junit.jupiter.MockitoExtension;
19+
20+
import com.microsoft.durabletask.RetryPolicy;
21+
import java.net.URI;
22+
import java.time.Duration;
23+
import java.util.Collections;
24+
import java.util.List;
25+
import java.util.Map;
26+
27+
import static org.junit.jupiter.api.Assertions.*;
28+
import static org.mockito.ArgumentMatchers.*;
29+
import static org.mockito.Mockito.*;
30+
31+
/**
32+
* Unit tests for {@link DurableHttp}.
33+
*/
34+
@ExtendWith(MockitoExtension.class)
35+
class DurableHttpTest {
36+
37+
@Mock
38+
private TaskOrchestrationContext ctx;
39+
40+
@Mock
41+
private Task<DurableHttpResponse> mockTask;
42+
43+
// ---- Null validation tests ----
44+
45+
@Test
46+
@DisplayName("callHttp(ctx, request): null ctx throws IllegalArgumentException")
47+
void callHttp_nullCtx_throws() {
48+
DurableHttpRequest request = new DurableHttpRequest("GET", URI.create("https://example.com"));
49+
assertThrows(IllegalArgumentException.class, () -> DurableHttp.callHttp(null, request));
50+
}
51+
52+
@Test
53+
@DisplayName("callHttp(ctx, request): null request throws IllegalArgumentException")
54+
void callHttp_nullRequest_throws() {
55+
assertThrows(IllegalArgumentException.class, () -> DurableHttp.callHttp(ctx, (DurableHttpRequest) null));
56+
}
57+
58+
@Test
59+
@DisplayName("callHttp(ctx, request, options): null ctx throws IllegalArgumentException")
60+
void callHttp_withOptions_nullCtx_throws() {
61+
DurableHttpRequest request = new DurableHttpRequest("GET", URI.create("https://example.com"));
62+
assertThrows(IllegalArgumentException.class, () -> DurableHttp.callHttp(null, request, null));
63+
}
64+
65+
@Test
66+
@DisplayName("callHttp(ctx, request, options): null request throws IllegalArgumentException")
67+
void callHttp_withOptions_nullRequest_throws() {
68+
assertThrows(IllegalArgumentException.class,
69+
() -> DurableHttp.callHttp(ctx, (DurableHttpRequest) null, null));
70+
}
71+
72+
// ---- Delegation tests ----
73+
74+
@SuppressWarnings("unchecked")
75+
@Test
76+
@DisplayName("callHttp(ctx, request): delegates to callActivity with wrapped list input")
77+
void callHttp_delegatesToCallActivity() {
78+
DurableHttpRequest request = new DurableHttpRequest("GET", URI.create("https://example.com"));
79+
ArgumentCaptor<List<DurableHttpRequest>> captor = ArgumentCaptor.forClass(List.class);
80+
when(ctx.callActivity(eq("BuiltIn::HttpActivity"), captor.capture(), eq(DurableHttpResponse.class)))
81+
.thenReturn(mockTask);
82+
83+
Task<DurableHttpResponse> result = DurableHttp.callHttp(ctx, request);
84+
85+
assertSame(mockTask, result);
86+
List<DurableHttpRequest> captured = captor.getValue();
87+
assertEquals(1, captured.size());
88+
assertSame(request, captured.get(0));
89+
}
90+
91+
@SuppressWarnings("unchecked")
92+
@Test
93+
@DisplayName("callHttp(ctx, request, options): delegates with TaskOptions when options is non-null")
94+
void callHttp_withOptions_delegatesWithTaskOptions() {
95+
DurableHttpRequest request = new DurableHttpRequest("POST", URI.create("https://example.com/api"));
96+
TaskOptions options = new TaskOptions(new RetryPolicy(3, Duration.ofSeconds(1)));
97+
ArgumentCaptor<List<DurableHttpRequest>> captor = ArgumentCaptor.forClass(List.class);
98+
when(ctx.callActivity(eq("BuiltIn::HttpActivity"), captor.capture(), eq(options), eq(DurableHttpResponse.class)))
99+
.thenReturn(mockTask);
100+
101+
Task<DurableHttpResponse> result = DurableHttp.callHttp(ctx, request, options);
102+
103+
assertSame(mockTask, result);
104+
List<DurableHttpRequest> captured = captor.getValue();
105+
assertEquals(1, captured.size());
106+
assertSame(request, captured.get(0));
107+
}
108+
109+
@SuppressWarnings("unchecked")
110+
@Test
111+
@DisplayName("callHttp(ctx, request, null options): delegates without TaskOptions")
112+
void callHttp_withNullOptions_delegatesWithoutTaskOptions() {
113+
DurableHttpRequest request = new DurableHttpRequest("GET", URI.create("https://example.com"));
114+
ArgumentCaptor<List<DurableHttpRequest>> captor = ArgumentCaptor.forClass(List.class);
115+
when(ctx.callActivity(eq("BuiltIn::HttpActivity"), captor.capture(), eq(DurableHttpResponse.class)))
116+
.thenReturn(mockTask);
117+
118+
Task<DurableHttpResponse> result = DurableHttp.callHttp(ctx, request, null);
119+
120+
assertSame(mockTask, result);
121+
assertEquals(1, captor.getValue().size());
122+
assertSame(request, captor.getValue().get(0));
123+
verify(ctx, never()).callActivity(anyString(), any(), any(TaskOptions.class), any());
124+
}
125+
126+
// ---- Convenience method tests ----
127+
128+
@SuppressWarnings("unchecked")
129+
@Test
130+
@DisplayName("callHttp(ctx, method, uri): creates DurableHttpRequest and delegates")
131+
void callHttp_convenience_methodUri() {
132+
ArgumentCaptor<List<DurableHttpRequest>> captor = ArgumentCaptor.forClass(List.class);
133+
when(ctx.callActivity(eq("BuiltIn::HttpActivity"), captor.capture(), eq(DurableHttpResponse.class)))
134+
.thenReturn(mockTask);
135+
136+
URI uri = URI.create("https://httpbin.org/get");
137+
Task<DurableHttpResponse> result = DurableHttp.callHttp(ctx, "GET", uri);
138+
139+
assertSame(mockTask, result);
140+
DurableHttpRequest captured = captor.getValue().get(0);
141+
assertEquals("GET", captured.getMethod());
142+
assertEquals(uri, captured.getUri());
143+
assertNull(captured.getHeaders());
144+
assertNull(captured.getContent());
145+
}
146+
147+
@SuppressWarnings("unchecked")
148+
@Test
149+
@DisplayName("callHttp(ctx, method, uri, headers, content): creates DurableHttpRequest and delegates")
150+
void callHttp_convenience_headersContent() {
151+
ArgumentCaptor<List<DurableHttpRequest>> captor = ArgumentCaptor.forClass(List.class);
152+
when(ctx.callActivity(eq("BuiltIn::HttpActivity"), captor.capture(), eq(DurableHttpResponse.class)))
153+
.thenReturn(mockTask);
154+
155+
URI uri = URI.create("https://httpbin.org/post");
156+
Map<String, String> headers = Collections.singletonMap("Content-Type", "application/json");
157+
String content = "{\"key\":\"value\"}";
158+
159+
Task<DurableHttpResponse> result = DurableHttp.callHttp(ctx, "POST", uri, headers, content);
160+
161+
assertSame(mockTask, result);
162+
DurableHttpRequest captured = captor.getValue().get(0);
163+
assertEquals("POST", captured.getMethod());
164+
assertEquals(uri, captured.getUri());
165+
assertEquals(headers, captured.getHeaders());
166+
assertEquals(content, captured.getContent());
167+
}
168+
169+
@SuppressWarnings("unchecked")
170+
@Test
171+
@DisplayName("callHttp(ctx, method, uri, null, null): null headers and content are allowed")
172+
void callHttp_convenience_nullHeadersContent() {
173+
ArgumentCaptor<List<DurableHttpRequest>> captor = ArgumentCaptor.forClass(List.class);
174+
when(ctx.callActivity(eq("BuiltIn::HttpActivity"), captor.capture(), eq(DurableHttpResponse.class)))
175+
.thenReturn(mockTask);
176+
177+
Task<DurableHttpResponse> result = DurableHttp.callHttp(
178+
ctx, "DELETE", URI.create("https://example.com/resource"), null, null);
179+
180+
assertSame(mockTask, result);
181+
DurableHttpRequest captured = captor.getValue().get(0);
182+
assertEquals("DELETE", captured.getMethod());
183+
assertNull(captured.getHeaders());
184+
assertNull(captured.getContent());
185+
}
186+
187+
// ---- Activity name tests ----
188+
189+
@Test
190+
@DisplayName("BUILT_IN_HTTP_ACTIVITY_NAME is 'BuiltIn::HttpActivity'")
191+
void builtInActivityName() {
192+
assertEquals("BuiltIn::HttpActivity", DurableHttp.BUILT_IN_HTTP_ACTIVITY_NAME);
193+
}
194+
195+
// ---- TokenSource / Managed Identity delegation tests ----
196+
197+
@SuppressWarnings("unchecked")
198+
@Test
199+
@DisplayName("callHttp: request with ManagedIdentityTokenSource passes token source to activity")
200+
void callHttp_withManagedIdentityTokenSource_passesThrough() {
201+
ManagedIdentityTokenSource tokenSource = new ManagedIdentityTokenSource(
202+
"https://management.core.windows.net/.default");
203+
DurableHttpRequest request = new DurableHttpRequest("GET",
204+
URI.create("https://example.com"), null, null, tokenSource);
205+
206+
ArgumentCaptor<List<DurableHttpRequest>> captor = ArgumentCaptor.forClass(List.class);
207+
when(ctx.callActivity(eq("BuiltIn::HttpActivity"), captor.capture(), eq(DurableHttpResponse.class)))
208+
.thenReturn(mockTask);
209+
210+
Task<DurableHttpResponse> result = DurableHttp.callHttp(ctx, request);
211+
212+
assertSame(mockTask, result);
213+
DurableHttpRequest captured = captor.getValue().get(0);
214+
assertNotNull(captured.getTokenSource());
215+
assertInstanceOf(ManagedIdentityTokenSource.class, captured.getTokenSource());
216+
ManagedIdentityTokenSource capturedToken = (ManagedIdentityTokenSource) captured.getTokenSource();
217+
assertEquals("https://management.core.windows.net/.default", capturedToken.getResource());
218+
assertEquals("AzureManagedIdentity", capturedToken.getKind());
219+
assertNull(capturedToken.getOptions());
220+
}
221+
222+
@SuppressWarnings("unchecked")
223+
@Test
224+
@DisplayName("callHttp: request with ManagedIdentityTokenSource and options passes both through")
225+
void callHttp_withManagedIdentityAndOptions_passesThrough() {
226+
ManagedIdentityOptions options = new ManagedIdentityOptions(
227+
URI.create("https://login.microsoftonline.com/"), "tenant_id");
228+
ManagedIdentityTokenSource tokenSource = new ManagedIdentityTokenSource(
229+
"https://graph.microsoft.com/.default", options);
230+
DurableHttpRequest request = new DurableHttpRequest("GET",
231+
URI.create("https://example.com"), null, null, tokenSource);
232+
233+
ArgumentCaptor<List<DurableHttpRequest>> captor = ArgumentCaptor.forClass(List.class);
234+
when(ctx.callActivity(eq("BuiltIn::HttpActivity"), captor.capture(), eq(DurableHttpResponse.class)))
235+
.thenReturn(mockTask);
236+
237+
Task<DurableHttpResponse> result = DurableHttp.callHttp(ctx, request);
238+
239+
assertSame(mockTask, result);
240+
DurableHttpRequest captured = captor.getValue().get(0);
241+
assertNotNull(captured.getTokenSource());
242+
ManagedIdentityTokenSource capturedToken = (ManagedIdentityTokenSource) captured.getTokenSource();
243+
assertEquals("https://graph.microsoft.com/.default", capturedToken.getResource());
244+
assertNotNull(capturedToken.getOptions());
245+
assertEquals(URI.create("https://login.microsoftonline.com/"),
246+
capturedToken.getOptions().getAuthorityHost());
247+
assertEquals("tenant_id", capturedToken.getOptions().getTenantId());
248+
}
249+
250+
@SuppressWarnings("unchecked")
251+
@Test
252+
@DisplayName("callHttp: request with ManagedIdentityTokenSource + TaskOptions passes both through")
253+
void callHttp_withManagedIdentityAndTaskOptions_passesThrough() {
254+
ManagedIdentityTokenSource tokenSource = new ManagedIdentityTokenSource(
255+
"https://vault.azure.net/.default");
256+
DurableHttpRequest request = new DurableHttpRequest("GET",
257+
URI.create("https://myvault.vault.azure.net/secrets/mysecret"),
258+
null, null, tokenSource);
259+
TaskOptions taskOptions = new TaskOptions(new RetryPolicy(3, Duration.ofSeconds(1)));
260+
261+
ArgumentCaptor<List<DurableHttpRequest>> captor = ArgumentCaptor.forClass(List.class);
262+
when(ctx.callActivity(eq("BuiltIn::HttpActivity"), captor.capture(),
263+
eq(taskOptions), eq(DurableHttpResponse.class)))
264+
.thenReturn(mockTask);
265+
266+
Task<DurableHttpResponse> result = DurableHttp.callHttp(ctx, request, taskOptions);
267+
268+
assertSame(mockTask, result);
269+
DurableHttpRequest captured = captor.getValue().get(0);
270+
assertNotNull(captured.getTokenSource());
271+
assertInstanceOf(ManagedIdentityTokenSource.class, captured.getTokenSource());
272+
assertEquals("https://vault.azure.net/.default",
273+
((ManagedIdentityTokenSource) captured.getTokenSource()).getResource());
274+
}
275+
276+
@SuppressWarnings("unchecked")
277+
@Test
278+
@DisplayName("callHttp: request without tokenSource passes null tokenSource")
279+
void callHttp_withoutTokenSource_passesNullTokenSource() {
280+
DurableHttpRequest request = new DurableHttpRequest("GET", URI.create("https://example.com"));
281+
282+
ArgumentCaptor<List<DurableHttpRequest>> captor = ArgumentCaptor.forClass(List.class);
283+
when(ctx.callActivity(eq("BuiltIn::HttpActivity"), captor.capture(), eq(DurableHttpResponse.class)))
284+
.thenReturn(mockTask);
285+
286+
DurableHttp.callHttp(ctx, request);
287+
288+
assertNull(captor.getValue().get(0).getTokenSource());
289+
}
290+
}

0 commit comments

Comments
 (0)