Compare commits

...
Author SHA1 Message Date
Cursor AgentandChristian Bromann 8699989b1a test(cli): add tests for http.apps config field
Add tests for:
- Validation of http.apps entries (prefix format, app path format)
- _is_js_http_app helper for JS/TS and npm package detection
- _has_js_http_apps helper for config-level JS app detection
- Docker generation with Python-only http.apps (no Node install)
- Docker generation with JS http.apps (triggers Node install)
- Docker generation with npm package http.apps
- Docker generation with mixed Python/JS http.apps

Co-authored-by: Christian Bromann <christian-bromann@users.noreply.github.com>
2026-03-23 06:13:08 +00:00
Cursor AgentandChristian Bromann 468c7546c8 feat(cli): add http.apps field for mounting multiple HTTP apps at path prefixes
Add support for the http.apps config field in langgraph.json that lets
users mount multiple HTTP apps at different path prefixes alongside the
built-in LangGraph API.

Config format:
  {
    "http": {
      "app": "./custom_routes.py:app",
      "apps": {
        "/dashboard": "./dashboard.py:app",
        "/ext": "./extension.js:app",
        "/conduit": "some-npm-package:app"
      }
    }
  }

Changes:
- Add apps: dict[str, str] | None to HttpConfig TypedDict in schemas.py
- Add validation for http.apps entries (prefix must start with /, values
  must be module:attribute format)
- Add _update_http_apps_paths() for Docker container path rewriting
- Add _is_js_http_app() and _has_js_http_apps() helpers for JS detection
- Ensure Node.js is installed in Docker images when JS HTTP apps present
- Regenerate JSON schema files

