Skip to content

Commit 97ae478

Browse files
kvmiloscopybara-github
authored andcommitted
fix(web): make the dev UI work behind a path-stripping reverse proxy
Serves the dev UI at `/dev-ui/` with its assets mounted there, derives the backend URL from the request so API calls carry a proxy's path prefix, and adds the `/version` and `/health` endpoints the UI asks for on startup. PiperOrigin-RevId: 977785275
1 parent 9bcfffd commit 97ae478

10 files changed

Lines changed: 589 additions & 42 deletions

File tree

dev/README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,19 @@
1-
ADK development utilities such as Spring REST server for agent.
1+
ADK development utilities such as Spring REST server for agent.
2+
3+
## Serving the dev UI behind a reverse proxy
4+
5+
When the server is published under a path prefix — say a gateway routing
6+
`https://gateway.example.com/my-app/` to it and stripping `/my-app` — set:
7+
8+
```properties
9+
server.forward-headers-strategy=framework
10+
```
11+
12+
and have the proxy send `X-Forwarded-Prefix`. Spring then puts the prefix in the
13+
request's context path, and the dev UI picks it up automatically: the redirect
14+
from `/` lands on the prefixed UI, and the UI's own API calls carry the prefix.
15+
Nothing else needs configuring.
16+
17+
Only `framework` works. `native` hands off to Tomcat's `RemoteIpValve`, which
18+
handles host, port and protocol but has no notion of a path prefix, so the prefix
19+
is silently dropped.

dev/src/main/java/com/google/adk/web/AdkWebServer.java

Lines changed: 19 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import com.google.adk.memory.InMemoryMemoryService;
2626
import com.google.adk.sessions.BaseSessionService;
2727
import com.google.adk.sessions.InMemorySessionService;
28+
import com.google.adk.web.config.DevUiAssets;
2829
import org.slf4j.Logger;
2930
import org.slf4j.LoggerFactory;
3031
import org.springframework.beans.factory.annotation.Value;
@@ -109,48 +110,33 @@ public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter(
109110
}
110111

111112
/**
112-
* Configures resource handlers for serving static content (like the Dev UI). Maps requests
113-
* starting with "/dev-ui/" to the directory specified by the 'adk.web.ui.dir' system property.
113+
* Maps requests under "/dev-ui/" to the directory named by the 'adk.web.ui.dir' property, or to
114+
* the bundled copy on the classpath when that is unset.
114115
*/
115116
@Override
116117
public void addResourceHandlers(ResourceHandlerRegistry registry) {
117-
if (webUiDir != null && !webUiDir.isEmpty()) {
118-
// Ensure the path uses forward slashes and ends with a slash
119-
String location = webUiDir.replace("\\", "/");
120-
if (!location.startsWith("file:")) {
121-
location = "file:" + location; // Ensure file: prefix
122-
}
123-
if (!location.endsWith("/")) {
124-
location += "/";
125-
}
126-
log.debug("Mapping URL path /** to static resources at location: {}", location);
127-
registry
128-
.addResourceHandler("/**")
129-
.addResourceLocations(location)
130-
.setCachePeriod(0)
131-
.resourceChain(true);
132-
133-
} else {
134-
log.debug(
135-
"System property 'adk.web.ui.dir' or config 'adk.web.ui.dir' is not set. Mapping URL path"
136-
+ " /** to classpath:/browser/");
137-
registry
138-
.addResourceHandler("/**")
139-
.addResourceLocations("classpath:/browser/")
140-
.setCachePeriod(0)
141-
.resourceChain(true);
142-
}
118+
String location = DevUiAssets.assetRoot(webUiDir);
119+
log.debug("Mapping URL path /dev-ui/** to static resources at location: {}", location);
120+
registry
121+
.addResourceHandler("/dev-ui/**")
122+
.addResourceLocations(location)
123+
.setCachePeriod(0)
124+
.resourceChain(true);
143125
}
144126

