Files
dify/api/core/dify_builder/urls.py
T
Charles YaoandClaude Opus 5 563afc60ca fix(dify-builder): a templated path or query no longer hides an invented host
is_user_supplied_url exempted ANY url containing a Dify template, so
https://api.pptrender.io/v1/render?topic={{#node1.topic#}} (a host the user
never typed) was left in place and every test run hit a host that does not
exist. Only a template in the HOST position (the url starts with one, or its
host contains one) exempts a url now; a literal host is checked as usual.

Grounding such a url replaces only scheme://host[:port] with the start
variable (labelled "<title> base URL") and keeps the path, query and the data
templates in them verbatim; a url whose host is already a template is never
re-grounded, so a second pass is a no-op. The host-position rule lives in the
new stdlib-only core/dify_builder/urls.py (urlsplit reads a template's '#' as
a fragment), shared with endpoint_variable_names' side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 10:06:32 -07:00

63 lines
2.8 KiB
Python

"""Where a URL's HOST sits, reading a Dify template as one opaque unit.
Pure, no I/O, stdlib-only -- ``core/dify_builder`` must not import from
``services``, so this is the single shared definition of a URL's host
position. Both ``services/dify_builder/agent`` (deciding whether a URL is
user-supplied, and grounding an invented one) and
``core/dify_builder/handlers_fix.py`` (``endpoint_variable_names``: which
start variables a mocked form must leave for the human) need the SAME rule,
or a template grounding creates could be mocked, or a path/query data input
could be withheld from mocking.
``urllib.parse.urlsplit`` cannot be used on a templated URL: a Dify template
(``{{#node.var#}}``) contains ``#``, which ``urlsplit`` reads as the start of
the fragment -- ``https://{{#s.host#}}/v1`` would have host ``{{``.
"""
import re
from typing import NamedTuple
# A Dify workflow template reference, e.g. ``{{#node1.url#}}``: a variable,
# not a literal.
TEMPLATE_MARKER = "{{#"
# ``scheme://authority`` at the very start of a URL. A template inside the
# authority is matched as one unit (tried first), so its ``#`` does not end
# the authority the way a bare ``/``, ``?`` or ``#`` does.
_ORIGIN_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.\-]*://(?:\{\{#[^#]*#\}\}|[^/?#\s])*")
class UrlOrigin(NamedTuple):
"""A URL split at the end of its authority, both halves exactly as written."""
origin: str # ``scheme://[userinfo@]host[:port]``
host: str # the authority's host alone (no userinfo, no port), NOT lowercased
rest: str # everything after the origin -- path, query, fragment -- verbatim
def split_origin(url: str) -> UrlOrigin | None:
"""``url`` (stripped of surrounding whitespace) split into its origin,
host and the rest, or None when it does not start with ``scheme://``
(a bare ``host/path``, or a URL that starts with a template)."""
text = (url or "").strip()
match = _ORIGIN_RE.match(text)
if match is None:
return None
origin = match.group(0)
host_port = origin.partition("://")[2].rpartition("@")[2]
host = host_port[1:].partition("]")[0] if host_port.startswith("[") else host_port.partition(":")[0]
return UrlOrigin(origin=origin, host=host, rest=text[len(origin) :])
def host_is_templated(url: str) -> bool:
"""True when a template occupies ``url``'s HOST position: the stripped
url STARTS with a template (``{{#s.h_url#}}/v1/render``), or its host
contains one (``https://{{#s.host#}}/v1``). A template only in the path
or query (``https://wttr.in/{{#s.city#}}``) does NOT count -- that is an
ordinary data input on a literal host."""
text = (url or "").strip()
if text.startswith(TEMPLATE_MARKER):
return True
parts = split_origin(text)
return parts is not None and TEMPLATE_MARKER in parts.host