Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion dev/README.md
Original file line number Diff line number Diff line change
@@ -1 +1,33 @@
ADK development utilities such as Spring REST server for agent.
ADK development utilities such as Spring REST server for agent.

## Serving the dev UI

The UI and its assets are served under `/dev-ui/`, and both `/` and `/dev-ui`
redirect there, keeping the query string. The assets are not served from the
origin root: `/adk_favicon.svg` and the like return 404, and only the `/dev-ui/`
form resolves.

## Behind a reverse proxy

When a gateway publishes this server under a path prefix and strips it, tell the
server the address browsers actually reach it on:

```properties
adk.web.backend-url=https://gateway.example.com/my-app
```

That one value does both halves: the entry redirect carries the prefix, and the
UI's own API calls go back through it. Nothing has to be forwarded by the proxy,
and nothing is read from the request.

It must be an absolute URL. The UI reads a value without a scheme as the host of
its live/websocket connection, so a bare `/my-app` makes that socket dial a host
named `my-app`.

Include any `server.servlet.context-path` in the value: in the redirect it
replaces the context path rather than stacking on it.

Leave it unset and nothing changes: the redirect is unprefixed and the bundled
`backendUrl` is served as it always was. A deployment that already restores the
prefix with Spring's own `server.forward-headers-strategy=framework` keeps
working that way; setting this property takes precedence over it.
63 changes: 30 additions & 33 deletions dev/src/main/java/com/google/adk/web/AdkWebServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import com.google.adk.memory.InMemoryMemoryService;
import com.google.adk.sessions.BaseSessionService;
import com.google.adk.sessions.InMemorySessionService;
import com.google.adk.web.config.DevUiAssets;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
Expand All @@ -51,6 +52,9 @@
@Value("${adk.web.ui.dir:#{null}}")
private String webUiDir;

@Value("${adk.web.backend-url:}")
private String backendUrl;

@Bean
public BaseSessionService sessionService() {
// TODO: Add logic to select service based on config (e.g., DB URL)
Expand Down Expand Up @@ -103,54 +107,47 @@
* @return A configured MappingJackson2HttpMessageConverter.
*/
@Bean
public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter(

Check warning on line 110 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (17)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 110 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (17)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 110 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (17)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 110 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (17)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 110 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (21)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 110 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (21)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 110 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (21)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 110 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (21)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 110 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (25)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 110 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (25)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 110 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (25)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 110 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (25)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal
ObjectMapper objectMapper) {
return new MappingJackson2HttpMessageConverter(objectMapper);

Check warning on line 112 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (17)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 112 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (21)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal

Check warning on line 112 in dev/src/main/java/com/google/adk/web/AdkWebServer.java

View workflow job for this annotation

GitHub Actions / build-modules (25)

org.springframework.http.converter.json.MappingJackson2HttpMessageConverter in org.springframework.http.converter.json has been deprecated and marked for removal
}

/**
* Configures resource handlers for serving static content (like the Dev UI). Maps requests
* starting with "/dev-ui/" to the directory specified by the 'adk.web.ui.dir' system property.
* Maps requests under "/dev-ui/" to the directory named by the 'adk.web.ui.dir' property, or to
* the bundled copy on the classpath when that is unset.
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
if (webUiDir != null && !webUiDir.isEmpty()) {
// Ensure the path uses forward slashes and ends with a slash
String location = webUiDir.replace("\\", "/");
if (!location.startsWith("file:")) {
location = "file:" + location; // Ensure file: prefix
}
if (!location.endsWith("/")) {
location += "/";
}
log.debug("Mapping URL path /** to static resources at location: {}", location);
registry
.addResourceHandler("/**")
.addResourceLocations(location)
.setCachePeriod(0)
.resourceChain(true);

} else {
log.debug(
"System property 'adk.web.ui.dir' or config 'adk.web.ui.dir' is not set. Mapping URL path"
+ " /** to classpath:/browser/");
registry
.addResourceHandler("/**")
.addResourceLocations("classpath:/browser/")
.setCachePeriod(0)
.resourceChain(true);
}
String location = DevUiAssets.assetRoot(webUiDir);
log.debug("Mapping URL path /dev-ui/** to static resources at location: {}", location);
registry
.addResourceHandler("/dev-ui/**")
.addResourceLocations(location)
.setCachePeriod(0)
.resourceChain(true);
}

/**
* Configures simple automated controllers: - Redirects the root path "/" to "/dev-ui". - Forwards
* requests to "/dev-ui" to "/dev-ui/index.html" so the ResourceHandler serves it.
* Configures simple automated controllers: "/" and "/dev-ui" both redirect to the UI, at {@code
* adk.web.backend-url}'s path when that is set, and it forwards to index.html. The trailing slash
* is required: index.html declares a {@code <base href="./">}, so served from "/dev-ui" the app
* resolves its own router path to "dev-ui" and matches none of its routes. The query string is
* carried across because the UI selects its agent from {@code ?app=}.
*/
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addRedirectViewController("/", "/dev-ui");
registry.addViewController("/dev-ui").setViewName("forward:/index.html");
registry.addViewController("/dev-ui/").setViewName("forward:/index.html");
String prefix = DevUiAssets.pathOf(backendUrl);
// The configured value is the public base, so do not stack the context path on it.
boolean contextRelative = prefix.isEmpty();
registry
.addRedirectViewController("/", prefix + "/dev-ui/")
.setKeepQueryParams(true)
.setContextRelative(contextRelative);
registry
.addRedirectViewController("/dev-ui", prefix + "/dev-ui/")
.setKeepQueryParams(true)
.setContextRelative(contextRelative);
registry.addViewController("/dev-ui/").setViewName("forward:/dev-ui/index.html");
}