Co-authored-by: Christian Bromann <christian-bromann@users.noreply.github.com>
2026-03-23 06:09:31 +00:00
5 changed files with 407 additions and 3 deletions
+109 -1
View File
@@ -287,6 +287,18 @@ def validate_config(config: Config) -> Config:
f"Invalid http.app format: '{http_conf['app']}'. "
"Must be in format './path/to/file.py:attribute_name'"
)
if "apps" in http_conf and http_conf["apps"]:
for prefix, app_path in http_conf["apps"].items():
if not prefix.startswith("/"):
raise ValueError(
f"Invalid http.apps prefix: '{prefix}'. Must start with '/'"
)
if ":" not in app_path:
raise ValueError(
f"Invalid http.apps format for prefix '{prefix}': '{app_path}'. "
"Must be in format './path/to/file:attribute_name' "
"or 'package:attribute_name'"
)
if keep_pkg_tools := config.get("keep_pkg_tools"):
if isinstance(keep_pkg_tools, list):
for tool in keep_pkg_tools:
@@ -807,6 +819,94 @@ def _update_http_app_path(
http_config["app"] = f"{module_str}:{attr_str}"
_JS_EXTENSIONS = frozenset({".js", ".ts", ".mts", ".mjs", ".cts", ".cjs"})
def _is_js_http_app(app_path: str) -> bool:
"""Check if an http.apps entry is a JavaScript/TypeScript app or npm package."""
module_str = app_path.split(":")[0]
ext = os.path.splitext(module_str)[1]
if ext:
return ext in _JS_EXTENSIONS
# No extension and not a file path → npm package reference (JS)
if not module_str.startswith((".", "/")):
return True
return False
def _has_js_http_apps(config: Config) -> bool:
"""Check if any http.apps entries are JavaScript apps."""
http_config = config.get("http")
if not http_config:
return False
apps = http_config.get("apps")
if not apps:
return False
return any(_is_js_http_app(app_path) for app_path in apps.values())
def _update_http_apps_paths(
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
) -> None:
"""Update HTTP apps paths to point to the correct location in the Docker container.
Similar to _update_http_app_path, but handles the `http.apps` dictionary
which maps path prefixes to app module paths. Python app paths are rewritten
to container paths; npm package references are left unchanged.
"""
if not (http_config := config.get("http")) or not (apps := http_config.get("apps")):
return
for prefix, app_str in apps.items():
module_str, _, attr_str = app_str.partition(":")
if not module_str or not attr_str:
message = (
'Import string "{import_str}" must be in format "<module>:<attribute>".'
)
raise ValueError(message.format(import_str=app_str))
# Skip npm package references (bare module names without path separators)
if (
not module_str.startswith((".", "/"))
and not os.path.splitext(module_str)[1]
):
continue
if "/" in module_str or "\\" in module_str:
resolved = (config_path.parent / module_str).resolve()
if not resolved.exists():
raise FileNotFoundError(
f"Could not find HTTP app module for prefix '{prefix}': {resolved}"
)
elif not resolved.is_file():
raise IsADirectoryError(
f"HTTP app module must be a file for prefix '{prefix}': {resolved}"
)
else:
for path in local_deps.real_pkgs:
if resolved.is_relative_to(path):
container_path = (
pathlib.Path("/deps")
/ path.name
/ resolved.relative_to(path)
)
module_str = container_path.as_posix()
break
else:
for faux_pkg, (_, destpath) in local_deps.faux_pkgs.items():
if resolved.is_relative_to(faux_pkg):
container_subpath = resolved.relative_to(faux_pkg)
module_str = f"{destpath}/{container_subpath.as_posix()}"
break
else:
raise ValueError(
f"HTTP app module '{app_str}' for prefix '{prefix}' "
"not found in 'dependencies' list. "
"Add its containing package to 'dependencies' list."
)
apps[prefix] = f"{module_str}:{attr_str}"
def _get_node_pm_install_cmd(config_path: pathlib.Path, config: Config) -> str:
def test_file(file_name):
full_path = config_path.parent / file_name
@@ -957,6 +1057,8 @@ def python_config_to_docker(
_update_checkpointer_path(config_path, config, local_deps)
# Rewrite HTTP app path, so it points to the correct location in the Docker container
_update_http_app_path(config_path, config, local_deps)
# Rewrite HTTP apps paths, so they point to the correct location in the Docker container
_update_http_apps_paths(config_path, config, local_deps)
pip_pkgs_str = (
f"RUN {local_reqs_pip_install} {' '.join(pypi_deps)}" if pypi_deps else ""
@@ -1017,9 +1119,11 @@ ADD {relpath} /deps/{name}
for fullpath, (relpath, name) in local_deps.real_pkgs.items()
)
has_js_apps = _has_js_http_apps(config)
install_node_str: str = (
"RUN /storage/install-node.sh"
if (config.get("ui") or config.get("node_version")) and local_deps.working_dir
if ((config.get("ui") or config.get("node_version")) and local_deps.working_dir)
or has_js_apps
else ""
)
@@ -1078,6 +1182,10 @@ ADD {relpath} /deps/{name}
"# -- End of JS dependencies install --",
]
)
elif has_js_apps:
js_inst_str = (
f"ENV NODE_VERSION={config.get('node_version') or DEFAULT_NODE_VERSION}"
)
image_str = docker_tag(config, base_image, api_version)
# Prepare docker file contents
+19
View File
@@ -446,6 +446,25 @@ class HttpConfig(TypedDict, total=False):
Format: "path/to/module.py:app_var"
If provided, it can override or extend the default routes.
"""
apps: dict[str, str] | None
"""Optional. Record mapping path prefix to app module path.
Each key is a URL path prefix (e.g., "/dashboard"), and each value is an
import path in "file:export" format (e.g., "./dashboard.py:app").
Python apps (.py) are mounted in-process as FastAPI/Starlette sub-applications.
JS apps (.js, .ts, .mts, .mjs) are spawned as Node.js subprocess servers
with a reverse proxy forwarding requests from the main server.
npm package references (paths not starting with . or /) are resolved as
npm imports in the JS subprocess.
Example:
{
"/dashboard": "./dashboard.py:app",
"/ext": "./extension.js:app",
"/conduit": "some-npm-package:app"
}
"""
disable_assistants: bool
"""Optional. If `True`, /assistants routes are removed from the server.
+15 -1
View File
@@ -687,7 +687,7 @@
},
"EncryptionConfig": {
"title": "EncryptionConfig",
"description": "Configuration for custom at-rest encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database,\nincluding metadata fields and checkpoint blobs.",
"description": "Configuration for custom at-rest encryption logic.\n\n Allows you to implement custom encryption for sensitive data stored in the database,\n including metadata fields and checkpoint blobs.",
"type": "object",
"properties": {
"path": {
@@ -728,6 +728,20 @@
"type": "string",
"description": "Optional. Import path to a custom Starlette/FastAPI application to mount.\n"
},
"apps": {
"anyOf": [
{
"type": "object",
"additionalProperties": {
"type": "string"
}
},
{
"type": "null"
}
],
"description": "Optional. Record mapping path prefix to app module path.\n\nEach key is a URL path prefix (e.g., \"/dashboard\"), and each value is an\n\nPython apps (.py) are mounted in-process as FastAPI/Starlette sub-applications.\nJS apps (.js, .ts, .mts, .mjs) are spawned as Node.js subprocess servers\nwith a reverse proxy forwarding requests from the main server.\nnpm package references (paths not starting with . or /) are resolved as\nnpm imports in the JS subprocess.\n"
},
"configurable_headers": {
"anyOf": [
{
+15 -1
View File
@@ -687,7 +687,7 @@
},
"EncryptionConfig": {
"title": "EncryptionConfig",
"description": "Configuration for custom at-rest encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database,\nincluding metadata fields and checkpoint blobs.",
"description": "Configuration for custom at-rest encryption logic.\n\n Allows you to implement custom encryption for sensitive data stored in the database,\n including metadata fields and checkpoint blobs.",
"type": "object",
"properties": {
"path": {
@@ -728,6 +728,20 @@
"type": "string",
"description": "Optional. Import path to a custom Starlette/FastAPI application to mount.\n"
},
"apps": {
"anyOf": [
{
"type": "object",
"additionalProperties": {
"type": "string"
}
},
{
"type": "null"
}
],
"description": "Optional. Record mapping path prefix to app module path.\n\nEach key is a URL path prefix (e.g., \"/dashboard\"), and each value is an\n\nPython apps (.py) are mounted in-process as FastAPI/Starlette sub-applications.\nJS apps (.js, .ts, .mts, .mjs) are spawned as Node.js subprocess servers\nwith a reverse proxy forwarding requests from the main server.\nnpm package references (paths not starting with . or /) are resolved as\nnpm imports in the JS subprocess.\n"
},
"configurable_headers": {
"anyOf": [
{
+249
View File
@@ -11,6 +11,8 @@ import pytest
from langgraph_cli.config import (
_BUILD_TOOLS,
_get_pip_cleanup_lines,
_has_js_http_apps,
_is_js_http_app,
config_to_compose,
config_to_docker,
default_base_image,
@@ -150,6 +152,148 @@ def test_validate_config():
)
def test_validate_config_http_apps():
"""Test validation of http.apps field."""
# Valid http.apps config
config = validate_config(
{
"python_version": "3.12",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"http": {
"apps": {
"/dashboard": "./dashboard.py:app",
"/ext": "./extension.js:app",
"/conduit": "some-npm-package:app",
}
},
}
)
assert config["http"]["apps"] == {
"/dashboard": "./dashboard.py:app",
"/ext": "./extension.js:app",
"/conduit": "some-npm-package:app",
}
# Valid with both app and apps
config = validate_config(
{
"python_version": "3.12",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"http": {
"app": "./custom_routes.py:app",
"apps": {
"/dashboard": "./dashboard.py:app",
},
},
}
)
assert config["http"]["app"] == "./custom_routes.py:app"
assert config["http"]["apps"] == {"/dashboard": "./dashboard.py:app"}
# Invalid prefix (doesn't start with /)
with pytest.raises(ValueError, match="Invalid http.apps prefix"):
validate_config(
{
"python_version": "3.12",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"http": {"apps": {"dashboard": "./dashboard.py:app"}},
}
)
# Invalid app path (no colon separator)
with pytest.raises(ValueError, match="Invalid http.apps format"):
validate_config(
{
"python_version": "3.12",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"http": {"apps": {"/dashboard": "./dashboard.py"}},
}
)
# Empty apps dict is fine (no-op)
config = validate_config(
{
"python_version": "3.12",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"http": {"apps": {}},
}
)
assert config["http"]["apps"] == {}
# None apps is fine
config = validate_config(
{
"python_version": "3.12",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"http": {"apps": None},
}
)
assert config["http"]["apps"] is None
def test_is_js_http_app():
"""Test _is_js_http_app detection logic."""
# Python files are not JS
assert not _is_js_http_app("./dashboard.py:app")
assert not _is_js_http_app("./app.pyx:app")
# JS/TS files are JS
assert _is_js_http_app("./extension.js:app")
assert _is_js_http_app("./extension.ts:app")
assert _is_js_http_app("./extension.mts:app")
assert _is_js_http_app("./extension.mjs:app")
assert _is_js_http_app("./extension.cts:app")
assert _is_js_http_app("./extension.cjs:app")
# npm package references (bare module names) are JS
assert _is_js_http_app("some-npm-package:app")
assert _is_js_http_app("@scope/package:app")
# File paths without extensions that start with . or / are not JS
assert not _is_js_http_app("./mymodule:app")
def test_has_js_http_apps():
"""Test _has_js_http_apps config detection."""
# No http config
assert not _has_js_http_apps({})
# No apps field
assert not _has_js_http_apps({"http": {}})
# Empty apps
assert not _has_js_http_apps({"http": {"apps": {}}})
# Python-only apps
assert not _has_js_http_apps(
{"http": {"apps": {"/dashboard": "./dashboard.py:app"}}}
)
# JS apps
assert _has_js_http_apps({"http": {"apps": {"/ext": "./extension.js:app"}}})
# npm package
assert _has_js_http_apps({"http": {"apps": {"/conduit": "some-npm-package:app"}}})
# Mixed
assert _has_js_http_apps(
{
"http": {
"apps": {
"/dashboard": "./dashboard.py:app",
"/ext": "./extension.js:app",
}
}
}
)
def test_validate_config_image_distro():
"""Test validation of image_distro field."""
# Valid image_distro values should work
@@ -491,6 +635,111 @@ WORKDIR /deps/outer-unit_tests/unit_tests\
}
def test_config_to_docker_with_http_apps_python():
"""Test Docker generation with http.apps containing Python apps."""
graphs = {"agent": "./agent.py:graph"}
actual_docker_stdin, additional_contexts = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{
"dependencies": [".", "../../examples"],
"graphs": graphs,
"http": {
"apps": {
"/dashboard": "../../examples/my_app.py:app",
}
},
}
),
base_image="langchain/langgraph-api",
)
# Python http.apps paths should be rewritten to container paths
assert "LANGGRAPH_HTTP" in actual_docker_stdin
assert "/deps/examples/my_app.py:app" in actual_docker_stdin
assert "/dashboard" in actual_docker_stdin
# No Node.js installation for Python-only apps
assert "install-node" not in actual_docker_stdin
def test_config_to_docker_with_http_apps_js():
"""Test Docker generation with http.apps containing JS apps triggers Node install."""
graphs = {"agent": "./agent.py:graph"}
# Use npm package references to avoid needing actual JS files on disk
actual_docker_stdin, _ = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{
"dependencies": ["."],
"graphs": graphs,
"http": {
"apps": {
"/ext": "my-js-package:app",
}
},
}
),
base_image="langchain/langgraph-api",
)
# JS http.apps should trigger Node.js installation
assert "install-node" in actual_docker_stdin
assert "NODE_VERSION=20" in actual_docker_stdin
assert "LANGGRAPH_HTTP" in actual_docker_stdin
def test_config_to_docker_with_http_apps_npm_package():
"""Test Docker generation with http.apps containing npm package triggers Node install."""
graphs = {"agent": "./agent.py:graph"}
actual_docker_stdin, _ = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{
"dependencies": ["."],
"graphs": graphs,
"http": {
"apps": {
"/conduit": "some-npm-package:app",
}
},
}
),
base_image="langchain/langgraph-api",
)
# npm package should trigger Node.js installation
assert "install-node" in actual_docker_stdin
assert "NODE_VERSION=20" in actual_docker_stdin
assert "LANGGRAPH_HTTP" in actual_docker_stdin
# npm package path should not be rewritten
assert "some-npm-package:app" in actual_docker_stdin
def test_config_to_docker_with_http_apps_mixed():
"""Test Docker generation with mixed Python and JS http.apps."""
graphs = {"agent": "./agent.py:graph"}
actual_docker_stdin, _ = config_to_docker(
PATH_TO_CONFIG,
validate_config(
{
"dependencies": [".", "../../examples"],
"graphs": graphs,
"http": {
"app": "../../examples/my_app.py:app",
"apps": {
"/dashboard": "../../examples/my_app.py:app",
"/conduit": "some-npm-package:app",
},
},
}
),
base_image="langchain/langgraph-api",
)
# Should have both Python path rewriting and Node.js installation
assert "install-node" in actual_docker_stdin
assert "NODE_VERSION=20" in actual_docker_stdin
assert "LANGGRAPH_HTTP" in actual_docker_stdin
# Python paths should be rewritten
assert "/deps/examples/my_app.py:app" in actual_docker_stdin
def test_config_to_docker_outside_path():
graphs = {"agent": "./agent.py:graph"}
actual_docker_stdin, additional_contexts = config_to_docker(