145127
/**
146-
* Configures simple automated controllers: - Redirects the root path "/" to "/dev-ui". - Forwards
147-
* requests to "/dev-ui" to "/dev-ui/index.html" so the ResourceHandler serves it.
128+
* Configures simple automated controllers: "/" and "/dev-ui" both redirect to "/dev-ui/", which
129+
* forwards to the UI's index.html. The trailing slash is load-bearing: index.html declares a
130+
* {@code <base href="./">}, so served from "/dev-ui" the app resolves its own router path to
131+
* "dev-ui" and matches none of its routes. The query string is carried across because the UI
132+
* selects its agent from {@code ?app=} and the sample READMEs send users to the slashless
133+
* "/dev-ui", so a redirect that dropped it would silently ignore the selection.
148134
*/
149135
@Override
150136
public void addViewControllers(ViewControllerRegistry registry) {
151-
registry.addRedirectViewController("/", "/dev-ui");
152-
registry.addViewController("/dev-ui").setViewName("forward:/index.html");
153-
registry.addViewController("/dev-ui/").setViewName("forward:/index.html");
137+
registry.addRedirectViewController("/", "/dev-ui/").setKeepQueryParams(true);
138+
registry.addRedirectViewController("/dev-ui", "/dev-ui/").setKeepQueryParams(true);
139+
registry.addViewController("/dev-ui/").setViewName("forward:/dev-ui/index.html");
154140
}
155141

