Skip to content
Merged
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
112 changes: 112 additions & 0 deletions src/bcli/client/_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import re
from pathlib import Path
from typing import Any

Expand All @@ -17,6 +18,56 @@
from bcli.registry._registry import EndpointRegistry


# OData v4 *bound action* (and bound function) invocation pattern:
#
# <entitySet>(<key>)/<Namespace>.<...>.<Identifier>
#
# - ``<entitySet>`` is a normal identifier; it's the parent entity set
# that the registry validator looks up.
# - ``<key>`` is anything inside the parentheses — a GUID, a single-
# quoted string, an int, or a composite ``k1='a',k2='b'``. We do not
# over-validate it; BC will reject malformed keys server-side and
# passing through preserves the user's spelling for debugging.
# - The tail is a *qualified identifier* — at least one dot separates
# the namespace from the action/function name. We intentionally don't
# hardcode ``Microsoft.NAV`` so the parser works with any tenant's
# custom action namespaces.
_BOUND_ACTION_RE = re.compile(
r"^(?P<entity>[A-Za-z_][A-Za-z0-9_]*)"
r"\((?P<key>.+)\)"
r"/(?P<qualified>[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+)$"
)

# An unbound action at the OData service root: ``Namespace.action``
# with no parent entity. v0.1 explicitly rejects these — a future
# revision could route them past the company-id URL builder, but the
# bound-action case is the only one in the bug report this PR fixes.
_UNBOUND_ACTION_RE = re.compile(
r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+$"
)


def _parse_bound_action(entity_set_name: str) -> tuple[str, str, str] | None:
"""Recognise a bound-action invocation in ``entity_set_name``.

Returns ``(parent_entity_set, key, qualified_action)`` if the
string matches the OData v4 bound-action shape, ``None`` otherwise.
A plain entity-set name (``customers``) or a record URL with no
action tail (``customers(123)``) also returns ``None`` — those go
through the standard validator path unchanged.
"""
m = _BOUND_ACTION_RE.match(entity_set_name)
if m is None:
return None
return m.group("entity"), m.group("key"), m.group("qualified")


def _is_unbound_action(entity_set_name: str) -> bool:
"""Return ``True`` for a service-root unbound-action invocation
(``Namespace.action`` with no parent entity set)."""
return bool(_UNBOUND_ACTION_RE.match(entity_set_name))


class AsyncBCClient:
"""Async client for Business Central APIs.

Expand Down Expand Up @@ -461,6 +512,67 @@ def _resolve_url_for_target(
"No company_id configured. Run 'bcli config init' or 'bcli company use <id>'."
)

# ─── OData v4 bound action / function invocation ──────────────
#
# Pattern: ``<entitySet>(<key>)/<Namespace>.<...>.<Identifier>``.
# The registry validator should look up *only the parent
# entity set*; everything from the ``(`` onward is opaque to
# the registry and gets stitched back onto the resolved URL.
# This keeps ``disable_standard_api`` enforcement intact (gated
# on the parent, which is the security-relevant identity) and
# honours custom-route registry entries on the parent.
bound = _parse_bound_action(entity_set_name)
if bound is not None:
parent, key, qualified = bound
# Composing a parent URL with ``record_id`` here would
# double-append parens — actions encode the key inside the
# bound-action string, so we resolve the parent alone and
# then splice the ``(key)/qualified`` tail.
parent_url = self._resolve_url_for_target(
environment,
company_id,
parent,
record_id=None,
publisher=publisher,
group=group,
version=version,
)
return f"{parent_url}({key})/{qualified}"

# Service-root unbound action: ``Namespace.action`` with no
# parent entity. Resolves to ``companies(<cid>)/<Namespace>.<action>``
# under the chosen API route. Registry lookup is skipped (unbound
# actions aren't entity sets and can't be registered), but the
# ``disable_standard_api`` security gate still applies when no
# explicit publisher/group/version override is supplied.
if _is_unbound_action(entity_set_name):
if publisher and group and version:
return build_url(
environment=environment,
company_id=company_id,
entity_set_name=entity_set_name,
record_id=None,
publisher=publisher,
group=group,
version=version,
)
if self._profile.disable_standard_api:
from bcli.errors import RegistryError

raise RegistryError(
f"Unbound action '{entity_set_name}' cannot be routed: "
f"'disable_standard_api = true' blocks the standard v2.0 "
f"fallback, and unbound actions are not registry entries. "
f"Pass --publisher/--group/--version to target a custom "
f"API route."
)
return build_url(
environment=environment,
company_id=company_id,
entity_set_name=entity_set_name,
record_id=None,
)

# Explicit override takes priority
if publisher and group and version:
return build_url(
Expand Down
50 changes: 48 additions & 2 deletions src/bcli/workflow/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from dataclasses import dataclass, field
from typing import Any, Literal

from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator


# ─── Pydantic models (YAML → validated structure) ────────────────────
Expand All @@ -20,7 +20,18 @@ class ParamDef(BaseModel):


class StepDef(BaseModel):
"""A single step as declared in a workflow YAML file."""
"""A single step as declared in a workflow YAML file.

