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