156142
/**
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.web.config;
18+
19+
import org.jspecify.annotations.Nullable;
20+
import org.springframework.core.io.ResourceLoader;
21+
22+
/**
23+
* Where the dev UI's static assets live. Shared so the resource handler and the runtime-config
24+
* endpoint resolve the same location; normalizing {@code adk.web.ui.dir} separately in each would
25+
* diverge silently.
26+
*/
27+
public final class DevUiAssets {
28+
29+
/** The runtime config, relative to the asset root. */
30+
public static final String RUNTIME_CONFIG_PATH = "assets/config/runtime-config.json";
31+
32+
private static final String CLASSPATH_ROOT = ResourceLoader.CLASSPATH_URL_PREFIX + "/browser/";
33+
34+
/**
35+
* The asset root: {@code webUiDir} as a {@code file:} URL when set, else the bundled classpath
36+
* copy. Always ends in a slash.
37+
*/
38+
public static String assetRoot(@Nullable String webUiDir) {
39+
if (webUiDir == null || webUiDir.isEmpty()) {
40+
return CLASSPATH_ROOT;
41+
}
42+
String location = webUiDir.replace("\\", "/");
43+
if (!location.startsWith("file:")) {
44+
location = "file:" + location;
45+
}
46+
return location.endsWith("/") ? location : location + "/";
47+
}
48+
49+
/** The location of a single asset, given relative to the asset root. */
50+
public static String assetLocation(@Nullable String webUiDir, String relativePath) {
51+
return assetRoot(webUiDir) + relativePath;
52+
}
53+
54+
private DevUiAssets() {}
55+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.web.controller;
18+
19+
import com.fasterxml.jackson.core.type.TypeReference;
20+
import com.fasterxml.jackson.databind.ObjectMapper;
21+
import com.google.adk.web.config.DevUiAssets;
22+
import jakarta.servlet.http.HttpServletRequest;
23+
import java.io.IOException;
24+
import java.io.InputStream;
25+
import java.util.LinkedHashMap;
26+
import java.util.Map;
27+
import org.jspecify.annotations.Nullable;
28+
import org.slf4j.Logger;
29+
import org.slf4j.LoggerFactory;
30+
import org.springframework.beans.factory.annotation.Autowired;
31+
import org.springframework.beans.factory.annotation.Value;
32+
import org.springframework.core.io.Resource;
33+
import org.springframework.core.io.ResourceLoader;
34+
import org.springframework.http.CacheControl;
35+
import org.springframework.http.MediaType;
36+
import org.springframework.http.ResponseEntity;
37+
import org.springframework.web.bind.annotation.GetMapping;
38+
import org.springframework.web.bind.annotation.RestController;
39+
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
40+
41+
/**
42+
* Serves the dev UI's runtime configuration, shadowing the copy bundled in the static assets so
43+
* {@code backendUrl} can name the address the browser actually reached this server on. The bundled
44+
* document is merged rather than replaced, so keys the UI gains in a later bundle survive.
45+
*/
46+
@RestController
47+
public class RuntimeConfigController {
48+
49+
private static final Logger log = LoggerFactory.getLogger(RuntimeConfigController.class);
50+
51+
private final ResourceLoader resourceLoader;
52+
private final ObjectMapper objectMapper;
53+
private final @Nullable String webUiDir;
54+
55+
@Autowired
56+
public RuntimeConfigController(
57+
ResourceLoader resourceLoader,
58+
ObjectMapper objectMapper,
59+
@Value("${adk.web.ui.dir:#{null}}") @Nullable String webUiDir) {
60+
this.resourceLoader = resourceLoader;
61+
this.objectMapper = objectMapper;
62+
this.webUiDir = webUiDir;
63+
}
64+
65+
@GetMapping(
66+
value = "/dev-ui/" + DevUiAssets.RUNTIME_CONFIG_PATH,
67+
produces = MediaType.APPLICATION_JSON_VALUE)
68+
public ResponseEntity<Map<String, Object>> runtimeConfig(HttpServletRequest request) {
69+
Map<String, Object> config = readBundledConfig();
70+
config.put("backendUrl", backendUrlFor(request));
71+
// The static handler this shadows is registered with setCachePeriod(0), and the value now
72+
// varies by request, so it must not be cached.
73+
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(config);
74+
}
75+
76+
/**
77+
* Where the browser should send API calls: empty when this server is at the root, else the
78+
* absolute base URL it was reached on. Behind a proxy that strips a path prefix, {@code
79+
* ForwardedHeaderFilter} puts that prefix in the context path, so this recovers it without the
80+
* deployment configuring anything — provided {@code server.forward-headers-strategy=framework} is
81+
* set and the proxy sends {@code X-Forwarded-Prefix}.
82+
*
83+
* <p>Absolute rather than just the prefix because the UI reads a value without a scheme as the
84+
* host of its live/websocket connection.
85+
*/
86+
private static String backendUrlFor(HttpServletRequest request) {
87+
String contextPath = request.getContextPath();
88+
if (contextPath == null || contextPath.isEmpty()) {
89+
return "";
90+
}
91+
return ServletUriComponentsBuilder.fromContextPath(request).toUriString();
92+
}
93+
94+
/**
95+
* The bundled config, or an empty document when it is absent or unreadable. A dev UI that cannot
96+
* read its own config is worse than one whose extra keys defaulted, so this never fails the
97+
* request.
98+
*/
99+
private Map<String, Object> readBundledConfig() {
100+
Resource resource =
101+
resourceLoader.getResource(
102+
DevUiAssets.assetLocation(webUiDir, DevUiAssets.RUNTIME_CONFIG_PATH));
103+
if (!resource.exists()) {
104+
log.debug("No bundled dev UI runtime config at {}; serving backendUrl only.", resource);
105+
return new LinkedHashMap<>();
106+
}
107+
try (InputStream in = resource.getInputStream()) {
108+
Map<String, Object> parsed = objectMapper.readValue(in, new TypeReference<>() {});
109+
return parsed == null ? new LinkedHashMap<>() : new LinkedHashMap<>(parsed);
110+
} catch (IOException e) {
111+
log.warn("Could not read the bundled dev UI runtime config at {}.", resource, e);
112+
return new LinkedHashMap<>();
113+
}
114+
}
115+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.web.controller;
18+
19+
import com.google.adk.Version;
20+
import java.util.Map;
21+
import org.springframework.http.MediaType;
22+
import org.springframework.web.bind.annotation.GetMapping;
23+
import org.springframework.web.bind.annotation.RestController;
24+
25+
/**
26+
* Reports the ADK and language versions, which the dev UI requests on startup, plus a liveness
27+
* endpoint. Mirrors the {@code /version} and {@code /health} endpoints ADK Python serves.
28+
*/
29+
@RestController
30+
public class VersionController {
31+
32+
@GetMapping(value = "/version", produces = MediaType.APPLICATION_JSON_VALUE)
33+
public Map<String, String> version() {
34+
return Map.of(
35+
"version",
36+
Version.JAVA_ADK_VERSION,
37+
"language",
38+
"java",
39+
"language_version",
40+
System.getProperty("java.version", "unknown"));
41+
}
42+
43+
@GetMapping(value = "/health", produces = MediaType.APPLICATION_JSON_VALUE)
44+
public Map<String, String> health() {
45+
return Map.of("status", "ok");
46+
}
47+
}

dev/src/test/java/com/google/adk/web/AdkWebServerUITest.java

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,22 +40,46 @@ public class AdkWebServerUITest {
4040

4141
@Autowired private MockMvc mockMvc;
4242

43-
@Test
44-
public void rootShouldRedirectToDevUi() throws Exception {
43+
@ParameterizedTest
44+
@ValueSource(strings = {"/", "/dev-ui"})
45+
public void devUiEntryPoints_shouldRedirectToTrailingSlashForm(String path) throws Exception {
46+
// index.html declares <base href="./">, which only resolves correctly from "/dev-ui/", so both
47+
// entry points have to land there rather than serving the app directly.
4548
mockMvc
46-
.perform(get("/"))
49+
.perform(get(path))
4750
.andExpect(status().is3xxRedirection())
48-
.andExpect(redirectedUrl("/dev-ui"));
51+
.andExpect(redirectedUrl("/dev-ui/"));
52+
}
53+
54+
@Test
55+
public void devUi_shouldBeServedAtTrailingSlashForm() throws Exception {
56+
mockMvc.perform(get("/dev-ui/")).andExpect(status().isOk());
57+
}
58+
59+
@Test
60+
public void devUiAssets_shouldBeServedBelowDevUi() throws Exception {
61+
mockMvc.perform(get("/dev-ui/adk_favicon.svg")).andExpect(status().isOk());
4962
}
5063

5164
@ParameterizedTest
52-
@ValueSource(strings = {"/dev-ui", "/dev-ui/"})
53-
public void devUiEndpointsShouldReturnOk(String path) throws Exception {
54-
mockMvc.perform(get(path)).andExpect(status().isOk());
65+
@ValueSource(strings = {"/", "/dev-ui"})
66+
public void devUiEntryPoints_shouldKeepQueryString(String path) throws Exception {
67+
// The UI picks its agent from ?app=, and the sample READMEs send users to "/dev-ui".
68+
mockMvc
69+
.perform(get(path + "?app=my-agent"))
70+
.andExpect(status().is3xxRedirection())
71+
.andExpect(redirectedUrl("/dev-ui/?app=my-agent"));
72+
}
73+
74+
@Test
75+
public void devUiAssets_shouldNotBeServedAtRoot() throws Exception {
76+
// The handler used to map "/**", so this was reachable. Narrowing it to "/dev-ui/**" is
77+
// deliberate: ADK Python has only ever mounted the assets under /dev-ui/.
78+
mockMvc.perform(get("/adk_favicon.svg")).andExpect(status().isNotFound());
5579
}
5680

5781
@Test
58-
public void nonExistentUiPageShouldReturnNotFound() throws Exception {
82+
public void nonExistentUiPage_shouldReturnNotFound() throws Exception {
5983
mockMvc.perform(get("/non-existent-page")).andExpect(status().isNotFound());
6084
}
6185
}

0 commit comments

Comments
 (0)