feat(trigger-service): manual receiver + REST CRUD (TS-IMPL-015)#668
Merged
Conversation
Implements TS-IMPL-015: the operator-facing REST surface for trigger
subscriptions plus a manual `:fire` that drives the same normalize → match →
dispatch pipeline an inbound event takes.
- `api/routes/subscriptions.py`: `POST/GET/PATCH/DELETE
/v1/workspaces/{ws}/triggers[/{id}]` + `POST .../{id}:fire`. Selectors are
validated through the CEL evaluator on create/patch (invalid → 422). DELETE
is a soft-delete (state → expired) since the locked SPL surface has no
row-delete. `:fire` returns the started `{runId}`; a paused/non-matching
subscription is `409 not_fireable`, a missing target version dead-letters to
`502`, and a redelivered fire collapses in dedup (`409 duplicate`).
- `api/errors.py`: RFC 7807 `application/problem+json` envelope mirroring the
workflow-service convention — a `TriggerError` handler over the locked
domain taxonomy plus route-local `trigger.api.*` kinds, and a
`RequestValidationError` handler.
- `stores`: a `SubscriptionReadable` capability Protocol + async
`SubscriptionStore.get` so the REST layer can read a single subscription
back; backends without the capability raise
`SubscriptionReadUnsupportedError` (the Postgres read surface lands later).
- `app.py`/`dependencies.py`: the lifespan now builds a shared
`SelectorEvaluator` and a `Dispatcher` (over an owned httpx client reaching
the Workflow Service through Dapr, closed at shutdown); `create_app` gains a
`dispatcher` injection seam for tests, and the Problem+JSON handlers +
router are wired in.
- `models.py`: `ManualFireRequest` / `ManualFireResult`.
RBAC is delegated to the call-context middleware via `require_permission`.
Full CRUD lifecycle, `:fire`, RBAC, and error-path tests added; 378 passed,
99% coverage.
Closes #645
Contributor
There was a problem hiding this comment.
Pull request overview
Adds an operator-facing subscription REST API to the trigger-service, including a manual :fire endpoint that drives the same normalize → match → dispatch pipeline as inbound events, and introduces an RFC7807 Problem+JSON error envelope for route-level failures.
Changes:
- Introduces
/v1/workspaces/{ws}/triggersCRUD +/{id}:firerouter and wires it into the FastAPI app lifespan (sharedSelectorEvaluator,Dispatcherconstruction/injection). - Adds an optional
SubscriptionReadablecapability +SubscriptionStore.get()read-back adapter, plus new wire models for manual fire. - Adds comprehensive route tests for CRUD, selector validation, dedup behavior, and RBAC presence.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| src/services/trigger-service/tests/test_subscriptions_routes.py | End-to-end tests for CRUD + :fire behavior over in-memory backend and fake workflow client. |
| src/services/trigger-service/src/custos_trigger/stores/subscriptions.py | Adds SubscriptionStore.get() and explicit error for backends without read support. |
| src/services/trigger-service/src/custos_trigger/stores/base.py | Introduces SubscriptionReadable Protocol as an optional read capability. |
| src/services/trigger-service/src/custos_trigger/stores/init.py | Re-exports new store capability + error type. |
| src/services/trigger-service/src/custos_trigger/models.py | Adds ManualFireRequest / ManualFireResult wire models. |
| src/services/trigger-service/src/custos_trigger/dependencies.py | Adds dependency getters for dispatcher, selector evaluator, and subscription store. |
| src/services/trigger-service/src/custos_trigger/app.py | Wires Problem+JSON handlers + subscriptions router; builds shared evaluator and dispatcher in lifespan. |
| src/services/trigger-service/src/custos_trigger/api/routes/subscriptions.py | Implements REST CRUD + :fire logic (validate selector, match, dispatch, dedup). |
| src/services/trigger-service/src/custos_trigger/api/routes/init.py | Exports the subscriptions router. |
| src/services/trigger-service/src/custos_trigger/api/errors.py | Adds RFC7807 Problem+JSON envelope + exception handlers for TriggerError and validation errors. |
| src/services/trigger-service/src/custos_trigger/api/init.py | Exposes API utilities (ProblemDetail, handler registration). |
| design/components/trigger-service/todos.md | Marks TS-IMPL-015 as completed. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Enforce path workspace_id matches the call-context workspace on every subscription route (403 workspace_mismatch) as defense-in-depth against a mismatched path and the dev shim's unsigned header. - PATCH can now explicitly clear optional blob fields (selector, targetWorkflowVersionId) via JSON null, distinguished from omission using model_fields_set. - Render SubscriptionReadUnsupportedError through the Problem+JSON envelope as a stable trigger.api.subscription_read_unsupported / 501 instead of an opaque 500.
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.
TS-IMPL-015 — Manual Receiver + REST CRUD
Implements the operator-facing REST surface for trigger subscriptions and a
manual
:firethat drives the same normalize → match → dispatch pipeline aninbound event takes. Closes #645.
Endpoints (
api/routes/subscriptions.py)POST /v1/workspaces/{ws}/triggers— create a start subscription (201)GET /v1/workspaces/{ws}/triggers/{id}— read one backPATCH /v1/workspaces/{ws}/triggers/{id}— amend selector / mapping / target / stateDELETE /v1/workspaces/{ws}/triggers/{id}— soft-delete → stateexpired(204)POST /v1/workspaces/{ws}/triggers/{id}:fire— fire now →{ runId }Behaviour
:firereturns the startedrunId; a paused/non-matching subscription →409
trigger.api.subscription_not_fireable; a subscription with no targetworkflow version dead-letters → 502
trigger.dispatch_failed; aredelivered fire collapses in the dedup window → 409
trigger.dedup_duplicate.trigger.subscription_not_foundon every route.require_permission(
trigger:subscriptions:{read,write,delete,fire}).Supporting changes
api/errors.py— RFC 7807application/problem+jsonenvelope (mirrors theworkflow-service convention): a
TriggerErrorhandler over the locked domaintaxonomy plus route-local
trigger.api.*kinds, and aRequestValidationErrorhandler.stores—SubscriptionReadablecapability Protocol + asyncSubscriptionStore.get; backends lacking the capability raiseSubscriptionReadUnsupportedError(the Postgres read surface lands in a later task).app.py/dependencies.py— the lifespan builds a sharedSelectorEvaluatorand a
Dispatcher(over an owned httpx client reaching the Workflow Servicethrough Dapr, closed at shutdown);
create_appgains adispatcherinjectionseam for tests; Problem+JSON handlers + router wired in.
models.py—ManualFireRequest/ManualFireResult.Design constraints honoured
uses an optional capability Protocol satisfied by the in-process backend and
DELETE is a soft-delete (
expiredis terminal — no row delete exists).input_mappingplaceholder resolution is forwarded literally (deferred to alater task); manual fire is start-only per
classify.Verification
ruff check .+ruff format --check .+mypy src testsclean.pytest: 378 passed, 99.1% coverage (subscriptions route 98.9%,api/errors 95.8%).