feat: OData v4 bound action support (validator + 'bcli action' verb + batch fix)#21
Merged
Conversation
Adds `_parse_bound_action` + `_is_unbound_action` helpers and wires them into `_resolve_url_for_target` so a URL of the form `<entitySet>(<key>)/<Namespace>.<...>.<Identifier>` is recognised as a bound-action invocation. The registry validator now looks up only the parent entity set and splices the action tail back onto the resolved URL, instead of treating the whole literal string as a missing entity set. The pattern is namespace-agnostic; tests exercise both `Microsoft.NAV.*` and a generic `Custom.Ns.*` namespace. `disable_standard_api` still applies — gated on the parent entity, which is the right grain (a bound action's policy follows the entity it's bound to). Unbound actions at the service root (`/<Namespace>.<action>`) are explicitly rejected with a clear "not yet supported" error. Pass-through would require bypassing the company-id URL builder, which is a much bigger change than the bound-action case the bug report calls out.
A YAML step that wrote 'method: POST' (instead of 'action: post') was
silently downgraded to a GET — the runner reads step.get("action") with
a default of "get", and 'method' was an undocumented unknown key. The
bug surfaces when users copy-paste OData examples, especially bound-
action invocations, where the conventional HTTP method key is 'method'.
Fix in three places that all read the action key off the raw dict:
* StepDef gains a model_validator that translates 'method:' to
'action:' (lowercased) before field validation, and rejects the
combination of both keys to keep YAML one-way.
* _execute_batch and the dry-run renderer apply the same alias logic
when reading the raw step dict.
* The disable_writes pre-flight that classifies mutating steps uses
a shared _step_action helper so a 'method: POST' step still trips
the write gate.
Regression test asserts a YAML step with 'method: POST' reaches
client.post — the AsyncMock now raises on .get to lock the contract.
A new top-level verb that lets a user invoke an OData v4 bound action
without hand-writing the parens-and-dot-escaped URL:
bcli action examples 42 archive --no-data
bcli action items "'ALFKI'" doSomething --data '{"flag": true}'
bcli action widgets 7 cancel --namespace Custom.Ns --data @body.json
Internally the verb composes the synthetic
'<entitySet>(<key>)/<Namespace>.<action>' string and forwards it to the
same client.post path that 'bcli post' uses — so the registry
validator's new bound-action recognition (previous commit) covers it
for free.
Design choices:
* --data and --no-data are mutually exclusive AND one is required.
Defaulting silently to '{}' would mask a forgotten parameter on a
real action; making the user choose forces an audit trail.
* Default namespace is 'Microsoft.NAV' (BC convention) with --namespace
for override. Tests exercise a 'Custom.Ns' namespace to lock in
namespace-agnosticism.
* Actions are always POST per the OData spec — there is no --method
flag; the verb refuses to GET.
* Honors --publisher/--group/--version, --idempotency-key, the
disable_writes gate, and the result envelope just like 'bcli post'.
… root Two follow-ups to the bound-action work that came out of design review: 1. ``bcli action`` no longer requires an explicit ``--data`` or ``--no-data`` flag. An empty body is the default — matching ``bcli post`` semantics — so an AI agent invoking ``bcli action examples 42 archive`` for a no-parameter action no longer hits a ``BadParameter``. ``--no-data`` is retained as an explicit no-op alias; ``--data`` and ``--no-data`` together is still an error. 2. Unbound actions at the OData service root (``Namespace.action`` with no parent entity set) are now routed instead of rejected. The resolver composes ``companies(<cid>)/<Namespace>.<action>`` under either the standard ``/api/v2.0/`` path or an explicit ``--publisher/--group/--version`` custom route. The ``disable_standard_api`` security gate still applies when no explicit override is supplied — locked-down profiles must opt into a specific custom API path, mirroring the protection on standard entity sets. Tests: - ``test_neither_flag_defaults_to_empty_body`` replaces the previous "neither flag is an error" assertion. - ``TestUnboundActionAtServiceRoot`` covers: standard-route routing, custom-route override, ``disable_standard_api`` enforcement, and namespace agnosticism. Full suite: 862 passed, 5 skipped (+3 net from the prior 859 baseline).
OData actions (bound and unbound) have no inverse operation — there is no DELETE that undoes an archive, supersede, or refreshAll. If a step records an action POST in the batch ledger and the action returns a body with an ``id`` field, the previous ``_compose_rollback_url`` logic would naïvely append ``(id)`` to the action endpoint, producing nonsense like ``examples(42)/Microsoft.NAV.archive(new-id-789)``. Subsequent ``bcli batch rollback`` would then send a DELETE to that path and fail in a confusing way. Detect action endpoints (both bound ``entitySet(key)/Ns.action`` and unbound ``Ns.action``) early and return ``None`` so the ledger records ``rollback_skipped`` — the correct semantics for a non-invertible step. Regression coverage: ``tests/test_workflow/test_rollback_url_bound_action.py``.
CI ran ``uv sync --locked --extra dev --extra etl`` and refused with ``The lockfile at 'uv.lock' needs to be updated, but '--locked' was provided``. The 0.4.0 release commit bumped pyproject.toml's project version but did not regenerate uv.lock, so every Tests run since 2026-05-18 (including this PR's, before any of the bound-action changes are even installed) errors out before pytest is invoked. Regenerating with ``uv lock`` yields the same two-character diff — the editable ``bc-cli`` entry catches up to its declared version.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds first-class support for OData v4 bound actions, plus an incidental batch-runner bug fix surfaced during the work.
Before this change, any URL of the form
<entitySet>(<key>)/<Namespace>.<action>was rejected at the registry-validation layer before reaching the wire. There was no way to invoke[ServiceEnabled]procedures onPageType = APIpages through bcli.What's included
Registry validator recognises OData v4 bound-action URLs.
New regex
<entitySet>(<key>)/<Identifier>(\.<Identifier>)+is detected in_resolve_url_for_target; only the parent entity set is looked up against the registry. Thedisable_standard_apisecurity gate continues to apply on the parent, so locked-down profiles still get their protection. Namespace is agnostic —Microsoft.NAV.archiveandCustom.Ns.doStuffboth work.New
bcli actionverb.Composes the URL internally so shells don't have to escape the parens/dot. POSTs the body. Returns
✓ Action invoked (204 No Content)on a void action, or pretty-prints the JSON body otherwise. Empty body is the default — matchesbcli postsemantics.--no-datais kept as an explicit alias.Unbound actions at service root.
Namespace.actionURLs with no parent entity now route tocompanies(<cid>)/<Namespace>.<action>under either the standard/api/v2.0/path or an explicit--publisher/--group/--versioncustom route.disable_standard_apistill blocks them on locked-down profiles unless a custom route is supplied — same security posture as standard entity sets.batch runPOST→GET silent-downgrade fix.Workflow YAML steps with
method:were being silently treated as GET because the schema usesaction:. Aliasedmethod:→action:(lowercased) in the four dict-read call sites + the pydantic model. Mutually-exclusive: setting bothmethod:andaction:on a single step now errors.Domain-neutral
All test fixtures use generic names (
examples,widgets,items,doSomething,archive,cancel). Default namespace isMicrosoft.NAVfor ergonomic BC usage but the validator and verb are namespace-agnostic.Tests
862 passed, 5 skipped(was831 passedonmain; +31 new tests across:tests/test_url/test_bound_action_resolve.py(bound + unbound routing, custom-route override, lockdown enforcement)tests/test_cli/test_action_cmd.py(data/no-data variants, namespace override, profile overrides)tests/test_workflow/test_method_alias.py(POST→GET regression, model validation, dict-read sites).Known follow-ups (not in scope)
registry import --from-metadatadoes not yet parse<Action>/<ActionImport>elements from$metadata. Discoverable actions viabcli endpoint info <name>will require a registry schema extension.WorkflowDef/StepDefpydantic models exist but the workflow loader bypassesmodel_validate; the runtime alias logic inbatch_cmdis the load-bearing path. Worth wiringmodel_validateinto the loader so unknown step keys surface as YAML errors instead of being silently ignored.