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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
76 changes: 76 additions & 0 deletions backend/docs/postman_collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
]
}
4 changes: 4 additions & 0 deletions backend/mpt_extension_python_template/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Empty file.
51 changes: 51 additions & 0 deletions backend/mpt_extension_python_template/routers/webhooks/order.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.*",
]

Expand Down
109 changes: 109 additions & 0 deletions backend/tests/routers/test_webhooks.py
Original file line number Diff line number Diff line change
@@ -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)
22 changes: 22 additions & 0 deletions backend/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
EventRouteDefinition,
PlugRouteDefinition,
ScheduleRouteDefinition,
WebhookRouteDefinition,
)

from mpt_extension_python_template.app import ext_app
Expand Down Expand Up @@ -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()

Expand Down
8 changes: 4 additions & 4 deletions backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading
Loading