diff --git a/python/x402/src/bankofai/x402/http/decorators/__init__.py b/python/x402/src/bankofai/x402/http/decorators/__init__.py new file mode 100644 index 00000000..459dae65 --- /dev/null +++ b/python/x402/src/bankofai/x402/http/decorators/__init__.py @@ -0,0 +1,12 @@ +"""Decorator-based API for x402 payment protection. + +Provides a declarative way to protect routes with x402 payment requirements +using Python decorators, as an alternative to the routes dict approach. +""" + +from ..types import PaymentOption, RouteConfig + +__all__ = [ + "PaymentOption", + "RouteConfig", +] diff --git a/python/x402/src/bankofai/x402/http/decorators/fastapi.py b/python/x402/src/bankofai/x402/http/decorators/fastapi.py new file mode 100644 index 00000000..57b5b781 --- /dev/null +++ b/python/x402/src/bankofai/x402/http/decorators/fastapi.py @@ -0,0 +1,189 @@ +"""FastAPI decorator-based API for x402 payment protection. + +Provides a declarative way to protect FastAPI routes with x402 payment +requirements using decorators, as an alternative to the routes dict approach. + +Example: + ```python + from bankofai.x402.http.decorators.fastapi import x402_app + from bankofai.x402.http import PaymentOption + + x402 = x402_app(server) + + @app.get("/weather") + @x402.pay(scheme="exact", network="eip155:8453", pay_to=ADDR, price="$0.01") + async def get_weather(): + return {"report": "sunny"} + + # Multiple payment options + @app.get("/premium") + @x402.pay([ + PaymentOption(scheme="exact", network="eip155:8453", pay_to=evm, price="$0.01"), + PaymentOption(scheme="exact", network="tron:728126428", pay_to=trx, price="$0.01"), + ]) + async def get_premium(): + return {"data": "premium"} + + x402.init_app(app) + ``` +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from ..types import PaymentOption, RouteConfig + +if TYPE_CHECKING: + from fastapi import FastAPI + + from ...server import x402ResourceServer + from ..types import PaywallConfig + from ..x402_http_server import PaywallProvider + + +class x402_app: + """FastAPI decorator-based x402 payment integration. + + Collects payment configurations from decorated route functions, + then registers them as middleware when init_app() is called. + + Args: + server: Pre-configured x402ResourceServer instance (async). + paywall_config: Optional paywall UI configuration. + paywall_provider: Optional custom paywall provider. + sync_facilitator_on_start: Fetch facilitator support on first request. + """ + + def __init__( + self, + server: x402ResourceServer, + paywall_config: PaywallConfig | None = None, + paywall_provider: PaywallProvider | None = None, + sync_facilitator_on_start: bool = True, + ) -> None: + self._server = server + self._paywall_config = paywall_config + self._paywall_provider = paywall_provider + self._sync_facilitator_on_start = sync_facilitator_on_start + self._decorated_functions: list[tuple[Callable[..., Any], RouteConfig]] = [] + + def pay( + self, + accepts: PaymentOption | list[PaymentOption] | None = None, + *, + scheme: str | None = None, + pay_to: str | None = None, + price: str | None = None, + network: str | None = None, + assets: list[str] | None = None, + max_timeout_seconds: int | None = None, + extra: dict[str, Any] | None = None, + description: str | None = None, + mime_type: str | None = None, + extensions: dict[str, Any] | None = None, + ) -> Callable[..., Any]: + """Decorator to mark a FastAPI route as payment-protected. + + Can be used with a PaymentOption / list of PaymentOptions, or with + keyword arguments for a single payment option. + + Args: + accepts: PaymentOption or list of PaymentOptions. + scheme: Payment scheme (shorthand for single option). + pay_to: Recipient address (shorthand for single option). + price: Price string (shorthand for single option). + network: CAIP-2 network identifier (shorthand for single option). + assets: Asset symbols to resolve via AssetRegistry. + max_timeout_seconds: Maximum payment timeout. + extra: Scheme-specific additional data. + description: Route description. + mime_type: Response MIME type. + extensions: Extension declarations. + + Returns: + Decorated function (unchanged). + """ + if accepts is None and scheme is not None: + if pay_to is None or price is None or network is None: + msg = "pay_to, price, and network are required when using keyword arguments" + raise ValueError(msg) + accepts = PaymentOption( + scheme=scheme, + pay_to=pay_to, + price=price, + network=network, + assets=assets, + max_timeout_seconds=max_timeout_seconds, + extra=extra, + ) + + if accepts is None: + msg = ( + "Either 'accepts' (PaymentOption or list) or keyword arguments " + "(scheme, pay_to, price, network) must be provided" + ) + raise ValueError(msg) + + route_config = RouteConfig( + accepts=accepts, + description=description, + mime_type=mime_type, + extensions=extensions, + ) + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + func._x402_config = route_config # type: ignore[attr-defined] + self._decorated_functions.append((func, route_config)) + return func + + return decorator + + def init_app(self, app: FastAPI) -> None: + """Scan decorated routes and attach payment middleware. + + Must be called after all routes are registered with the FastAPI app. + Iterates over registered API routes, matches them with decorated + functions, and builds the routes config for the payment middleware. + + Args: + app: FastAPI application instance. + """ + from ..middleware.fastapi import payment_middleware + + routes: dict[str, RouteConfig] = {} + + for route in app.routes: + # FastAPI APIRoute has endpoint and methods attributes + endpoint = getattr(route, "endpoint", None) + if endpoint is None: + continue + + config = getattr(endpoint, "_x402_config", None) + if config is None: + continue + + methods = getattr(route, "methods", set()) + path = getattr(route, "path", "") + + for method in methods: + if method in ("OPTIONS", "HEAD"): + continue + key = f"{method} {path}" + routes[key] = config + + if not routes: + return + + middleware_func = payment_middleware( + routes, + self._server, + paywall_config=self._paywall_config, + paywall_provider=self._paywall_provider, + sync_facilitator_on_start=self._sync_facilitator_on_start, + ) + + @app.middleware("http") + async def x402_middleware(request: Any, call_next: Any) -> Any: + return await middleware_func(request, call_next) diff --git a/python/x402/src/bankofai/x402/http/decorators/flask.py b/python/x402/src/bankofai/x402/http/decorators/flask.py new file mode 100644 index 00000000..ce649a54 --- /dev/null +++ b/python/x402/src/bankofai/x402/http/decorators/flask.py @@ -0,0 +1,182 @@ +"""Flask decorator-based API for x402 payment protection. + +Provides a declarative way to protect Flask routes with x402 payment +requirements using decorators, as an alternative to the routes dict approach. + +Example: + ```python + from bankofai.x402.http.decorators.flask import x402_app + from bankofai.x402.http import PaymentOption + + x402 = x402_app(server) + + @app.route("/weather") + @x402.pay(scheme="exact", network="eip155:8453", pay_to=ADDR, price="$0.01") + def get_weather(): + return jsonify({"report": "sunny"}) + + # Multiple payment options + @app.route("/premium") + @x402.pay([ + PaymentOption(scheme="exact", network="eip155:8453", pay_to=evm, price="$0.01"), + PaymentOption(scheme="exact", network="tron:728126428", pay_to=trx, price="$0.01"), + ]) + def get_premium(): + return jsonify({"data": "premium"}) + + x402.init_app(app) + ``` +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from ..types import PaymentOption, RouteConfig + +if TYPE_CHECKING: + from flask import Flask + + from ...server import x402ResourceServerSync + from ..types import PaywallConfig + from ..x402_http_server import PaywallProvider + + +class x402_app: + """Flask decorator-based x402 payment integration. + + Collects payment configurations from decorated route functions, + then registers them as middleware when init_app() is called. + + Args: + server: Pre-configured x402ResourceServerSync instance. + paywall_config: Optional paywall UI configuration. + paywall_provider: Optional custom paywall provider. + sync_facilitator_on_start: Fetch facilitator support on first request. + """ + + def __init__( + self, + server: x402ResourceServerSync, + paywall_config: PaywallConfig | None = None, + paywall_provider: PaywallProvider | None = None, + sync_facilitator_on_start: bool = True, + ) -> None: + self._server = server + self._paywall_config = paywall_config + self._paywall_provider = paywall_provider + self._sync_facilitator_on_start = sync_facilitator_on_start + self._decorated_functions: list[tuple[Callable[..., Any], RouteConfig]] = [] + + def pay( + self, + accepts: PaymentOption | list[PaymentOption] | None = None, + *, + scheme: str | None = None, + pay_to: str | None = None, + price: str | None = None, + network: str | None = None, + assets: list[str] | None = None, + max_timeout_seconds: int | None = None, + extra: dict[str, Any] | None = None, + description: str | None = None, + mime_type: str | None = None, + extensions: dict[str, Any] | None = None, + ) -> Callable[..., Any]: + """Decorator to mark a Flask route as payment-protected. + + Can be used with a PaymentOption / list of PaymentOptions, or with + keyword arguments for a single payment option. + + Args: + accepts: PaymentOption or list of PaymentOptions. + scheme: Payment scheme (shorthand for single option). + pay_to: Recipient address (shorthand for single option). + price: Price string (shorthand for single option). + network: CAIP-2 network identifier (shorthand for single option). + assets: Asset symbols to resolve via AssetRegistry. + max_timeout_seconds: Maximum payment timeout. + extra: Scheme-specific additional data. + description: Route description. + mime_type: Response MIME type. + extensions: Extension declarations. + + Returns: + Decorated function (unchanged). + """ + if accepts is None and scheme is not None: + if pay_to is None or price is None or network is None: + msg = "pay_to, price, and network are required when using keyword arguments" + raise ValueError(msg) + accepts = PaymentOption( + scheme=scheme, + pay_to=pay_to, + price=price, + network=network, + assets=assets, + max_timeout_seconds=max_timeout_seconds, + extra=extra, + ) + + if accepts is None: + msg = ( + "Either 'accepts' (PaymentOption or list) or keyword arguments " + "(scheme, pay_to, price, network) must be provided" + ) + raise ValueError(msg) + + route_config = RouteConfig( + accepts=accepts, + description=description, + mime_type=mime_type, + extensions=extensions, + ) + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + func._x402_config = route_config # type: ignore[attr-defined] + self._decorated_functions.append((func, route_config)) + return func + + return decorator + + def init_app(self, app: Flask) -> None: + """Scan decorated routes and attach payment middleware. + + Must be called after all routes are registered with the Flask app. + Iterates over registered URL rules, matches them with decorated + functions, and builds the routes config for the payment middleware. + + Args: + app: Flask application instance. + """ + from ..middleware.flask import PaymentMiddleware + + routes: dict[str, RouteConfig] = {} + + for rule in app.url_map.iter_rules(): + view_func = app.view_functions.get(rule.endpoint) + if view_func is None: + continue + + config = getattr(view_func, "_x402_config", None) + if config is None: + continue + + for method in rule.methods or set(): + if method in ("OPTIONS", "HEAD"): + continue + key = f"{method} {rule.rule}" + routes[key] = config + + if not routes: + return + + PaymentMiddleware( + app, + routes, + self._server, + paywall_config=self._paywall_config, + paywall_provider=self._paywall_provider, + sync_facilitator_on_start=self._sync_facilitator_on_start, + ) diff --git a/python/x402/src/bankofai/x402/http/x402_http_server.py b/python/x402/src/bankofai/x402/http/x402_http_server.py index 9d207a79..785ee80d 100644 --- a/python/x402/src/bankofai/x402/http/x402_http_server.py +++ b/python/x402/src/bankofai/x402/http/x402_http_server.py @@ -272,6 +272,7 @@ async def _build_payment_requirements_from_options( price=price, network=option.network, max_timeout_seconds=option.max_timeout_seconds, + assets=option.assets, ) requirements = self._server.build_payment_requirements(config) @@ -431,6 +432,7 @@ def _build_payment_requirements_from_options_sync( price=price, network=option.network, max_timeout_seconds=option.max_timeout_seconds, + assets=option.assets, ) requirements = self._server.build_payment_requirements(config) diff --git a/python/x402/tests/unit/http/decorators/__init__.py b/python/x402/tests/unit/http/decorators/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/x402/tests/unit/http/decorators/test_fastapi_decorator.py b/python/x402/tests/unit/http/decorators/test_fastapi_decorator.py new file mode 100644 index 00000000..27200d3a --- /dev/null +++ b/python/x402/tests/unit/http/decorators/test_fastapi_decorator.py @@ -0,0 +1,219 @@ +"""Tests for FastAPI decorator-based x402 payment integration.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from bankofai.x402.http.decorators.fastapi import x402_app +from bankofai.x402.http.types import PaymentOption, RouteConfig + + +@pytest.fixture() +def mock_server() -> MagicMock: + """Create a mock x402ResourceServer (async).""" + server = MagicMock() + server.build_payment_requirements.return_value = [] + return server + + +@pytest.fixture() +def x402(mock_server: MagicMock) -> x402_app: + """Create an x402_app instance with mock server.""" + return x402_app(mock_server) + + +class TestPayDecorator: + """Tests for the @x402.pay() decorator.""" + + def test_single_option_via_keywords(self, x402: x402_app) -> None: + """Should create a PaymentOption from keyword arguments.""" + + @x402.pay( + scheme="exact", + pay_to="0x123", + price="$0.01", + network="eip155:8453", + ) + async def handler() -> dict: + return {"ok": True} + + config = handler._x402_config # type: ignore[attr-defined] + assert isinstance(config, RouteConfig) + assert isinstance(config.accepts, PaymentOption) + assert config.accepts.scheme == "exact" + + def test_single_option_via_accepts(self, x402: x402_app) -> None: + """Should accept a PaymentOption directly.""" + option = PaymentOption( + scheme="exact", + pay_to="0x123", + price="$0.01", + network="eip155:8453", + ) + + @x402.pay(option) + async def handler() -> dict: + return {"ok": True} + + config = handler._x402_config # type: ignore[attr-defined] + assert config.accepts is option + + def test_multiple_options(self, x402: x402_app) -> None: + """Should accept a list of PaymentOptions.""" + options = [ + PaymentOption(scheme="exact", pay_to="0x123", price="$0.01", network="eip155:8453"), + PaymentOption(scheme="exact", pay_to="Txyz", price="$0.01", network="tron:728126428"), + ] + + @x402.pay(options) + async def handler() -> dict: + return {"ok": True} + + config = handler._x402_config # type: ignore[attr-defined] + assert isinstance(config.accepts, list) + assert len(config.accepts) == 2 + + def test_assets_field(self, x402: x402_app) -> None: + """Should pass assets field through PaymentOption.""" + + @x402.pay( + scheme="exact", + pay_to="0x123", + price="$0.01", + network="eip155:8453", + assets=["USDC", "USDT"], + ) + async def handler() -> dict: + return {"ok": True} + + config = handler._x402_config # type: ignore[attr-defined] + assert config.accepts.assets == ["USDC", "USDT"] + + def test_route_metadata(self, x402: x402_app) -> None: + """Should pass description and mime_type to RouteConfig.""" + + @x402.pay( + scheme="exact", + pay_to="0x123", + price="$0.01", + network="eip155:8453", + description="Weather API", + mime_type="application/json", + ) + async def handler() -> dict: + return {"ok": True} + + config = handler._x402_config # type: ignore[attr-defined] + assert config.description == "Weather API" + assert config.mime_type == "application/json" + + def test_raises_without_required_keywords(self, x402: x402_app) -> None: + """Should raise ValueError when keyword args are incomplete.""" + with pytest.raises(ValueError, match="pay_to, price, and network are required"): + + @x402.pay(scheme="exact") + async def handler() -> dict: + return {"ok": True} + + def test_raises_without_any_args(self, x402: x402_app) -> None: + """Should raise ValueError when no arguments provided.""" + with pytest.raises(ValueError, match="Either 'accepts'"): + + @x402.pay() + async def handler() -> dict: + return {"ok": True} + + +class TestInitApp: + """Tests for x402_app.init_app().""" + + def test_registers_middleware_for_decorated_routes(self, mock_server: MagicMock) -> None: + """Should scan FastAPI routes and register payment middleware.""" + pytest.importorskip("fastapi") + from fastapi import FastAPI + + app = FastAPI() + x402 = x402_app(mock_server) + + @app.get("/weather") + @x402.pay(scheme="exact", pay_to="0x123", price="$0.01", network="eip155:8453") + async def get_weather() -> dict: + return {"report": "sunny"} + + with patch("bankofai.x402.http.middleware.fastapi.payment_middleware") as mock_pm: + mock_pm.return_value = MagicMock() + x402.init_app(app) + + mock_pm.assert_called_once() + call_args = mock_pm.call_args + routes = call_args[0][0] # First positional arg is routes + + assert isinstance(routes, dict) + assert len(routes) > 0 + route_keys = list(routes.keys()) + assert any("/weather" in k for k in route_keys) + + def test_skips_options_and_head_methods(self, mock_server: MagicMock) -> None: + """Should not register OPTIONS or HEAD methods.""" + pytest.importorskip("fastapi") + from fastapi import FastAPI + + app = FastAPI() + x402 = x402_app(mock_server) + + @app.api_route("/test", methods=["GET", "POST"]) + @x402.pay(scheme="exact", pay_to="0x123", price="$0.01", network="eip155:8453") + async def handler() -> dict: + return {"ok": True} + + with patch("bankofai.x402.http.middleware.fastapi.payment_middleware") as mock_pm: + mock_pm.return_value = MagicMock() + x402.init_app(app) + + routes = mock_pm.call_args[0][0] + route_methods = {k.split(" ")[0] for k in routes} + assert "OPTIONS" not in route_methods + assert "HEAD" not in route_methods + + def test_no_middleware_when_no_decorated_routes(self, mock_server: MagicMock) -> None: + """Should not register middleware when no routes are decorated.""" + pytest.importorskip("fastapi") + from fastapi import FastAPI + + app = FastAPI() + x402 = x402_app(mock_server) + + @app.get("/free") + async def free_route() -> dict: + return {"free": True} + + with patch("bankofai.x402.http.middleware.fastapi.payment_middleware") as mock_pm: + x402.init_app(app) + mock_pm.assert_not_called() + + def test_passes_server_and_config(self, mock_server: MagicMock) -> None: + """Should pass server and paywall_config to middleware.""" + pytest.importorskip("fastapi") + from fastapi import FastAPI + + paywall_config = {"app_name": "TestApp"} + app = FastAPI() + x402 = x402_app( + mock_server, + paywall_config=paywall_config, + ) + + @app.get("/test") + @x402.pay(scheme="exact", pay_to="0x123", price="$0.01", network="eip155:8453") + async def handler() -> dict: + return {"ok": True} + + with patch("bankofai.x402.http.middleware.fastapi.payment_middleware") as mock_pm: + mock_pm.return_value = MagicMock() + x402.init_app(app) + + call_args = mock_pm.call_args + assert call_args[0][1] is mock_server + assert call_args[1]["paywall_config"] is paywall_config diff --git a/python/x402/tests/unit/http/decorators/test_flask_decorator.py b/python/x402/tests/unit/http/decorators/test_flask_decorator.py new file mode 100644 index 00000000..aa03cd33 --- /dev/null +++ b/python/x402/tests/unit/http/decorators/test_flask_decorator.py @@ -0,0 +1,234 @@ +"""Tests for Flask decorator-based x402 payment integration.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from bankofai.x402.http.decorators.flask import x402_app +from bankofai.x402.http.types import PaymentOption, RouteConfig + + +@pytest.fixture() +def mock_server() -> MagicMock: + """Create a mock x402ResourceServerSync.""" + server = MagicMock() + server.build_payment_requirements.return_value = [] + return server + + +@pytest.fixture() +def x402(mock_server: MagicMock) -> x402_app: + """Create an x402_app instance with mock server.""" + return x402_app(mock_server) + + +class TestPayDecorator: + """Tests for the @x402.pay() decorator.""" + + def test_single_option_via_keywords(self, x402: x402_app) -> None: + """Should create a PaymentOption from keyword arguments.""" + + @x402.pay( + scheme="exact", + pay_to="0x123", + price="$0.01", + network="eip155:8453", + ) + def handler() -> str: + return "ok" + + config = handler._x402_config # type: ignore[attr-defined] + assert isinstance(config, RouteConfig) + assert isinstance(config.accepts, PaymentOption) + assert config.accepts.scheme == "exact" + assert config.accepts.pay_to == "0x123" + assert config.accepts.price == "$0.01" + assert config.accepts.network == "eip155:8453" + + def test_single_option_via_accepts(self, x402: x402_app) -> None: + """Should accept a PaymentOption directly.""" + option = PaymentOption( + scheme="exact", + pay_to="0x123", + price="$0.01", + network="eip155:8453", + ) + + @x402.pay(option) + def handler() -> str: + return "ok" + + config = handler._x402_config # type: ignore[attr-defined] + assert config.accepts is option + + def test_multiple_options(self, x402: x402_app) -> None: + """Should accept a list of PaymentOptions.""" + options = [ + PaymentOption(scheme="exact", pay_to="0x123", price="$0.01", network="eip155:8453"), + PaymentOption(scheme="exact", pay_to="Txyz", price="$0.01", network="tron:728126428"), + ] + + @x402.pay(options) + def handler() -> str: + return "ok" + + config = handler._x402_config # type: ignore[attr-defined] + assert isinstance(config.accepts, list) + assert len(config.accepts) == 2 + + def test_assets_field(self, x402: x402_app) -> None: + """Should pass assets field through PaymentOption.""" + + @x402.pay( + scheme="exact", + pay_to="0x123", + price="$0.01", + network="eip155:8453", + assets=["USDC", "USDT"], + ) + def handler() -> str: + return "ok" + + config = handler._x402_config # type: ignore[attr-defined] + assert config.accepts.assets == ["USDC", "USDT"] + + def test_route_metadata(self, x402: x402_app) -> None: + """Should pass description and mime_type to RouteConfig.""" + + @x402.pay( + scheme="exact", + pay_to="0x123", + price="$0.01", + network="eip155:8453", + description="Weather API", + mime_type="application/json", + ) + def handler() -> str: + return "ok" + + config = handler._x402_config # type: ignore[attr-defined] + assert config.description == "Weather API" + assert config.mime_type == "application/json" + + def test_preserves_original_function(self, x402: x402_app) -> None: + """Should not modify the decorated function.""" + + @x402.pay( + scheme="exact", + pay_to="0x123", + price="$0.01", + network="eip155:8453", + ) + def handler() -> str: + return "ok" + + assert handler() == "ok" + + def test_raises_without_required_keywords(self, x402: x402_app) -> None: + """Should raise ValueError when keyword args are incomplete.""" + with pytest.raises(ValueError, match="pay_to, price, and network are required"): + + @x402.pay(scheme="exact") + def handler() -> str: + return "ok" + + def test_raises_without_any_args(self, x402: x402_app) -> None: + """Should raise ValueError when no arguments provided.""" + with pytest.raises(ValueError, match="Either 'accepts'"): + + @x402.pay() + def handler() -> str: + return "ok" + + +class TestInitApp: + """Tests for x402_app.init_app().""" + + def test_registers_middleware_for_decorated_routes(self, mock_server: MagicMock) -> None: + """Should scan Flask routes and register payment middleware.""" + pytest.importorskip("flask") + from flask import Flask + + app = Flask(__name__) + x402 = x402_app(mock_server) + + @app.route("/weather") + @x402.pay(scheme="exact", pay_to="0x123", price="$0.01", network="eip155:8453") + def get_weather() -> str: + return "sunny" + + with patch("bankofai.x402.http.middleware.flask.PaymentMiddleware") as mock_middleware: + x402.init_app(app) + + mock_middleware.assert_called_once() + call_args = mock_middleware.call_args + routes = call_args[0][1] # Second positional arg is routes + + assert isinstance(routes, dict) + assert len(routes) > 0 + # Flask registers GET routes with a "GET /path" key + route_keys = list(routes.keys()) + assert any("/weather" in k for k in route_keys) + + def test_skips_options_and_head_methods(self, mock_server: MagicMock) -> None: + """Should not register OPTIONS or HEAD methods.""" + pytest.importorskip("flask") + from flask import Flask + + app = Flask(__name__) + x402 = x402_app(mock_server) + + @app.route("/test", methods=["GET", "POST", "OPTIONS", "HEAD"]) + @x402.pay(scheme="exact", pay_to="0x123", price="$0.01", network="eip155:8453") + def handler() -> str: + return "ok" + + with patch("bankofai.x402.http.middleware.flask.PaymentMiddleware") as mock_middleware: + x402.init_app(app) + + routes = mock_middleware.call_args[0][1] + route_methods = {k.split(" ")[0] for k in routes} + assert "OPTIONS" not in route_methods + assert "HEAD" not in route_methods + + def test_no_middleware_when_no_decorated_routes(self, mock_server: MagicMock) -> None: + """Should not register middleware when no routes are decorated.""" + pytest.importorskip("flask") + from flask import Flask + + app = Flask(__name__) + x402 = x402_app(mock_server) + + @app.route("/free") + def free_route() -> str: + return "free" + + with patch("bankofai.x402.http.middleware.flask.PaymentMiddleware") as mock_middleware: + x402.init_app(app) + mock_middleware.assert_not_called() + + def test_passes_server_and_config(self, mock_server: MagicMock) -> None: + """Should pass server, paywall_config, and paywall_provider.""" + pytest.importorskip("flask") + from flask import Flask + + paywall_config = {"app_name": "TestApp"} + app = Flask(__name__) + x402 = x402_app( + mock_server, + paywall_config=paywall_config, + ) + + @app.route("/test") + @x402.pay(scheme="exact", pay_to="0x123", price="$0.01", network="eip155:8453") + def handler() -> str: + return "ok" + + with patch("bankofai.x402.http.middleware.flask.PaymentMiddleware") as mock_middleware: + x402.init_app(app) + + call_kwargs = mock_middleware.call_args + assert call_kwargs[0][0] is app + assert call_kwargs[0][2] is mock_server diff --git a/python/x402/tests/unit/http/test_http_server_assets.py b/python/x402/tests/unit/http/test_http_server_assets.py new file mode 100644 index 00000000..55e9dddf --- /dev/null +++ b/python/x402/tests/unit/http/test_http_server_assets.py @@ -0,0 +1,142 @@ +"""Tests for assets passthrough in HTTP server payment option building.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from bankofai.x402.http.types import PaymentOption +from bankofai.x402.http.x402_http_server import x402HTTPResourceServer, x402HTTPResourceServerSync +from bankofai.x402.schemas.config import ResourceConfig + + +@pytest.fixture() +def mock_server() -> MagicMock: + """Create a mock x402ResourceServer (async).""" + server = MagicMock() + server.build_payment_requirements.return_value = [] + return server + + +@pytest.fixture() +def mock_server_sync() -> MagicMock: + """Create a mock x402ResourceServerSync.""" + server = MagicMock() + server.build_payment_requirements.return_value = [] + return server + + +class TestAsyncAssetsPassthrough: + """Tests for assets passthrough in async HTTP server.""" + + @pytest.mark.asyncio() + async def test_assets_passed_to_resource_config(self, mock_server: MagicMock) -> None: + """Should pass assets from PaymentOption to ResourceConfig.""" + http_server = x402HTTPResourceServer(mock_server, {}) + + option = PaymentOption( + scheme="exact", + pay_to="0x123", + price="$0.01", + network="eip155:8453", + assets=["USDC", "USDT"], + ) + + mock_context = MagicMock() + await http_server._build_payment_requirements_from_options(option, mock_context, None) + + mock_server.build_payment_requirements.assert_called_once() + config = mock_server.build_payment_requirements.call_args[0][0] + assert isinstance(config, ResourceConfig) + assert config.assets == ["USDC", "USDT"] + + @pytest.mark.asyncio() + async def test_none_assets_passed_through(self, mock_server: MagicMock) -> None: + """Should pass None assets when not specified.""" + http_server = x402HTTPResourceServer(mock_server, {}) + + option = PaymentOption( + scheme="exact", + pay_to="0x123", + price="$0.01", + network="eip155:8453", + ) + + mock_context = MagicMock() + await http_server._build_payment_requirements_from_options(option, mock_context, None) + + config = mock_server.build_payment_requirements.call_args[0][0] + assert config.assets is None + + @pytest.mark.asyncio() + async def test_multiple_options_each_pass_assets(self, mock_server: MagicMock) -> None: + """Should pass assets for each PaymentOption independently.""" + http_server = x402HTTPResourceServer(mock_server, {}) + + options = [ + PaymentOption( + scheme="exact", + pay_to="0x123", + price="$0.01", + network="eip155:8453", + assets=["USDC"], + ), + PaymentOption( + scheme="exact", + pay_to="0x456", + price="$0.02", + network="eip155:8453", + assets=["USDT", "DAI"], + ), + ] + + mock_context = MagicMock() + await http_server._build_payment_requirements_from_options(options, mock_context, None) + + assert mock_server.build_payment_requirements.call_count == 2 + config1 = mock_server.build_payment_requirements.call_args_list[0][0][0] + config2 = mock_server.build_payment_requirements.call_args_list[1][0][0] + assert config1.assets == ["USDC"] + assert config2.assets == ["USDT", "DAI"] + + +class TestSyncAssetsPassthrough: + """Tests for assets passthrough in sync HTTP server.""" + + def test_assets_passed_to_resource_config(self, mock_server_sync: MagicMock) -> None: + """Should pass assets from PaymentOption to ResourceConfig.""" + http_server = x402HTTPResourceServerSync(mock_server_sync, {}) + + option = PaymentOption( + scheme="exact", + pay_to="0x123", + price="$0.01", + network="eip155:8453", + assets=["USDC", "USDT"], + ) + + mock_context = MagicMock() + http_server._build_payment_requirements_from_options_sync(option, mock_context) + + mock_server_sync.build_payment_requirements.assert_called_once() + config = mock_server_sync.build_payment_requirements.call_args[0][0] + assert isinstance(config, ResourceConfig) + assert config.assets == ["USDC", "USDT"] + + def test_none_assets_passed_through(self, mock_server_sync: MagicMock) -> None: + """Should pass None assets when not specified.""" + http_server = x402HTTPResourceServerSync(mock_server_sync, {}) + + option = PaymentOption( + scheme="exact", + pay_to="0x123", + price="$0.01", + network="eip155:8453", + ) + + mock_context = MagicMock() + http_server._build_payment_requirements_from_options_sync(option, mock_context) + + config = mock_server_sync.build_payment_requirements.call_args[0][0] + assert config.assets is None diff --git a/typescript/packages/core/src/server/index.ts b/typescript/packages/core/src/server/index.ts index 412c94d7..364490dd 100644 --- a/typescript/packages/core/src/server/index.ts +++ b/typescript/packages/core/src/server/index.ts @@ -23,4 +23,5 @@ export type { ProcessSettleSuccessResponse, ProcessSettleFailureResponse, RouteValidationError, + PaymentOption, } from "../http/x402HTTPResourceServer"; diff --git a/typescript/packages/core/src/server/x402ResourceServer.ts b/typescript/packages/core/src/server/x402ResourceServer.ts index e78487ee..116a7bc7 100644 --- a/typescript/packages/core/src/server/x402ResourceServer.ts +++ b/typescript/packages/core/src/server/x402ResourceServer.ts @@ -545,6 +545,7 @@ export class x402ResourceServer { network: Network; maxTimeoutSeconds?: number; extra?: Record; + assets?: string[]; }>, context: TContext, ): Promise { @@ -564,6 +565,7 @@ export class x402ResourceServer { network: option.network, maxTimeoutSeconds: option.maxTimeoutSeconds, extra: option.extra, + assets: option.assets, }; // Use existing buildPaymentRequirements for each option diff --git a/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts b/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts index b904637f..5ebf2036 100644 --- a/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts +++ b/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import { x402ResourceServer } from "../../../src/server/x402ResourceServer"; import { MockFacilitatorClient, @@ -401,6 +401,133 @@ describe("x402ResourceServer", () => { }); }); + describe("buildPaymentRequirementsFromOptions", () => { + it("should pass assets field through to buildPaymentRequirements", async () => { + const mockClient = new MockFacilitatorClient( + buildSupportedResponse({ + kinds: [{ x402Version: 2, scheme: "test-scheme", network: "test:network" as Network }], + }), + ); + + const server = new x402ResourceServer(mockClient); + const mockScheme = new MockSchemeNetworkServer("test-scheme", { + amount: "1000000", + asset: "USDC", + extra: {}, + }); + + server.register("test:network" as Network, mockScheme); + await server.initialize(); + + // Mock buildPaymentRequirements to capture the ResourceConfig without hitting AssetRegistry + const buildSpy = vi + .spyOn(server, "buildPaymentRequirements") + .mockResolvedValue([buildPaymentRequirements()]); + + await server.buildPaymentRequirementsFromOptions( + [ + { + scheme: "test-scheme", + payTo: "recipient", + price: "$1.00", + network: "test:network" as Network, + assets: ["USDC", "USDT"], + }, + ], + {}, + ); + + expect(buildSpy).toHaveBeenCalledWith( + expect.objectContaining({ + assets: ["USDC", "USDT"], + }), + ); + + buildSpy.mockRestore(); + }); + + it("should resolve dynamic payTo and price functions", async () => { + const mockClient = new MockFacilitatorClient( + buildSupportedResponse({ + kinds: [{ x402Version: 2, scheme: "test-scheme", network: "test:network" as Network }], + }), + ); + + const server = new x402ResourceServer(mockClient); + const mockScheme = new MockSchemeNetworkServer("test-scheme", { + amount: "500000", + asset: "USDT", + extra: {}, + }); + + server.register("test:network" as Network, mockScheme); + await server.initialize(); + + const buildSpy = vi + .spyOn(server, "buildPaymentRequirements") + .mockResolvedValue([buildPaymentRequirements()]); + + await server.buildPaymentRequirementsFromOptions( + [ + { + scheme: "test-scheme", + payTo: () => "dynamic_recipient", + price: () => "$2.00", + network: "test:network" as Network, + }, + ], + {}, + ); + + expect(buildSpy).toHaveBeenCalledWith( + expect.objectContaining({ + payTo: "dynamic_recipient", + price: "$2.00", + }), + ); + + buildSpy.mockRestore(); + }); + + it("should handle multiple payment options and aggregate results", async () => { + const mockClient = new MockFacilitatorClient( + buildSupportedResponse({ + kinds: [{ x402Version: 2, scheme: "test-scheme", network: "test:network" as Network }], + }), + ); + + const server = new x402ResourceServer(mockClient); + const mockScheme = new MockSchemeNetworkServer("test-scheme", { + amount: "1000000", + asset: "USDC", + extra: {}, + }); + + server.register("test:network" as Network, mockScheme); + await server.initialize(); + + const results = await server.buildPaymentRequirementsFromOptions( + [ + { + scheme: "test-scheme", + payTo: "recipient1", + price: "$1.00", + network: "test:network" as Network, + }, + { + scheme: "test-scheme", + payTo: "recipient2", + price: "$2.00", + network: "test:network" as Network, + }, + ], + {}, + ); + + expect(results).toHaveLength(2); + }); + }); + describe("Lifecycle hooks", () => { let server: x402ResourceServer; let mockClient: MockFacilitatorClient; diff --git a/typescript/packages/http/express/src/index.ts b/typescript/packages/http/express/src/index.ts index fe3ab74a..242ecf0b 100644 --- a/typescript/packages/http/express/src/index.ts +++ b/typescript/packages/http/express/src/index.ts @@ -399,3 +399,6 @@ export { RouteConfigurationError } from "@bankofai/x402-core/server"; export type { RouteValidationError } from "@bankofai/x402-core/server"; export { ExpressAdapter } from "./adapter"; + +export { withPayment } from "./withPayment"; +export type { WithPaymentOptions } from "./withPayment"; diff --git a/typescript/packages/http/express/src/withPayment.test.ts b/typescript/packages/http/express/src/withPayment.test.ts new file mode 100644 index 00000000..4f3cc65b --- /dev/null +++ b/typescript/packages/http/express/src/withPayment.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Request, Response, NextFunction } from "express"; +import type { x402HTTPResourceServer } from "@bankofai/x402-core/server"; + +import { withPayment } from "./withPayment"; +import { x402ResourceServer } from "@bankofai/x402-core/server"; + +// Track constructor calls to verify route config passed correctly +let lastConstructedRoutes: Record | undefined; + +vi.mock("@bankofai/x402-core/server", () => ({ + x402ResourceServer: vi.fn(), + x402HTTPResourceServer: vi.fn().mockImplementation((_server, routes) => { + lastConstructedRoutes = routes; + return { + initialize: vi.fn().mockResolvedValue(undefined), + processHTTPRequest: vi.fn().mockResolvedValue({ type: "no-payment-required" }), + processSettlement: vi.fn(), + registerPaywallProvider: vi.fn(), + requiresPayment: vi.fn().mockReturnValue(false), + routes, + server: { + hasExtension: vi.fn().mockReturnValue(false), + registerExtension: vi.fn(), + }, + } as unknown as x402HTTPResourceServer; + }), +})); + +/** + * Create a mock x402ResourceServer for testing. + * + * @returns A mock server instance. + */ +function createMockServer() { + return new x402ResourceServer(undefined as never); +} + +/** + * Create mock Express request, response, and next function for testing. + * + * @param path - The request path. + * @returns Mock request, response, and next function. + */ +function createMockReqRes(path = "/test") { + const req = { + path, + method: "GET", + headers: {}, + header: vi.fn(() => undefined), + } as unknown as Request; + + const res = { + statusCode: 200, + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + setHeader: vi.fn(), + writeHead: vi.fn().mockReturnThis(), + write: vi.fn().mockReturnValue(true), + end: vi.fn().mockReturnThis(), + flushHeaders: vi.fn(), + } as unknown as Response; + + const next = vi.fn() as NextFunction; + + return { req, res, next }; +} + +describe("withPayment (Express)", () => { + beforeEach(() => { + vi.clearAllMocks(); + lastConstructedRoutes = undefined; + }); + + it("should create an x402HTTPResourceServer with wildcard route from single PaymentOption", () => { + const server = createMockServer(); + const accepts = { + scheme: "exact", + payTo: "0x123", + price: "$0.01", + network: "eip155:8453", + }; + + withPayment(accepts, server); + + expect(lastConstructedRoutes).toBeDefined(); + expect(lastConstructedRoutes!["*"]).toMatchObject({ + accepts: { scheme: "exact", payTo: "0x123", price: "$0.01", network: "eip155:8453" }, + }); + }); + + it("should create an x402HTTPResourceServer with wildcard route from PaymentOption array", () => { + const server = createMockServer(); + const accepts = [ + { scheme: "exact", payTo: "0x123", price: "$0.01", network: "eip155:8453" }, + { scheme: "exact", payTo: "Txyz", price: "$0.01", network: "tron:728126428" }, + ]; + + withPayment(accepts, server); + + expect(lastConstructedRoutes).toBeDefined(); + const routeConfig = lastConstructedRoutes!["*"] as { accepts: unknown[] }; + expect(routeConfig.accepts).toHaveLength(2); + }); + + it("should pass assets field through PaymentOption", () => { + const server = createMockServer(); + const accepts = { + scheme: "exact", + payTo: "0x123", + price: "$0.01", + network: "eip155:8453", + assets: ["USDC", "USDT"], + }; + + withPayment(accepts, server); + + const routeConfig = lastConstructedRoutes!["*"] as { accepts: { assets: string[] } }; + expect(routeConfig.accepts.assets).toEqual(["USDC", "USDT"]); + }); + + it("should pass optional route metadata to RouteConfig", () => { + const server = createMockServer(); + const accepts = { scheme: "exact", payTo: "0x123", price: "$0.01", network: "eip155:8453" }; + + withPayment(accepts, server, { + description: "Weather API", + mimeType: "application/json", + }); + + const routeConfig = lastConstructedRoutes!["*"] as { + description: string; + mimeType: string; + }; + expect(routeConfig.description).toBe("Weather API"); + expect(routeConfig.mimeType).toBe("application/json"); + }); + + it("should return a callable Express middleware", async () => { + const server = createMockServer(); + const accepts = { scheme: "exact", payTo: "0x123", price: "$0.01", network: "eip155:8453" }; + + const middleware = withPayment(accepts, server); + expect(typeof middleware).toBe("function"); + + const { req, res, next } = createMockReqRes(); + await middleware(req, res, next); + + // Should pass through since requiresPayment returns false + expect(next).toHaveBeenCalled(); + }); +}); diff --git a/typescript/packages/http/express/src/withPayment.ts b/typescript/packages/http/express/src/withPayment.ts new file mode 100644 index 00000000..0c59402d --- /dev/null +++ b/typescript/packages/http/express/src/withPayment.ts @@ -0,0 +1,65 @@ +/** + * Per-route payment HOF for Express. + * + * Provides a declarative way to protect individual Express routes with x402 payment + * requirements, as an alternative to the global middleware approach. + */ + +import { + x402HTTPResourceServer, + type PaywallConfig, + type PaywallProvider, +} from "@bankofai/x402-core/server"; +import type { x402ResourceServer } from "@bankofai/x402-core/server"; +import type { PaymentOption } from "@bankofai/x402-core/server"; +import { paymentMiddlewareFromHTTPServer } from "./index.js"; + +/** + * Optional configuration for the withPayment HOF. + */ +export interface WithPaymentOptions { + description?: string; + mimeType?: string; + paywallConfig?: PaywallConfig; + paywall?: PaywallProvider; + extensions?: Record; +} + +/** + * Express per-route payment HOF. + * + * Returns an Express middleware that enforces x402 payment for a single route. + * Internally constructs a wildcard-matched x402HTTPResourceServer and delegates + * to the existing paymentMiddlewareFromHTTPServer logic. + * + * @param accepts - Payment option(s) for this route + * @param server - Pre-configured x402ResourceServer instance + * @param options - Optional route metadata and paywall configuration + * @returns Express middleware handler + * + * @example + * ```typescript + * import { withPayment } from "@bankofai/x402-express"; + * + * app.get("/weather", + * withPayment({ scheme: "exact", network: "eip155:8453", payTo: addr, price: "$0.01" }, server), + * (req, res) => { res.json({ report: "sunny" }); } + * ); + * ``` + */ +export function withPayment( + accepts: PaymentOption | PaymentOption[], + server: x402ResourceServer, + options?: WithPaymentOptions, +) { + const routeConfig = { + accepts, + description: options?.description, + mimeType: options?.mimeType, + extensions: options?.extensions, + }; + + const httpServer = new x402HTTPResourceServer(server, { "*": routeConfig }); + + return paymentMiddlewareFromHTTPServer(httpServer, options?.paywallConfig, options?.paywall); +} diff --git a/typescript/packages/http/hono/src/index.ts b/typescript/packages/http/hono/src/index.ts index 570b684b..ec133345 100644 --- a/typescript/packages/http/hono/src/index.ts +++ b/typescript/packages/http/hono/src/index.ts @@ -308,3 +308,6 @@ export { RouteConfigurationError } from "@bankofai/x402-core/server"; export type { RouteValidationError } from "@bankofai/x402-core/server"; export { HonoAdapter } from "./adapter"; + +export { withPayment } from "./withPayment"; +export type { WithPaymentOptions } from "./withPayment"; diff --git a/typescript/packages/http/hono/src/withPayment.test.ts b/typescript/packages/http/hono/src/withPayment.test.ts new file mode 100644 index 00000000..edbbef55 --- /dev/null +++ b/typescript/packages/http/hono/src/withPayment.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { x402HTTPResourceServer } from "@bankofai/x402-core/server"; + +import { withPayment } from "./withPayment"; +import { x402ResourceServer } from "@bankofai/x402-core/server"; + +// Track constructor calls to verify route config passed correctly +let lastConstructedRoutes: Record | undefined; + +vi.mock("@bankofai/x402-core/server", () => ({ + x402ResourceServer: vi.fn(), + x402HTTPResourceServer: vi.fn().mockImplementation((_server, routes) => { + lastConstructedRoutes = routes; + return { + initialize: vi.fn().mockResolvedValue(undefined), + processHTTPRequest: vi.fn().mockResolvedValue({ type: "no-payment-required" }), + processSettlement: vi.fn(), + registerPaywallProvider: vi.fn(), + requiresPayment: vi.fn().mockReturnValue(false), + routes, + server: { + hasExtension: vi.fn().mockReturnValue(false), + registerExtension: vi.fn(), + }, + } as unknown as x402HTTPResourceServer; + }), +})); + +/** + * Create a mock x402ResourceServer for testing. + * + * @returns A mock server instance. + */ +function createMockServer() { + return new x402ResourceServer(undefined as never); +} + +describe("withPayment (Hono)", () => { + beforeEach(() => { + vi.clearAllMocks(); + lastConstructedRoutes = undefined; + }); + + it("should create an x402HTTPResourceServer with wildcard route from single PaymentOption", () => { + const server = createMockServer(); + const accepts = { + scheme: "exact", + payTo: "0x123", + price: "$0.01", + network: "eip155:8453", + }; + + withPayment(accepts, server); + + expect(lastConstructedRoutes).toBeDefined(); + expect(lastConstructedRoutes!["*"]).toMatchObject({ + accepts: { scheme: "exact", payTo: "0x123", price: "$0.01", network: "eip155:8453" }, + }); + }); + + it("should create an x402HTTPResourceServer with wildcard route from PaymentOption array", () => { + const server = createMockServer(); + const accepts = [ + { scheme: "exact", payTo: "0x123", price: "$0.01", network: "eip155:8453" }, + { scheme: "exact", payTo: "Txyz", price: "$0.01", network: "tron:728126428" }, + ]; + + withPayment(accepts, server); + + expect(lastConstructedRoutes).toBeDefined(); + const routeConfig = lastConstructedRoutes!["*"] as { accepts: unknown[] }; + expect(routeConfig.accepts).toHaveLength(2); + }); + + it("should pass assets field through PaymentOption", () => { + const server = createMockServer(); + const accepts = { + scheme: "exact", + payTo: "0x123", + price: "$0.01", + network: "eip155:8453", + assets: ["USDC", "USDT"], + }; + + withPayment(accepts, server); + + const routeConfig = lastConstructedRoutes!["*"] as { accepts: { assets: string[] } }; + expect(routeConfig.accepts.assets).toEqual(["USDC", "USDT"]); + }); + + it("should pass optional route metadata to RouteConfig", () => { + const server = createMockServer(); + const accepts = { scheme: "exact", payTo: "0x123", price: "$0.01", network: "eip155:8453" }; + + withPayment(accepts, server, { + description: "Weather API", + mimeType: "application/json", + }); + + const routeConfig = lastConstructedRoutes!["*"] as { + description: string; + mimeType: string; + }; + expect(routeConfig.description).toBe("Weather API"); + expect(routeConfig.mimeType).toBe("application/json"); + }); + + it("should return a Hono MiddlewareHandler function", () => { + const server = createMockServer(); + const accepts = { scheme: "exact", payTo: "0x123", price: "$0.01", network: "eip155:8453" }; + + const middleware = withPayment(accepts, server); + expect(typeof middleware).toBe("function"); + }); +}); diff --git a/typescript/packages/http/hono/src/withPayment.ts b/typescript/packages/http/hono/src/withPayment.ts new file mode 100644 index 00000000..c59d219e --- /dev/null +++ b/typescript/packages/http/hono/src/withPayment.ts @@ -0,0 +1,66 @@ +/** + * Per-route payment HOF for Hono. + * + * Provides a declarative way to protect individual Hono routes with x402 payment + * requirements, as an alternative to the global middleware approach. + */ + +import { + x402HTTPResourceServer, + type PaywallConfig, + type PaywallProvider, +} from "@bankofai/x402-core/server"; +import type { x402ResourceServer } from "@bankofai/x402-core/server"; +import type { PaymentOption } from "@bankofai/x402-core/server"; +import type { MiddlewareHandler } from "hono"; +import { paymentMiddlewareFromHTTPServer } from "./index.js"; + +/** + * Optional configuration for the withPayment HOF. + */ +export interface WithPaymentOptions { + description?: string; + mimeType?: string; + paywallConfig?: PaywallConfig; + paywall?: PaywallProvider; + extensions?: Record; +} + +/** + * Hono per-route payment HOF. + * + * Returns a Hono middleware that enforces x402 payment for a single route. + * Internally constructs a wildcard-matched x402HTTPResourceServer and delegates + * to the existing paymentMiddlewareFromHTTPServer logic. + * + * @param accepts - Payment option(s) for this route + * @param server - Pre-configured x402ResourceServer instance + * @param options - Optional route metadata and paywall configuration + * @returns Hono middleware handler + * + * @example + * ```typescript + * import { withPayment } from "@bankofai/x402-hono"; + * + * app.get("/weather", + * withPayment({ scheme: "exact", network: "eip155:8453", payTo: addr, price: "$0.01" }, server), + * (c) => c.json({ report: "sunny" }) + * ); + * ``` + */ +export function withPayment( + accepts: PaymentOption | PaymentOption[], + server: x402ResourceServer, + options?: WithPaymentOptions, +): MiddlewareHandler { + const routeConfig = { + accepts, + description: options?.description, + mimeType: options?.mimeType, + extensions: options?.extensions, + }; + + const httpServer = new x402HTTPResourceServer(server, { "*": routeConfig }); + + return paymentMiddlewareFromHTTPServer(httpServer, options?.paywallConfig, options?.paywall); +} diff --git a/typescript/packages/http/next/src/index.ts b/typescript/packages/http/next/src/index.ts index ea26fdf7..58089edd 100644 --- a/typescript/packages/http/next/src/index.ts +++ b/typescript/packages/http/next/src/index.ts @@ -401,3 +401,6 @@ export { export type { RouteValidationError } from "@bankofai/x402-core/server"; export { NextAdapter } from "./adapter"; + +export { withPayment } from "./withPayment"; +export type { WithPaymentOptions } from "./withPayment"; diff --git a/typescript/packages/http/next/src/withPayment.test.ts b/typescript/packages/http/next/src/withPayment.test.ts new file mode 100644 index 00000000..6d2a9c5f --- /dev/null +++ b/typescript/packages/http/next/src/withPayment.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { withPayment } from "./withPayment"; +import { withX402 } from "./index.js"; + +// Track the routeConfig passed to withX402 +let lastRouteConfig: Record | undefined; + +vi.mock("./index.js", () => ({ + withX402: vi.fn().mockImplementation((_handler, routeConfig, _) => { + lastRouteConfig = routeConfig; + return vi.fn(); + }), +})); + +describe("withPayment (Next.js)", () => { + beforeEach(() => { + vi.clearAllMocks(); + lastRouteConfig = undefined; + }); + + it("should delegate to withX402 with correct RouteConfig from single PaymentOption", () => { + const handler = vi.fn(); + const server = {} as never; + const accepts = { + scheme: "exact", + payTo: "0x123", + price: "$0.01", + network: "eip155:8453", + }; + + withPayment(handler, accepts, server); + + expect(withX402).toHaveBeenCalledOnce(); + expect(lastRouteConfig).toMatchObject({ + accepts: { scheme: "exact", payTo: "0x123", price: "$0.01", network: "eip155:8453" }, + }); + }); + + it("should delegate to withX402 with correct RouteConfig from PaymentOption array", () => { + const handler = vi.fn(); + const server = {} as never; + const accepts = [ + { scheme: "exact", payTo: "0x123", price: "$0.01", network: "eip155:8453" }, + { scheme: "exact", payTo: "Txyz", price: "$0.01", network: "tron:728126428" }, + ]; + + withPayment(handler, accepts, server); + + expect(withX402).toHaveBeenCalledOnce(); + const routeConfig = lastRouteConfig as { accepts: unknown[] }; + expect(routeConfig.accepts).toHaveLength(2); + }); + + it("should pass assets field through PaymentOption", () => { + const handler = vi.fn(); + const server = {} as never; + const accepts = { + scheme: "exact", + payTo: "0x123", + price: "$0.01", + network: "eip155:8453", + assets: ["USDC", "USDT"], + }; + + withPayment(handler, accepts, server); + + const routeConfig = lastRouteConfig as { accepts: { assets: string[] } }; + expect(routeConfig.accepts.assets).toEqual(["USDC", "USDT"]); + }); + + it("should pass optional route metadata to RouteConfig", () => { + const handler = vi.fn(); + const server = {} as never; + const accepts = { scheme: "exact", payTo: "0x123", price: "$0.01", network: "eip155:8453" }; + + withPayment(handler, accepts, server, { + description: "Premium API", + mimeType: "application/json", + }); + + expect(lastRouteConfig).toMatchObject({ + description: "Premium API", + mimeType: "application/json", + }); + }); + + it("should pass paywallConfig and paywall to withX402", () => { + const handler = vi.fn(); + const server = {} as never; + const accepts = { scheme: "exact", payTo: "0x123", price: "$0.01", network: "eip155:8453" }; + const paywallConfig = { appName: "TestApp" }; + + withPayment(handler, accepts, server, { paywallConfig }); + + expect(withX402).toHaveBeenCalledWith( + handler, + expect.any(Object), + server, + paywallConfig, + undefined, + ); + }); + + it("should return the wrapped handler from withX402", () => { + const handler = vi.fn(); + const server = {} as never; + const accepts = { scheme: "exact", payTo: "0x123", price: "$0.01", network: "eip155:8453" }; + + const result = withPayment(handler, accepts, server); + expect(typeof result).toBe("function"); + }); +}); diff --git a/typescript/packages/http/next/src/withPayment.ts b/typescript/packages/http/next/src/withPayment.ts new file mode 100644 index 00000000..5394e545 --- /dev/null +++ b/typescript/packages/http/next/src/withPayment.ts @@ -0,0 +1,66 @@ +/** + * Per-route payment HOF for Next.js App Router. + * + * Provides a simplified API for protecting individual Next.js route handlers + * with x402 payment requirements, wrapping the existing withX402 function. + */ + +import type { x402ResourceServer } from "@bankofai/x402-core/server"; +import type { PaywallConfig, PaywallProvider, PaymentOption } from "@bankofai/x402-core/server"; +import type { NextRequest, NextResponse } from "next/server"; +import { withX402 } from "./index.js"; + +/** + * Optional configuration for the withPayment HOF. + */ +export interface WithPaymentOptions { + description?: string; + mimeType?: string; + paywallConfig?: PaywallConfig; + paywall?: PaywallProvider; + extensions?: Record; +} + +/** + * Next.js per-route payment HOF. + * + * Wraps a Next.js App Router route handler with x402 payment protection. + * This is a simplified version of withX402 that accepts PaymentOption(s) + * directly instead of a full RouteConfig. + * + * @param handler - The route handler to protect + * @param accepts - Payment option(s) for this route + * @param server - Pre-configured x402ResourceServer instance + * @param options - Optional route metadata and paywall configuration + * @returns A wrapped Next.js route handler + * + * @example + * ```typescript + * import { withPayment } from "@bankofai/x402-next"; + * + * const handler = async (req: NextRequest) => { + * return NextResponse.json({ data: "premium" }); + * }; + * + * export const GET = withPayment( + * handler, + * { scheme: "exact", network: "eip155:8453", payTo: addr, price: "$0.01" }, + * server, + * ); + * ``` + */ +export function withPayment( + handler: (request: NextRequest) => Promise>, + accepts: PaymentOption | PaymentOption[], + server: x402ResourceServer, + options?: WithPaymentOptions, +): (request: NextRequest) => Promise> { + const routeConfig = { + accepts, + description: options?.description, + mimeType: options?.mimeType, + extensions: options?.extensions, + }; + + return withX402(handler, routeConfig, server, options?.paywallConfig, options?.paywall); +}