Steps may specify the HTTP verb under either of two keys:

* ``action: post`` — the original spelling, lowercased.
* ``method: POST`` — alias kept because authors copy-pasting OData
examples (especially bound-action invocations) reach for ``method``
out of habit. The model lowercases the value before assigning it
to ``action``.

Both keys at once are rejected to keep YAML files unambiguous.
"""

name: str = ""
action: Literal["get", "post", "patch", "delete"] = "get"
Expand All @@ -30,6 +41,41 @@ class StepDef(BaseModel):
id: str | None = None
etag: str = "*"

@model_validator(mode="before")
@classmethod
def _accept_method_alias(cls, data: Any) -> Any:
"""Translate ``method:`` → ``action:`` before field validation.

Runs in ``mode="before"`` so the ``Literal[...]`` validator on
``action`` sees the normalised lowercase value and the unknown
``method`` field is consumed (it would otherwise raise an
``extra inputs`` error under ``model_config = ConfigDict(extra=
"forbid")`` — kept open so existing YAML stays valid, but the
validator still needs to drop the alias).
"""
if not isinstance(data, dict):
return data
if "method" not in data:
return data
method_raw = data["method"]
# Default to checking against the literal set the field accepts.
# Anything else gets normalised and passed through so pydantic's
# ``Literal[...]`` validator raises with a clear message.
if isinstance(method_raw, str):
method_norm = method_raw.lower()
else:
method_norm = method_raw
if "action" in data:
raise ValueError(
"Step declares both 'action' and 'method'. Pick one key — "
"'action' is the canonical spelling, 'method' is an alias "
"accepted for OData copy-paste habits."
)
# Strip the alias and inject the normalised action.
normalised = {k: v for k, v in data.items() if k != "method"}
normalised["action"] = method_norm
return normalised


class WorkflowDef(BaseModel):
"""Top-level workflow definition parsed from YAML."""
Expand Down
5 changes: 5 additions & 0 deletions src/bcli_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ def _emit_command_summary() -> None:

# Import and register command groups
from bcli_cli.commands import ( # noqa: E402
action_cmd,
attach_cmd,
auth_cmd,
batch_cmd,
Expand Down Expand Up @@ -197,6 +198,10 @@ def _emit_command_summary() -> None:
app.command(name="post")(post_cmd.post_command)
app.command(name="patch")(patch_cmd.patch_command)
app.command(name="delete")(delete_cmd.delete_command)
app.command(
name="action",
help="Invoke an OData v4 bound action on a record",
)(action_cmd.action_command)
app.command(name="q", help="Run a saved query (no OData required)")(query_cmd.query_command)
app.command(name="ai-context")(context_cmd.ai_context_command)
app.command(name="doctor", help="Diagnose your bcli install (self-rescue for team users)")(doctor_cmd.doctor_command)
Expand Down
Loading
Loading