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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Then inspect the code paths relevant to the task:
- [`backend/migrations/`](backend/migrations/): migration files managed by `mpt-tool`
- [`backend/tests/`](backend/tests/): backend test suite
- [`frontend/src/modules/`](frontend/src/modules/): React plug entry points (one bundle per module). Frontend authoring rules live in the shared [frontend standard](https://github.com/softwareone-platform/mpt-extension-skills/blob/main/standards/extensions-ui-best-practices.md)
- [`frontend/src/shared/`](frontend/src/shared/): shared frontend components, hooks, and model helpers
- [`frontend/src/shared/`](frontend/src/shared/): shared frontend components, hooks, constants, model helpers, and test fixtures
- [`frontend/esbuild.config.js`](frontend/esbuild.config.js): frontend build that emits plug bundles into `static/`
- [`peripherals/devmock/`](peripherals/devmock): WireMock image and mappings backing `make run-local`
- [`make/`](make): canonical commands used by the repository
Expand Down
81 changes: 74 additions & 7 deletions backend/mpt_extension_python_template/routers/api/agreement.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,43 @@
import asyncio
from http import HTTPStatus

from mpt_api_client.exceptions import MPTHttpError
from mpt_extension_sdk.api import APIResponse, PaginatedResult
from mpt_extension_sdk import TaskTimeouts
from mpt_extension_sdk.api import APIResponse, PaginatedResult, TaskResult
from mpt_extension_sdk.api.context import APIContext
from mpt_extension_sdk.api.errors import NotFoundError
from mpt_extension_sdk.models import Agreement
from mpt_extension_sdk.routing import APIRouter
from mpt_extension_sdk.schemas import BaseSchema
from mpt_extension_sdk.tasks import TaskHandle
from pydantic import Field

agreements_router = APIRouter(prefix="/api/v2/agreements")

# The deferred sync reports progress one step per percent while it waits, and 100 % once
# it has synced. The wait is long on purpose: it outlasts the 300-second Delay the SDK
# answers the first delivery with, so the Extension Service delivers the task again while
# the handler is still processing.
PROGRESS_INTERVAL_SECONDS = 4
FULL_PROGRESS = 100
# The whole handler must fit in this budget, declared on the route. The platform finalizes
# a task that runs longer, and the SDK keeps 60 of these seconds as its own safety margin
# before that deadline, so the sync itself gets the rest.
SYNC_HEADROOM_SECONDS = 120
DEFERRED_SYNC_MAX_PROCESSING_SECONDS = (
PROGRESS_INTERVAL_SECONDS * (FULL_PROGRESS - 1) + SYNC_HEADROOM_SECONDS
)


class SyncAgreementPayload(BaseSchema):
"""Body of the sync call: a deferrable route cannot take the id in the path.

The id is a path segment of every Marketplace call the handler makes, and the
API client does not escape it, so the pattern keeps separators out of it.
"""

agreement_id: str = Field(alias="agreementId", min_length=1, pattern=r"^[A-Za-z0-9-]+$")


@agreements_router.get("/", name="agreements-list")
async def get_agreements(ctx: APIContext) -> APIResponse:
Expand All @@ -32,16 +62,53 @@ async def get_agreement(agreement_id: str, ctx: APIContext) -> APIResponse:
return APIResponse.ok(payload=agreement.to_dict())


@agreements_router.post(path="/{agreement_id}/sync", name="agreements-sync")
async def sync_agreement(agreement_id: str, ctx: APIContext) -> APIResponse:
@agreements_router.post(
path="/sync",
name="agreements-sync",
body_validator=SyncAgreementPayload,
deferrable=True,
timeouts=TaskTimeouts(max_task_processing=DEFERRED_SYNC_MAX_PROCESSING_SECONDS),
)
async def sync_agreement(
payload: SyncAgreementPayload, ctx: APIContext
) -> APIResponse | TaskResult:
"""Synchronize an agreement view with the current Marketplace data.

Writes `externalIds.vendor` as the vendor, the only agreement key a client
or operations account cannot touch. The value is the one already there, so
the example changes no data.
Writes `externalIds.vendor` as the vendor, the only agreement key a client or
operations account cannot touch. It writes back the value already there, so the
example changes no data.

The route is deferrable. A plain call syncs and answers with the agreement.
A call with `MPT-Async: true` runs as a platform Task: it sleeps, reporting
progress, syncs at the end and returns a result pointing at the agreement.
The sleep is deliberate — it lasts long enough for the Extension Service to
deliver the same task again while this execution is still running, which is
the redelivery an extension has to tolerate.
"""
agreement_id = payload.agreement_id
if ctx.task is None:
agreement = await _sync_vendor_external_id(ctx, agreement_id)
return APIResponse.ok(payload=agreement.to_dict())

await ctx.task.log(
"Agreement sync for {{AgreementId}} waits for the redelivery before it runs",
parameters={"AgreementId": agreement_id},
)
await _wait_reporting_progress(ctx.task)
await _sync_vendor_external_id(ctx, agreement_id)
await ctx.task.progress(FULL_PROGRESS)
return TaskResult.reference(f"/public/v1/commerce/agreements/{agreement_id}")


async def _sync_vendor_external_id(ctx: APIContext, agreement_id: str) -> Agreement:
agreement = await ctx.mpt_api_service.agreements.get_by_id(agreement_id)
await ctx.vendor_mpt_api_service.agreements.update(
agreement_id, {"externalIds": {"vendor": agreement.external_ids.vendor}}
)
return APIResponse.ok(payload=agreement.to_dict())
return agreement


async def _wait_reporting_progress(task: TaskHandle) -> None:
for progress in range(1, FULL_PROGRESS):
await asyncio.sleep(PROGRESS_INTERVAL_SECONDS) # noqa: WPS476
await task.progress(progress) # noqa: WPS476
62 changes: 60 additions & 2 deletions backend/tests/routers/test_agreements.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@

import pytest
from mpt_api_client.exceptions import MPTAPIError
from mpt_extension_sdk.api import TaskResult
from mpt_extension_sdk.api.errors import NotFoundError
from mpt_extension_sdk.models import Agreement
from mpt_extension_sdk.models.external_id import ExternalIds
from mpt_extension_sdk.services.mpt_api_service.base import PaginatedCollection
from mpt_extension_sdk.tasks import TaskHandle
from pydantic import ValidationError

from mpt_extension_python_template.routers.api.agreement import (
FULL_PROGRESS,
SyncAgreementPayload,
get_agreement,
get_agreements,
sync_agreement,
Expand All @@ -23,6 +28,18 @@ def agreement_in_mpt(mocker, agreement_payload, vendor_external_id, mpt_api_serv
return agreement


@pytest.fixture
def deferred_context(mocker, api_context_factory):
return api_context_factory(task=mocker.create_autospec(TaskHandle, instance=True))


@pytest.fixture
def mock_sleep(mocker):
return mocker.patch(
"mpt_extension_python_template.routers.api.agreement.asyncio.sleep", autospec=True
)


async def test_get_reads_marketplace(
api_context_factory, agreement_id, agreement_in_mpt, agreement_payload
):
Expand Down Expand Up @@ -61,7 +78,9 @@ async def test_get_agreements_paginated(api_context_factory, agreement_payload):
async def test_sync_answers_with_the_agreement(
api_context_factory, agreement_id, agreement_in_mpt, agreement_payload
):
result = await sync_agreement(agreement_id, api_context_factory())
result = await sync_agreement(
SyncAgreementPayload(agreement_id=agreement_id), api_context_factory()
)

assert result.payload == agreement_payload

Expand All @@ -71,9 +90,48 @@ async def test_sync_writes_back_as_the_vendor(
):
ctx = api_context_factory()

await sync_agreement(agreement_id, ctx) # act
await sync_agreement(SyncAgreementPayload(agreement_id=agreement_id), ctx) # act

ctx.vendor_mpt_api_service.agreements.update.assert_awaited_once_with(
agreement_id, {"externalIds": {"vendor": vendor_external_id}}
)
ctx.mpt_api_service.agreements.update.assert_not_awaited()


async def test_sync_deferred_returns_task_result(
deferred_context, agreement_id, agreement_in_mpt, mock_sleep
):
result = await sync_agreement(SyncAgreementPayload(agreement_id=agreement_id), deferred_context)

assert result == TaskResult.reference(f"/public/v1/commerce/agreements/{agreement_id}")


async def test_sync_deferred_logs_the_wait(
deferred_context, agreement_id, agreement_in_mpt, mock_sleep
):
await sync_agreement( # act
SyncAgreementPayload(agreement_id=agreement_id), deferred_context
)

deferred_context.task.log.assert_awaited_once_with(
"Agreement sync for {{AgreementId}} waits for the redelivery before it runs",
parameters={"AgreementId": agreement_id},
)


async def test_sync_deferred_reports_progress(
deferred_context, agreement_id, agreement_in_mpt, mock_sleep
):
await sync_agreement( # act
SyncAgreementPayload(agreement_id=agreement_id), deferred_context
)

reported = [call.args[0] for call in deferred_context.task.progress.await_args_list]
assert reported == list(range(1, FULL_PROGRESS + 1))
assert mock_sleep.await_count == FULL_PROGRESS - 1


@pytest.mark.parametrize("unsafe_id", ["", "AGR-1/../other", "AGR 1", "AGR-1/sync"])
def test_sync_payload_rejects_an_unsafe_id(unsafe_id):
with pytest.raises(ValidationError):
SyncAgreementPayload(agreement_id=unsafe_id)
4 changes: 3 additions & 1 deletion backend/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ def test_app_registers_agreement_event_route():
def test_app_registers_sync_route():
result = {route.path: route for route in ext_app.routes}

assert isinstance(result["/api/v2/agreements/{agreement_id}/sync"], APIRouteDefinition)
sync_route = result["/api/v2/agreements/sync"]
assert isinstance(sync_route, APIRouteDefinition)
assert sync_route.deferrable is True


def test_app_registers_get_route():
Expand Down
6 changes: 4 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ repository; for the SDK APIs themselves, see the SDK references in

The extension currently registers:

- agreement API routes under `/api/v2/agreements`
- agreement API routes under `/api/v2/agreements`, with `POST /api/v2/agreements/sync` published in the `deferrables` metadata block
- order event route `/events/v2/orders/purchase`
- agreement event route `/events/v2/agreements/complete`
- agreement schedule route `/schedules/v1/agreements/sync`
Expand Down Expand Up @@ -61,7 +61,9 @@ This section only covers what is specific to *this* repository:
`components/` (presentational UI, including `AddPlugShowcase` reused by the
`add-*` modules), `hooks/` (for example `useAgreement`, which fetches
`/api/v2/agreements/{id}` through the SDK `http` client and exposes
load/error/ready states), and `model.ts` (response types and formatters).
load/error/ready states), `model.ts` (response types and formatters), and
`constants.ts` (paths, intervals and other configuration literals, kept out of
the modules that use them).
- **Build output**: the build emits bundles plus sourcemaps, and TypeScript
declarations under `static/types/`.

Expand Down
51 changes: 45 additions & 6 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,50 @@ exposes an `APIRouter` under `/api/v2/agreements`:
| --- | --- | --- |
| `GET /` | `agreements-list` | Paginated reads with `ctx.request.pagination` and `PaginatedResult` / `APIResponse.paginated` |
| `GET /{agreement_id}` | `agreements-get` | Reading a single agreement via `ctx.mpt_api_service`, returning `APIResponse.ok`, and turning a Marketplace 404 into an SDK `NotFoundError` so the route answers 404 |
| `POST /{agreement_id}/sync` | `agreements-sync` | A read as the calling account followed by a write back as the extension vendor (see [Two Account Identities](#two-account-identities)) |
| `POST /sync` | `agreements-sync` | A deferrable route with a `body_validator` (`{"agreementId": ...}`): a read as the calling account followed by a write back as the extension vendor (see [Two Account Identities](#two-account-identities)), served as a direct call or as a platform Task (see [Deferrable Sync](#deferrable-sync)) |

These handlers read Marketplace data through `ctx.mpt_api_service.agreements`.
The `sync` route is the backend half of the frontend "Sync now" example (see
[Plugs](#plugs)).
The `sync` route is the backend half of the frontend "Sync now" and "Sync in
background" example (see [Plugs](#plugs)).

## Deferrable Sync

`agreements-sync` is declared with `deferrable=True`, so the SDK publishes it in
the `deferrables` block of `meta.yaml` and one handler serves two call modes:

- **Direct call** — a plain `POST`. `ctx.task` is `None`, and the handler answers
with the agreement through `APIResponse.ok`.
- **Deferred call** — the same `POST` with the `MPT-Async: true` header. The
Extension Service answers `202 Accepted` with `MPT-Task-Id` itself, then delivers
the request to the extension as a Task. `ctx.task` is set, and the handler
returns `TaskResult.reference(...)` pointing at the agreement, which the SDK
stores as the Task result before completing it.

The deferred branch sleeps before it syncs, reporting progress 1 % … 99 % every
`PROGRESS_INTERVAL_SECONDS` (4 s) and 100 % once the sync is done. The sleep is
deliberate: it outlasts the `Delay` of at least 300 s the SDK answers the first
delivery with, so the Extension Service delivers the task again while the handler is
still running — the redelivery an extension has to tolerate. The SDK answers that
second delivery with another `Delay` instead of running the handler twice. The route
declares a `max_task_processing` budget covering the sleep plus two minutes, one of
which the SDK keeps as its own safety margin.

The plug follows the Task on the Marketplace API: after "Sync in background" it polls
`GET /public/v1/system/tasks/{id}` every 5 seconds with the caller's token — the account
that triggered the call is in the Task's `access` — and keeps both buttons disabled until
the Task is final. After `MAX_POLL_FAILURES` failed reads in a row it stops following and
says so, while the Task keeps running.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

The plug follows a Task only while it is on screen. It does not survive a page reload, and
another browser or device does not see the running sync either: the Tasks API cannot be
asked for "the open task of this agreement", so there is nothing to resume from. An
extension that needs that has to keep the task id itself.

A deferrable route cannot declare path parameters, because the platform does not
resolve them for a deferred call, so the agreement id travels in the request
body. See the SDK
[deferrable endpoints guide](https://github.com/softwareone-platform/mpt-extension-sdk/blob/main/docs/sdk_usage/api.md#deferrable-endpoints)
for the full contract.

## Two Account Identities

Expand All @@ -57,8 +96,8 @@ authenticated as the account in the incoming request token, and
extension. Use the first by default; reach for the second only when a flow has
to act as the extension owner regardless of who triggered it.

The `agreements-sync` route shows both in one handler — it reads as the caller,
then writes back as the vendor:
The `agreements-sync` route shows both in one step, shared by its two call modes —
it reads as the caller, then writes back as the vendor:

```python
agreement = await ctx.mpt_api_service.agreements.get_by_id(agreement_id)
Expand Down Expand Up @@ -187,7 +226,7 @@ module name matches the bundle path one-to-one:

| Plug id / socket | Frontend module | Demonstrates |
| --- | --- | --- |
| `agreements-agreement-<extension-id>` — `portal.commerce.agreements.agreement` | [`modules/agreements-agreement/`](../frontend/src/modules/agreements-agreement/) | A full agreement tab: sync status, `useAgreementSync`, status chips, and a "Sync now" action calling the `sync` API |
| `agreements-agreement-<extension-id>` — `portal.commerce.agreements.agreement` | [`modules/agreements-agreement/`](../frontend/src/modules/agreements-agreement/) | A full agreement tab: sync status, `useAgreementSync`, status chips, a "Sync now" action calling the `sync` API directly, and a "Sync in background" action sending the same call with `MPT-Async: true` and following the returned task, with its progress, until it is final |

## Modal Plugs (Opened By Id)

Expand Down
Loading
Loading