diff --git a/packages/flow/docs/API_TIER.md b/packages/flow/docs/API_TIER.md index 667cd7b6f..06185a23a 100644 --- a/packages/flow/docs/API_TIER.md +++ b/packages/flow/docs/API_TIER.md @@ -60,6 +60,14 @@ field the click before the typing targeted), never by its value matching a parameter's. So a parameter whose demonstration value was blank still binds its field, and two parameters with equal values bind their own fields. +The save's URL and the form page's URL keep their query. A path segment or +query value that equals exactly one input's demonstration value (a record +identifier such as `record_id=17`) becomes that parameter; the rest is kept +as recorded. A hidden form field that echoes such an identifier (an update +form's `pid`) is the same parameter. The form page, the pre-checks and the +save must share one origin, and the API step only ever sends to the live +form's own origin. + If a field can't be explained, nothing is proposed. That includes a field that carries a parameter's value although no recorded step typed it there, a typed field the save sent with another value, a typed field the save doesn't @@ -71,6 +79,8 @@ doesn't propose an API step in these cases: - the form page wasn't captured; - the save isn't the program's last step; - the program has branches; +- a URL value equals two inputs' values (ambiguous), or the save or a + pre-check is on another origin than the form; - a covered step has a guard, a wait rule, or a record-identifying region the person marked with `record --identifier`; - a covered step made a request whose response must show text the GUI checks diff --git a/packages/flow/openadapt_flow/compiler/api_proposal.py b/packages/flow/openadapt_flow/compiler/api_proposal.py index 7a89a3444..704d5fdb4 100644 --- a/packages/flow/openadapt_flow/compiler/api_proposal.py +++ b/packages/flow/openadapt_flow/compiler/api_proposal.py @@ -95,6 +95,54 @@ def _templated(value: str, by_value: dict[str, list[str]]) -> Optional[str]: return "{" + names[0] + "}" if len(names) == 1 else None +def _origin(url: str) -> str: + parts = up.urlsplit(url) + return f"{parts.scheme.lower()}://{parts.netloc.lower()}" + + +def _url_template(url: str, by_value: dict[str, list[str]]) -> tuple[str, list[str]]: + """``url``'s path and query, with input values as ``{param}``. + + A path segment or query value that equals exactly one input's + demonstration value becomes that parameter (a record identifier such as + ``record_id=17``); the rest is kept as recorded. A value equal to two + inputs is ambiguous and reported. + """ + + parts = up.urlsplit(url) + problems: list[str] = [] + + def bind(value: str, where: str) -> str: + names = by_value.get(value) or [] + if len(names) > 1: + problems.append( + f"{where} {value!r} equals {', '.join('{' + n + '}' for n in names)}; " + "the binding is ambiguous" + ) + return value + return "{" + names[0] + "}" if names else value + + segments = [ + bind(up.unquote(seg), "the URL path segment") if seg else seg + for seg in parts.path.split("/") + ] + path = "/".join( + seg if seg.startswith("{") else up.quote(seg, safe=":@!$&'()*+,;=-._~") + for seg in segments + ) + query = [ + (k, bind(v, f"the URL query value {k}=") if v else v) + for k, v in up.parse_qsl(parts.query, keep_blank_values=True) + ] + if query: + # Placeholders stay readable; fill_url encodes the run's values. + path += "?" + "&".join( + up.quote(k) + "=" + (v if v.startswith("{") else up.quote(v)) + for k, v in query + ) + return path, problems + + _ID_SELECTOR = re.compile(r"^#([A-Za-z_][\w:.-]*)$") _NAME_SELECTOR = re.compile(r"""^(?:[a-z]+)?\[name=["']?([^"'\]]+)["']?\]$""") @@ -259,7 +307,9 @@ def carried(line: dict[str, Any]) -> int: post = max(posts, key=carried) if carried(post) < max(1, len(by_value) // 2): return workflow, None, ["the save's request does not carry the recorded values"] - action = _path(post["url"]) + action, url_problems = _url_template(post["url"], by_value) + if url_problems: + return workflow, None, url_problems if any(v == nc.SECRET for _, v in post["form"]): return workflow, None, ["the save sends a secret field; it stays on the GUI"] @@ -273,7 +323,7 @@ def carried(line: dict[str, Any]) -> int: and ln.get("status") == 200 and "