/**
Expand Down
85 changes: 85 additions & 0 deletions dev/src/main/java/com/google/adk/web/config/DevUiAssets.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.adk.web.config;

import com.google.common.base.CharMatcher;
import java.net.URI;
import java.net.URISyntaxException;
import org.jspecify.annotations.Nullable;
import org.springframework.core.io.ResourceLoader;

/**
* Where the dev UI's static assets live. Shared so the resource handler and the runtime-config
* endpoint resolve the same location; normalizing {@code adk.web.ui.dir} separately in each would
* diverge silently.
*/
public final class DevUiAssets {

/** The runtime config, relative to the asset root. */
public static final String RUNTIME_CONFIG_PATH = "assets/config/runtime-config.json";

private static final String CLASSPATH_ROOT = ResourceLoader.CLASSPATH_URL_PREFIX + "/browser/";

/**
* The asset root: {@code webUiDir} as a {@code file:} URL when set, else the bundled classpath
* copy. Always ends in a slash.
*/
public static String assetRoot(@Nullable String webUiDir) {
if (webUiDir == null || webUiDir.isEmpty()) {
return CLASSPATH_ROOT;
}
String location = webUiDir.replace("\\", "/");
if (!location.startsWith("file:")) {
location = "file:" + location;
}
return location.endsWith("/") ? location : location + "/";
}

/** The location of a single asset, given relative to the asset root. */
public static String assetLocation(@Nullable String webUiDir, String relativePath) {
return assetRoot(webUiDir) + relativePath;
}

/**
* The path a gateway strips, taken from {@code backendUrl} on a best-effort basis, or empty when
* there is none to take. This is what the UI's entry redirect has to carry, so it stays
* percent-encoded and never starts with a second slash, which a browser would read as a host.
*/
public static String pathOf(@Nullable String backendUrl) {
if (backendUrl == null) {
return "";
}
String trimmed = backendUrl.trim();
if (trimmed.isEmpty()) {
return "";
}
String path;
try {
// Raw: the result goes into a Location header, so decoding it here would re-encode wrongly.
path = new URI(trimmed).getRawPath();
} catch (URISyntaxException e) {
return "";
}
if (path == null) {
return "";
}
String collapsed = path.replaceAll("^/+", "/");
return CharMatcher.is('/').trimTrailingFrom(collapsed);
}

private DevUiAssets() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.adk.web.controller;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.adk.web.config.DevUiAssets;
import com.google.common.base.CharMatcher;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Pattern;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* Serves the dev UI's runtime configuration, shadowing the copy bundled in the static assets so
* {@code adk.web.backend-url} can point the UI at the address browsers reach this server on. The
* bundled document is merged rather than replaced, so keys the UI gains in a later bundle survive.
*/
@RestController
public class RuntimeConfigController {

private static final Logger log = LoggerFactory.getLogger(RuntimeConfigController.class);

private static final Pattern ABSOLUTE_URL = Pattern.compile("^https?://.+");

private final ResourceLoader resourceLoader;
private final ObjectMapper objectMapper;
private final @Nullable String webUiDir;
private final String backendUrl;

/** Reads the bundled config through {@code resourceLoader}, or from {@code webUiDir} if set. */
@Autowired
public RuntimeConfigController(
ResourceLoader resourceLoader,
ObjectMapper objectMapper,
@Value("${adk.web.ui.dir:#{null}}") @Nullable String webUiDir,
@Value("${adk.web.backend-url:}") String backendUrl) {
this.resourceLoader = resourceLoader;
this.objectMapper = objectMapper;
this.webUiDir = webUiDir;
String configured = backendUrl.trim();
// A trailing slash would double up: the UI appends paths that already start with one.
String normalized = CharMatcher.is('/').trimTrailingFrom(configured);
if (configured.isEmpty()) {
this.backendUrl = "";
} else if (ABSOLUTE_URL.matcher(normalized).matches()) {
this.backendUrl = normalized;
} else {
// Served exactly as configured, so an explicit value is never silently discarded.
this.backendUrl = configured;
log.warn(
"adk.web.backend-url should be an absolute URL, but is \"{}\". The dev UI reads a value"
+ " without a lower-case http:// or https:// scheme as the host of its"
+ " live/websocket connection.",
configured);
}
}

/** Serves the bundled config with {@code backendUrl} taken from configuration when set. */
@GetMapping(
value = "/dev-ui/" + DevUiAssets.RUNTIME_CONFIG_PATH,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> runtimeConfig() {
Map<String, Object> config = readBundledConfig();
// Unset leaves a value the bundled document already carries, which is what used to be served.
if (backendUrl.isEmpty()) {
config.putIfAbsent("backendUrl", "");
} else {
config.put("backendUrl", backendUrl);
}
// The bundled document can change on disk under adk.web.ui.dir, so do not let it be cached.
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(config);
}

/**
* The bundled config, or an empty document when it is absent or unreadable. A dev UI that cannot
* read its own config is worse than one whose extra keys defaulted, so this never fails the
* request.
*/
private Map<String, Object> readBundledConfig() {
Resource resource =
resourceLoader.getResource(
DevUiAssets.assetLocation(webUiDir, DevUiAssets.RUNTIME_CONFIG_PATH));
if (!resource.exists()) {
log.debug("No bundled dev UI runtime config at {}; serving backendUrl only.", resource);
return new LinkedHashMap<>();
}
try (InputStream in = resource.getInputStream()) {
Map<String, Object> parsed = objectMapper.readValue(in, new TypeReference<>() {});
return parsed == null ? new LinkedHashMap<>() : new LinkedHashMap<>(parsed);
} catch (IOException e) {
log.warn("Could not read the bundled dev UI runtime config at {}.", resource, e);
return new LinkedHashMap<>();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.adk.web.controller;

import com.google.adk.Version;
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* Reports the ADK and language versions, which the dev UI requests on startup, plus a liveness
* endpoint.
*/
@RestController
public class VersionController {

/** Returns the ADK version, the implementation language, and the running JVM's version. */
@GetMapping(value = "/version", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, String> version() {
return Map.of(
"version",
Version.JAVA_ADK_VERSION,
"language",
"java",
"language_version",
System.getProperty("java.version", "unknown"));
}

/** Returns a fixed OK status, so a load balancer can tell the server is up. */
@GetMapping(value = "/health", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, String> health() {
return Map.of("status", "ok");
}
}
Loading