From 3cf5dd7036eca21c0e44a502083af54818fe4d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Susana=20V=C3=A1zquez?= <3016283+svazquezco@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:33:06 +0200 Subject: [PATCH] feat(webhooks): add a draft purchase order validation webhook example Register a validatePurchaseOrderDraft webhook with one criteria per configured product, so the Extensions Service creates one platform webhook per product on the same route. The handler clears the errors of the previous answer, validates the agreementType ordering parameter and, when the value is not a known one, answers 200 with the order carrying an error on the parameter and a summary error on the order itself. Depend on mpt-extension-sdk 6.9, the first release that ships the webhooks API, replacing the locally built wheel that was pinned through tool.uv.sources while the API was unreleased. Move the mypy pre-commit hook to the same version, because it type-checks in its own environment. Cover the handler with unit tests, add the accepted and rejected webhook calls to the Postman collection, and describe the example in docs/examples.md. --- .pre-commit-config.yaml | 2 +- backend/docs/postman_collection.json | 76 ++++++++++++ backend/mpt_extension_python_template/app.py | 4 + .../routers/webhooks/__init__.py | 0 .../routers/webhooks/order.py | 51 ++++++++ backend/pyproject.toml | 2 +- backend/tests/routers/test_webhooks.py | 109 ++++++++++++++++++ backend/tests/test_app.py | 22 ++++ backend/uv.lock | 8 +- docs/architecture.md | 2 + docs/examples.md | 53 ++++++++- docs/testing.md | 5 +- 12 files changed, 324 insertions(+), 10 deletions(-) create mode 100644 backend/mpt_extension_python_template/routers/webhooks/__init__.py create mode 100644 backend/mpt_extension_python_template/routers/webhooks/order.py create mode 100644 backend/tests/routers/test_webhooks.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6d1997e..c1a84d4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -49,4 +49,4 @@ repos: args: ["--config-file=backend/pyproject.toml", "backend/mpt_extension_python_template"] pass_filenames: false additional_dependencies: - - mpt-extension-sdk==6.8.* + - mpt-extension-sdk==6.9.* diff --git a/backend/docs/postman_collection.json b/backend/docs/postman_collection.json index 331984e..e5bd16d 100644 --- a/backend/docs/postman_collection.json +++ b/backend/docs/postman_collection.json @@ -293,6 +293,82 @@ } }, "response": [] + }, + { + "name": "webhook / validate-purchase-draft \u2014 valid agreement type (expected 200 OK, errors of the previous answer cleared)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "x-request-id", + "value": "8c1f7b02-5a3d-4c19-9f02-1d7ac6b4e833" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"id\": \"ORD-1111-1111-0001\",\n \"type\": \"Purchase\",\n \"status\": \"Draft\",\n \"revision\": 1,\n \"product\": {\n \"id\": \"PRD-1111-1111\",\n \"name\": \"Sample Product\"\n },\n \"agreement\": {\n \"id\": \"AGR-1111-1111-0001\",\n \"name\": \"Sample Agreement #1\",\n \"status\": \"Active\"\n },\n \"authorization\": {\n \"id\": \"AUT-1111-1111\",\n \"name\": \"Sample Authorization\",\n \"currency\": \"USD\"\n },\n \"lines\": [],\n \"subscriptions\": [],\n \"assets\": [],\n \"parameters\": {\n \"ordering\": [\n {\n \"externalId\": \"agreementType\",\n \"name\": \"Agreement type\",\n \"type\": \"SingleLineText\",\n \"value\": \"New\",\n \"error\": {\n \"id\": \"TPL001\",\n \"message\": \"Invalid agreement type: Renewal\"\n }\n }\n ]\n },\n \"externalIds\": {},\n \"error\": {\n \"id\": \"TPL001\",\n \"message\": \"Invalid agreement type: Renewal\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/webhooks/v1/orders/validate-purchase-draft", + "host": [ + "{{base_url}}" + ], + "path": [ + "webhooks", + "v1", + "orders", + "validate-purchase-draft" + ] + } + }, + "response": [] + }, + { + "name": "webhook / validate-purchase-draft \u2014 invalid agreement type (expected 200 OK, order returned with a parameter error)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "x-request-id", + "value": "8c1f7b02-5a3d-4c19-9f02-1d7ac6b4e833" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"id\": \"ORD-1111-1111-0001\",\n \"type\": \"Purchase\",\n \"status\": \"Draft\",\n \"revision\": 1,\n \"product\": {\n \"id\": \"PRD-1111-1111\",\n \"name\": \"Sample Product\"\n },\n \"agreement\": {\n \"id\": \"AGR-1111-1111-0001\",\n \"name\": \"Sample Agreement #1\",\n \"status\": \"Active\"\n },\n \"authorization\": {\n \"id\": \"AUT-1111-1111\",\n \"name\": \"Sample Authorization\",\n \"currency\": \"USD\"\n },\n \"lines\": [],\n \"subscriptions\": [],\n \"assets\": [],\n \"parameters\": {\n \"ordering\": [\n {\n \"externalId\": \"agreementType\",\n \"name\": \"Agreement type\",\n \"type\": \"SingleLineText\",\n \"value\": \"Renewal\"\n }\n ]\n },\n \"externalIds\": {}\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{base_url}}/webhooks/v1/orders/validate-purchase-draft", + "host": [ + "{{base_url}}" + ], + "path": [ + "webhooks", + "v1", + "orders", + "validate-purchase-draft" + ] + } + }, + "response": [] } ] } diff --git a/backend/mpt_extension_python_template/app.py b/backend/mpt_extension_python_template/app.py index 699b986..d2e9d4e 100644 --- a/backend/mpt_extension_python_template/app.py +++ b/backend/mpt_extension_python_template/app.py @@ -23,12 +23,16 @@ from mpt_extension_python_template.routers.schedules.agreement import ( agreements_router as schedules_agreements_router, ) +from mpt_extension_python_template.routers.webhooks.order import ( + orders_router as webhooks_orders_router, +) ext_app = ExtensionApp(prefix="", version="6.0.0") ext_app.include_router(events_orders_router) ext_app.include_router(events_agreements_router) ext_app.include_router(api_agreements_router) ext_app.include_router(schedules_agreements_router) +ext_app.include_router(webhooks_orders_router) ext_app.include_router(plug_portal_router) ext_app.include_router(plug_modals_router) ext_app.include_router(plug_agreements_router) diff --git a/backend/mpt_extension_python_template/routers/webhooks/__init__.py b/backend/mpt_extension_python_template/routers/webhooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/mpt_extension_python_template/routers/webhooks/order.py b/backend/mpt_extension_python_template/routers/webhooks/order.py new file mode 100644 index 0000000..2e461a9 --- /dev/null +++ b/backend/mpt_extension_python_template/routers/webhooks/order.py @@ -0,0 +1,51 @@ +from mpt_extension_sdk.routing import WebhookRouter +from mpt_extension_sdk.webhooks import OrderWebhookContext, WebhookResponse, WebhookType + +from mpt_extension_python_template.settings import get_extension_settings + +AGREEMENT_TYPE_PARAM = "agreementType" +INVALID_AGREEMENT_TYPE_ERROR = "TPL001" +INVALID_ORDER_ERROR = "TPL002" +VALID_AGREEMENT_TYPES = ("New", "Migration") + +orders_router = WebhookRouter(prefix="/webhooks/v1/orders") + + +@orders_router.webhook( + "/validate-purchase-draft", + type=WebhookType.VALIDATE_PURCHASE_ORDER_DRAFT, + description="Validate purchase orders in Draft status.", + criteria=[{"product.id": product_id} for product_id in get_extension_settings().product_ids], +) +def validate_purchase_order_draft(ctx: OrderWebhookContext) -> WebhookResponse: + """Validate the `agreementType` ordering parameter of a draft purchase order. + + The platform waits for this answer while the user is in the order wizard, + so a real validation must stay fast. The errors of the previous answer are + cleared first, otherwise the wizard keeps showing them after the user fixes + the value. A rejection is still a `200`: the order comes back carrying the + error, and the wizard shows it on the offending parameter. + + A rejection reports at both levels, as the SDK webhooks guide shows: + `with_ordering_error(...)` puts the error on the parameter the user has to + fix, and `with_error(...)` puts a summary on the order itself. Use the + parameter level for a bad value and the order level for a problem with the + order as a whole; this example chains both so the template shows each one. + """ + ctx.logger.info("Validating draft order id=%s", ctx.order.id) + order = ctx.order.without_errors() + agreement_type = order.parameters.get_ordering_value(AGREEMENT_TYPE_PARAM) + if agreement_type is None or agreement_type in VALID_AGREEMENT_TYPES: + return WebhookResponse.ok(order) + + order_with_errors = order.with_error({ + "id": INVALID_ORDER_ERROR, + "message": "The order has invalid parameters", + }).with_ordering_error( + AGREEMENT_TYPE_PARAM, + { + "id": INVALID_AGREEMENT_TYPE_ERROR, + "message": f"Invalid agreement type: {agreement_type}", + }, + ) + return WebhookResponse.ok(order_with_errors) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 80080db..a30908b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.12,<4" license = { text = "Apache-2.0 license" } dependencies = [ "mpt-extension-contrib-order-status==1.0.*", - "mpt-extension-sdk==6.8.*", + "mpt-extension-sdk==6.9.*", "mpt-tool==6.0.*", ] diff --git a/backend/tests/routers/test_webhooks.py b/backend/tests/routers/test_webhooks.py new file mode 100644 index 0000000..25bd715 --- /dev/null +++ b/backend/tests/routers/test_webhooks.py @@ -0,0 +1,109 @@ +from logging import Logger + +import pytest +from mpt_extension_sdk.models import OrderSimple +from mpt_extension_sdk.webhooks import OrderWebhookContext + +from mpt_extension_python_template.routers.webhooks.order import ( + AGREEMENT_TYPE_PARAM, + INVALID_AGREEMENT_TYPE_ERROR, + INVALID_ORDER_ERROR, + validate_purchase_order_draft, +) + +ORDER_ID = "ORD-1111-1111-1111" + + +@pytest.fixture +def webhook_context_factory(mocker): + def factory(agreement_type="New"): + previous_error = {"id": "OLD001", "message": "Previous answer"} + ordering = [] + if agreement_type is not None: + ordering = [ + { + "externalId": AGREEMENT_TYPE_PARAM, + "value": agreement_type, + "error": previous_error, + } + ] + ctx = mocker.create_autospec(OrderWebhookContext, instance=True) + ctx.logger = mocker.create_autospec(Logger, instance=True) + ctx.order = OrderSimple.model_validate({ + "id": ORDER_ID, + "status": "Draft", + "type": "Purchase", + "error": previous_error, + "agreement": {"id": "AGR-1111-1111-1111"}, + "authorization": {"id": "AUT-1111-1111", "name": "US", "currency": "USD"}, + "product": {"id": "PRD-1111-1111", "name": "Product"}, + "parameters": {"ordering": ordering}, + }) + return ctx + + return factory + + +def test_validation_clears_previous_errors(webhook_context_factory): + ctx = webhook_context_factory() + + result = validate_purchase_order_draft(ctx) + + agreement_type = result.payload.parameters.get_ordering_parameter(AGREEMENT_TYPE_PARAM) + assert (result.status_code, result.payload.error, agreement_type.error) == (200, None, None) + + +def test_validation_accepts_known_type(webhook_context_factory): + ctx = webhook_context_factory("Migration") + + result = validate_purchase_order_draft(ctx) + + assert (result.status_code, result.payload) == (200, ctx.order.without_errors()) + + +def test_validation_accepts_missing_parameter(webhook_context_factory): + ctx = webhook_context_factory(None) + + result = validate_purchase_order_draft(ctx) + + assert (result.status_code, result.payload) == (200, ctx.order.without_errors()) + + +def test_validation_rejects_unknown_type(webhook_context_factory): + ctx = webhook_context_factory("Renewal") + + result = validate_purchase_order_draft(ctx) + + agreement_type = result.payload.parameters.get_ordering_parameter(AGREEMENT_TYPE_PARAM) + assert (result.status_code, agreement_type.error) == ( + 200, + {"id": INVALID_AGREEMENT_TYPE_ERROR, "message": "Invalid agreement type: Renewal"}, + ) + + +def test_validation_rejects_at_order_level_too(webhook_context_factory): + ctx = webhook_context_factory("Renewal") + + result = validate_purchase_order_draft(ctx) + + assert result.payload.error == { + "id": INVALID_ORDER_ERROR, + "message": "The order has invalid parameters", + } + + +def test_validation_marks_the_parameter_required(webhook_context_factory): + ctx = webhook_context_factory("Renewal") + + result = validate_purchase_order_draft(ctx) + + agreement_type = result.payload.parameters.get_ordering_parameter(AGREEMENT_TYPE_PARAM) + assert (agreement_type.constraints.hidden, agreement_type.constraints.required) == (False, True) + + +def test_validation_logs_the_order(webhook_context_factory): + ctx = webhook_context_factory() + + validate_purchase_order_draft(ctx) # act + + ctx.logger.info.assert_called_once_with("Validating draft order id=%s", ORDER_ID) diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py index 5d7f24a..dc4eedb 100644 --- a/backend/tests/test_app.py +++ b/backend/tests/test_app.py @@ -3,6 +3,7 @@ EventRouteDefinition, PlugRouteDefinition, ScheduleRouteDefinition, + WebhookRouteDefinition, ) from mpt_extension_python_template.app import ext_app @@ -61,6 +62,27 @@ def test_app_generates_schedule_metadata(): } +def test_app_registers_order_webhook_route(): + result = {route.path: route for route in ext_app.routes} + + assert isinstance(result["/webhooks/v1/orders/validate-purchase-draft"], WebhookRouteDefinition) + + +def test_app_generates_webhook_metadata(): + result = ext_app.to_meta_config() + + assert result.webhooks is not None + assert [webhook.model_dump() for webhook in result.webhooks] == [ + { + "type": "validatePurchaseOrderDraft", + "description": "Validate purchase orders in Draft status.", + "path": "/webhooks/v1/orders/validate-purchase-draft", + "criteria": {"product.id": product_id}, + } + for product_id in ("PRD-1111-1111", "PRD-1111-1112") + ] + + def test_app_generates_agreement_plug_metadata(): result = ext_app.to_meta_config() diff --git a/backend/uv.lock b/backend/uv.lock index 065369e..c4bfd80 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1688,7 +1688,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "mpt-extension-contrib-order-status", specifier = "==1.0.*" }, - { name = "mpt-extension-sdk", specifier = "==6.8.*" }, + { name = "mpt-extension-sdk", specifier = "==6.9.*" }, { name = "mpt-tool", specifier = "==6.0.*" }, ] @@ -1711,7 +1711,7 @@ dev = [ [[package]] name = "mpt-extension-sdk" -version = "6.8.0" +version = "6.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastapi", extra = ["standard"] }, @@ -1726,9 +1726,9 @@ dependencies = [ { name = "pydantic" }, { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/ec/cc5e2c5357a4a931031d25d3e56adc7659848e5956c58cdc78ecc60e994b/mpt_extension_sdk-6.8.0.tar.gz", hash = "sha256:af6f0c5b25d50a08855927b45265ab5d3e4506e552c7087756a7919b4219de59", size = 73168, upload-time = "2026-10-22T15:14:13.207Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/d4/7e5e48ec6e6f55dbfebee1d406e85708d9361f1861f364da1f4e47c71115/mpt_extension_sdk-6.9.0.tar.gz", hash = "sha256:32eeba55aa33497c742822536c977b95a4f0d8f67a4c4f5914f2f6f66f0f67ce", size = 80166, upload-time = "2026-11-03T09:48:07.391Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/2e834abb62185f11526387fbf7a96969885b0c8efff8038490da5dbf8b8f/mpt_extension_sdk-6.8.0-py3-none-any.whl", hash = "sha256:6fc574d01e56450af047b836610fdbc3419c974b651ce45d84a0f2de9c9306c3", size = 124262, upload-time = "2026-10-22T15:14:12.121Z" }, + { url = "https://files.pythonhosted.org/packages/11/11/4b19691b934caeb2ef6bddb8dd0549505561f67ecb5ac3de98dad073124d/mpt_extension_sdk-6.9.0-py3-none-any.whl", hash = "sha256:811484d987469a20db2bccecb0ce27cba72157029117f43b4272c93fd25ad614", size = 139556, upload-time = "2026-11-03T09:48:06.308Z" }, ] [[package]] diff --git a/docs/architecture.md b/docs/architecture.md index a52db82..cdac46d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,6 +11,7 @@ This repository is a small Marketplace extension template. It is intentionally m - `backend/mpt_extension_python_template/routers/api/` exposes extension API endpoints. - `backend/mpt_extension_python_template/routers/events/` declares Marketplace event handlers. - `backend/mpt_extension_python_template/routers/schedules/` declares cron-driven schedule handlers. +- `backend/mpt_extension_python_template/routers/webhooks/` declares synchronous platform webhook handlers. - `backend/mpt_extension_python_template/flows/pipelines/` contains simple order and agreement pipelines. - `backend/mpt_extension_python_template/flows/steps/` contains reusable pipeline steps. - `backend/mpt_extension_python_template/context/` contains context adapters (for example `EventAgreementContext`) used by pipelines. @@ -30,6 +31,7 @@ The extension currently registers: - order event route `/events/v2/orders/purchase` - agreement event route `/events/v2/agreements/complete` - agreement schedule route `/schedules/v1/agreements/sync` +- order webhook route `/webhooks/v1/orders/validate-purchase-draft` - Marketplace Portal plugs that load bundles from `/static/`: the agreement plug, socketless modal plugs opened by id, and a UI SDK showcase (an examples app, a guide, and per-socket "add a plug" demos) ## Data Flow diff --git a/docs/examples.md b/docs/examples.md index e5e2b8d..f409591 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -15,8 +15,10 @@ is a ready-made Postman collection for a locally running backend (default is wired automatically through a collection-level pre-request script. It contains sample requests for the example routes below — the `/bypass` operational routes (`health`, `live`, `ready`), the order and agreement events, the agreement API -(including list pagination, get, not-found, and sync), and a schedule delivery — -so you can exercise the template without writing requests by hand. +(including list pagination, get, not-found, and sync), a schedule delivery, and +the two draft order validation webhook calls (a valid and an invalid +`agreementType`) — so you can exercise the template without writing requests by +hand. ## Application Wiring @@ -27,6 +29,7 @@ so you can exercise the template without writing requests by hand. - `events_agreements_router` — agreement event handling - `api_agreements_router` — agreement API endpoints - `schedules_agreements_router` — the scheduled agreement synchronization +- `webhooks_orders_router` — the draft purchase order validation webhook - `plug_portal_router` — top-level portal plugs (the `learn-extensions` navigation group with the examples app and guide nested under it, plus the `add` showcase) - `plug_modals_router` — socketless modal plugs (`dialog` and `wizard`) opened by id from the examples "Modals" view - `plug_agreements_router` — agreement plugs (the agreement tab example plus the agreements `add` showcase) @@ -136,6 +139,52 @@ handler has to live with, and the SDK documents the delivery protocol, the watchdog cadence and the task lifetime limits. +## Webhooks + +[`backend/mpt_extension_python_template/routers/webhooks/order.py`](../backend/mpt_extension_python_template/routers/webhooks/order.py) +registers a `WebhookRouter` webhook with one criteria per product in +`MPT_PRODUCTS_IDS`. The SDK publishes one `webhooks:` entry per product in +`meta.yaml`, all with the same path, and the Extensions Service creates the +platform webhooks. + +| Route | Type | Demonstrates | +| --- | --- | --- | +| `POST /webhooks/v1/orders/validate-purchase-draft` | `validatePurchaseOrderDraft` | A `@webhook(...)` registration with a list of `criteria`, the typed `ctx.order`, `without_errors()`, `with_ordering_error(...)`, `with_error(...)`, and `WebhookResponse.ok(...)` | + +The handler first clears the errors of the previous answer with +`without_errors()`, otherwise the wizard keeps showing them after the user fixes +the value. It then checks the `agreementType` ordering parameter against +`VALID_AGREEMENT_TYPES` and returns the order either unchanged or carrying the +rejection. An order whose payload has no `agreementType` parameter is accepted, +because there is nothing to validate. + +A rejection is reported at both levels the SDK offers, so the template shows +each one: `with_ordering_error(...)` puts the error on the parameter the user +has to fix, and `with_error(...)` adds a summary on the order itself. Use the +parameter level for a bad value and the order level for a problem with the +order as a whole. + +A rejection is still a `200`: the platform reads the validation result from the +returned order, not from the status code, and the wizard shows the error on the +offending parameter. `WebhookResponse.error(...)` is for the handler failing, +not for an order being invalid. + +The platform waits for the answer while the user is in the order wizard, so a +real validation must stay fast. The SDK +[webhooks guide](https://github.com/softwareone-platform/mpt-extension-sdk/blob/main/docs/sdk_usage/webhooks.md) +documents the contexts, the responses and the error mapping. + +### Trying the webhook locally + +The webhook body is the order object itself, not an event envelope. The two +`webhook / validate-purchase-draft` requests in the +[Postman collection](#trying-it-with-postman) post one: the first sends a valid +`agreementType` carrying the errors of a previous answer and gets them back +cleared, the second sends an invalid one and gets the order back with the error +on the parameter and a summary on the order. `with_ordering_error(...)` also +unhides the parameter and marks it required, so the wizard shows the field the +user has to fix. + ## Events The template shows the two event styles supported by the SDK: diff --git a/docs/testing.md b/docs/testing.md index 386bec3..8cf539e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -13,11 +13,12 @@ This file documents only repository-specific testing behavior. The current test scope covers: -- backend app route registration for API, event, schedule, and plug routes -- generated plug and schedule metadata +- backend app route registration for API, event, schedule, webhook, and plug routes +- generated plug, schedule, and webhook metadata - agreement API handlers - order and agreement event handlers - the agreement schedule handler +- the draft purchase order validation webhook handler - order and agreement pipeline execution - pipeline step logging behavior - frontend plug components, hooks, and shared model helpers