From 9b31d12921ab41ff5821eb81c64482363928e17e Mon Sep 17 00:00:00 2001 From: Amir Hasan Aref Asl Date: Wed, 24 Dec 2025 13:09:58 +0330 Subject: [PATCH] Add webhook feature for asynchronous response delivery - Introduced `webhook_url` parameter in AsyncClient for webhook support. - Updated README.md with usage instructions for webhooks. - Created example script demonstrating webhook functionality. - Enhanced BaseService to handle webhook responses and validation. - Added unit tests for webhook functionality. - Bumped version to 1.5.0. --- README.md | 16 +++ examples/webhook_example.py | 45 ++++++++ magicalapi/client.py | 53 +++++++--- magicalapi/services/base_service.py | 107 +++++++++++++++---- magicalapi/settings.py | 1 + magicalapi/types/schemas.py | 11 ++ pyproject.toml | 2 +- smoke/test_smoke.py | 2 + tests/services/test_webhook_url.py | 155 ++++++++++++++++++++++++++++ 9 files changed, 357 insertions(+), 35 deletions(-) create mode 100644 examples/webhook_example.py create mode 100644 tests/services/test_webhook_url.py diff --git a/README.md b/README.md index 5e12f9a..34a9c52 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,22 @@ client = AsyncClinet()
+### Using Webhooks (Optional) + +For asynchronous response delivery, you can provide a `webhook_url` when initializing the client. The API will send the response to your webhook endpoint instead of using the polling mechanism. + +```python +from magicalapi.client import AsyncClient + +# Your webhook must be whitelisted in MagicalAPI panel +webhook_url = "https://your-domain.com/webhook" +client = AsyncClient(webhook_url=webhook_url) +``` + +**Important:** Webhook domains must be registered in the [MagicalAPI panel](https://panel.magicalapi.com/). For setup guide, see the [webhook documentation](https://docs.magicalapi.com/docs/webhook) and [example](https://github.com/magicalapi/magicalapi-python/blob/master/examples/webhook_example.py). + +
+ Here is an example of how to parse a resume using [Resume Parser](https://magicalapi.com/resume/) service: ```python diff --git a/examples/webhook_example.py b/examples/webhook_example.py new file mode 100644 index 0000000..6c590b7 --- /dev/null +++ b/examples/webhook_example.py @@ -0,0 +1,45 @@ +""" +Example: Using webhook_url for asynchronous response delivery + +IMPORTANT: Before using webhooks, you must register your webhook domain +in the whitelist via the MagicalAPI panel: https://panel.magicalapi.com/ + +For complete setup guide: https://docs.magicalapi.com/docs/webhook +""" + +import asyncio + +from magicalapi.client import AsyncClient +from magicalapi.types.base import ErrorResponse +from magicalapi.types.schemas import WebhookCreatedResponse + + +async def main(): + """Example of using webhook URL with resume parser service.""" + + # Your webhook endpoint (must be whitelisted in MagicalAPI panel) + webhook_url = "https://your-domain.com/webhook/magical-api" + + async with AsyncClient(webhook_url=webhook_url) as client: + # Make a request - the full response will be sent to your webhook_url + response = await client.resume_parser.get_resume_parser( + url="https://pub-4aa6fc29899047be8d4a342594b2c033.r2.dev/00016-poduct-manager-resume-example.pdf" + ) + + # With webhook_url, you get an immediate acknowledgment + if isinstance(response, WebhookCreatedResponse): + print(f"✅ Request accepted! Status: {response.data.status}") + print(f"The full response will be sent to: {webhook_url}") + print(f"Credits: {response.usage.credits}") + + # Handle errors (e.g., domain not whitelisted) + elif isinstance(response, ErrorResponse): + if response.status_code == 403: + print(f"❌ Error: {response.message}") + print("Register your domain at: https://panel.magicalapi.com/") + else: + print(f"Error {response.status_code}: {response.message}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/magicalapi/client.py b/magicalapi/client.py index 51e9bfc..f91fac1 100644 --- a/magicalapi/client.py +++ b/magicalapi/client.py @@ -20,15 +20,26 @@ class AsyncClient(AbstractAsyncContextManager): # type: ignore The MagicalAPI client module to work and connect to the api. """ - def __init__(self, api_key: str | None = None) -> None: - """initializing MagicalAPI client. - - - api_key(``str``): - your Magical API account's `api_key` that you can get it from https://panel.magicalapi.com/ - - if passed empty, `api_key` will read from the .env file. - + def __init__( + self, api_key: str | None = None, webhook_url: str | None = None + ) -> None: + """Initialize the MagicalAPI async client. + + Args: + api_key (str | None): Your Magical API account's API key from https://panel.magicalapi.com/ + If None, will be read from .env file or MAG_API_KEY environment variable. + + webhook_url (str | None): Optional webhook URL to receive asynchronous responses. + When provided, API responses will be sent to this URL instead of using + the polling mechanism. If None, will be read from MAG_WEBHOOK_URL environment + variable. The webhook listener must be implemented by the user. + + **IMPORTANT**: Your webhook domain must be registered in the whitelist via + the MagicalAPI panel. Unregistered domains will receive a 403 error. + See https://docs.magicalapi.com/docs/webhook for complete setup guide. + + Raises: + TypeError: If api_key is not a string or None. """ # get api_key from .env or from arguments @@ -42,6 +53,10 @@ def __init__(self, api_key: str | None = None) -> None: f'api_key field type must be string, not a "{api_key.__class__.__name__}"' ) + # get webhook_url from settings if not provided + if webhook_url is None: + webhook_url = settings.webhook_url + self._api_key = api_key _request_headers = { "api-key": self._api_key, @@ -55,11 +70,21 @@ def __init__(self, api_key: str | None = None) -> None: logger.debug("httpx client created") # create service - self.profile_data = ProfileDataService(httpx_client=self._httpx_client) - self.company_data = CompanyDataService(httpx_client=self._httpx_client) - self.resume_parser = ResumeParserService(httpx_client=self._httpx_client) - self.resume_score = ResumeScoreService(httpx_client=self._httpx_client) - self.resume_review = ResumeReviewService(httpx_client=self._httpx_client) + self.profile_data = ProfileDataService( + httpx_client=self._httpx_client, webhook_url=webhook_url + ) + self.company_data = CompanyDataService( + httpx_client=self._httpx_client, webhook_url=webhook_url + ) + self.resume_parser = ResumeParserService( + httpx_client=self._httpx_client, webhook_url=webhook_url + ) + self.resume_score = ResumeScoreService( + httpx_client=self._httpx_client, webhook_url=webhook_url + ) + self.resume_review = ResumeReviewService( + httpx_client=self._httpx_client, webhook_url=webhook_url + ) logger.debug(f"async client created : {self}") diff --git a/magicalapi/services/base_service.py b/magicalapi/services/base_service.py index 3f687fc..75a2324 100644 --- a/magicalapi/services/base_service.py +++ b/magicalapi/services/base_service.py @@ -1,7 +1,8 @@ """ -base service implementations, -sending requests and validating the responses. +Base service implementations for sending requests and validating responses. +This module provides the base service class that handles HTTP communication +with the MagicalAPI server, including webhook support and retry logic. """ import asyncio @@ -15,26 +16,67 @@ from magicalapi.errors import APIServerError, APIServerTimedout from magicalapi.settings import settings from magicalapi.types.base import ErrorResponse -from magicalapi.types.schemas import HttpResponse, PendingResponse +from magicalapi.types.schemas import ( + HttpResponse, + PendingResponse, + WebhookCreatedResponse, +) from magicalapi.utils.logger import get_logger logger = get_logger("base_service") class BaseService(BaseServiceAbc): - def __init__(self, httpx_client: httpx.AsyncClient) -> None: + def __init__( + self, httpx_client: httpx.AsyncClient, webhook_url: str | None = None + ) -> None: + """Initialize the base service. + + Args: + httpx_client (httpx.AsyncClient): The HTTP client for making requests. + webhook_url (str | None): Optional webhook URL for asynchronous responses. + When provided, responses will be sent to this URL instead of being + returned synchronously via the polling mechanism. + + Note: Webhook domain must be whitelisted in MagicalAPI panel. + See https://docs.magicalapi.com/docs/webhook + """ self._httpx_client = httpx_client + self._webhook_url = webhook_url async def _send_post_request( self, path: str, data: dict[str, Any], headers: dict[str, str] = {} ) -> HttpResponse: - """ - send a post request to the API server with given `path` and `data` + """Send a POST request to the API server. + + Args: + path (str): The API endpoint path. + data (dict[str, Any]): The request payload. + headers (dict[str, str]): Optional request headers. + + Returns: + HttpResponse: The response from the API server. + + Raises: + APIServerTimedout: If the request exceeds the timeout limit. + + Note: + - If webhook_url is configured, it will be added to the request payload + and the method returns immediately without polling for completion. + - If webhook_url is not configured, the method will poll for completion + using the 201 status code retry mechanism with request_id. + - Webhook domains must be whitelisted via MagicalAPI panel to avoid 403 errors. + See https://docs.magicalapi.com/docs/webhook for setup instructions. """ try: logger.debug( f"sending POST request : {self._httpx_client.base_url.join(path)}" ) + # Add webhook_url to request if configured (for async webhook delivery) + if self._webhook_url is not None: + logger.debug(f"Adding webhook_url to request: {self._webhook_url}") + data["webhook_url"] = self._webhook_url + httpx_response = await self._httpx_client.post( headers=headers, url=path, @@ -43,8 +85,17 @@ async def _send_post_request( logger.info( f"{self._httpx_client.base_url}{path} got status code {httpx_response.status_code}" ) - # check 201 response + + # Skip polling retry loop when using webhook (response will be sent to webhook_url) + if self._webhook_url is not None: + logger.debug("Webhook mode: returning immediately without polling") + return HttpResponse( + text=httpx_response.text, + status_code=httpx_response.status_code, + ) + _credits = 0 + # retry to get the full response while httpx_response.status_code == 201: # send request with request_id logger.debug("hadnling response with status code 201.") @@ -54,7 +105,7 @@ async def _send_post_request( data["request_id"] = pend_response.data.request_id logger.info( - f"send request again with requst_id : \"{data['request_id']}\"" + f'send request again with requst_id : "{data["request_id"]}"' ) # send request again httpx_response = await self._httpx_client.post( @@ -107,27 +158,43 @@ async def _send_get_request( def validate_response( self, response: HttpResponse, validate_model: type[BaseModel] ): + """Validate and parse the API response. + + Args: + response (HttpResponse): The HTTP response to validate. + validate_model (type[BaseModel]): The Pydantic model to validate against. + + Returns: + BaseModel | ErrorResponse | WebhookCreatedResponse: The validated response model. + - Returns validate_model instance for successful 200 responses + - Returns WebhookCreatedResponse for 201 responses when using webhooks + - Returns ErrorResponse for error status codes + + Raises: + APIServerError: If response parsing or validation fails. """ - this method validate the response from API and returns the correct model basesd on response type. - """ - # check response successed - logger.debug("validating response.") - logger.debug(f"response : {response}") + logger.debug("Validating response") + logger.debug(f"Response: {response}") + + # Validate webhook acknowledgment response (201 with webhook_url) + if response.status_code == 201 and self._webhook_url is not None: + logger.debug("Validating webhook created response") + return WebhookCreatedResponse.model_validate_json(response.text) + + # Validate successful response (200) if response.status_code == 200: try: - # valdiate model + # Validate and parse response with the expected model return validate_model.model_validate_json(response.text) except ValidationError: logger.exception( - f"parsing response JSON data for model {validate_model.__name__} failed!" + f"Failed to parse response JSON for model {validate_model.__name__}" ) - # raise exception - raise APIServerError("parsing response JSON data failed!") + raise APIServerError("Failed to parse response JSON data") - # handle user error response + # Handle API error responses (4xx, 5xx) try: - # error response - logger.debug("response returned an error response.") + logger.debug("Parsing error response") _response_data = json.loads(response.text) return ErrorResponse(status_code=response.status_code, **_response_data) diff --git a/magicalapi/settings.py b/magicalapi/settings.py index 157aca6..be183ed 100644 --- a/magicalapi/settings.py +++ b/magicalapi/settings.py @@ -19,6 +19,7 @@ class Settings(BaseSettings): """ api_key: str | None = None + webhook_url: str | None = None base_url: HttpUrl = "https://gw.magicalapi.com" retry_201_delay: int = 2 # seconds request_timeout: int = 15 # seconds diff --git a/magicalapi/types/schemas.py b/magicalapi/types/schemas.py index 2702654..50beabb 100644 --- a/magicalapi/types/schemas.py +++ b/magicalapi/types/schemas.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Literal from pydantic import BaseModel @@ -21,3 +22,13 @@ class RequestID(BaseModel): class PendingResponse(BaseModel): data: RequestID usage: Usage + + +class DataStatusResponse(BaseModel): + status: Literal["created", "pending", "completed"] + message: str + + +class WebhookCreatedResponse(BaseModel): + data: DataStatusResponse + usage: Usage diff --git a/pyproject.toml b/pyproject.toml index 0848c17..1baee20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "magicalapi" -version = "1.4.0" +version = "1.5.0" description = "This is a Python client that provides easy access to the MagicalAPI.com services, fully type annotated, and asynchronous." authors = [ { name = "MagicalAPI", email = "info@magicalapi.com" } diff --git a/smoke/test_smoke.py b/smoke/test_smoke.py index ef29c43..a1b8ef0 100644 --- a/smoke/test_smoke.py +++ b/smoke/test_smoke.py @@ -12,11 +12,13 @@ import pytest_asyncio from magicalapi.client import AsyncClient +from magicalapi.types.base import ErrorResponse from magicalapi.types.company_data import CompanyDataResponse from magicalapi.types.profile_data import ProfileDataResponse from magicalapi.types.resume_parser import ResumeParserResponse from magicalapi.types.resume_review import ResumeReviewResponse from magicalapi.types.resume_score import ResumeScoreResponse +from magicalapi.types.schemas import WebhookCreatedResponse @pytest_asyncio.fixture(scope="function") diff --git a/tests/services/test_webhook_url.py b/tests/services/test_webhook_url.py new file mode 100644 index 0000000..9b0eb6a --- /dev/null +++ b/tests/services/test_webhook_url.py @@ -0,0 +1,155 @@ +""" +Unit tests for webhook_url functionality. +""" + +import json +from collections.abc import AsyncGenerator + +import httpx +import pytest +import pytest_asyncio + +from magicalapi.client import AsyncClient +from magicalapi.services.base_service import BaseService +from magicalapi.types.schemas import HttpResponse, WebhookCreatedResponse + + +@pytest_asyncio.fixture(scope="function") +async def httpxclient() -> AsyncGenerator[httpx.AsyncClient]: + """Fixture to create an httpx client for testing.""" + client = httpx.AsyncClient( + headers={"content-type": "application/json"}, + ) + + yield client + + await client.aclose() + del client + + +@pytest.mark.asyncio +async def test_base_service_with_webhook_url(httpxclient: httpx.AsyncClient): + """Test that webhook_url is properly added to request body.""" + # Set a reasonable timeout for the client + httpxclient._timeout = httpx.Timeout(30.0) + + webhook_url = "https://example.com/webhook" + base_service = BaseService(httpxclient, webhook_url=webhook_url) + test_data = {"foo": "bar"} + + response = await base_service._send_post_request( + path="https://httpbin.org/post", data=test_data + ) + + # Verify response + assert isinstance(response, HttpResponse) + assert response.status_code == 200 + + # Verify webhook_url was added to the request + response_json = json.loads(response.text) + assert response_json["json"]["webhook_url"] == webhook_url + assert response_json["json"]["foo"] == "bar" + + +@pytest.mark.asyncio +async def test_base_service_without_webhook_url(httpxclient: httpx.AsyncClient): + """Test that request works without webhook_url.""" + base_service = BaseService(httpxclient, webhook_url=None) + test_data = {"foo": "bar"} + + response = await base_service._send_post_request( + path="https://httpbin.org/post", data=test_data + ) + + # Verify response + assert isinstance(response, HttpResponse) + assert response.status_code == 200 + + # Verify webhook_url was NOT added to the request + response_json = json.loads(response.text) + assert "webhook_url" not in response_json["json"] + assert response_json["json"]["foo"] == "bar" + + +def test_validate_webhook_created_response(httpxclient: httpx.AsyncClient): + """Test validation of webhook created response (201 status).""" + webhook_url = "https://example.com/webhook" + base_service = BaseService(httpxclient, webhook_url=webhook_url) + + # Mock a 201 webhook created response + fake_webhook_response = { + "data": { + "status": "created", + "message": "Request accepted, result will be sent to webhook", + }, + "usage": {"credits": 5}, + } + + fake_response = HttpResponse( + text=json.dumps(fake_webhook_response), status_code=201 + ) + + # Validate response + result = base_service.validate_response( + response=fake_response, validate_model=WebhookCreatedResponse + ) + + assert isinstance(result, WebhookCreatedResponse) + assert result.data.status == "created" + assert result.data.message == "Request accepted, result will be sent to webhook" + assert result.usage.credits == 5 + + +def test_validate_webhook_not_triggered_without_webhook_url( + httpxclient: httpx.AsyncClient, +): + """Test that 201 response without webhook_url raises APIServerError.""" + # No webhook_url provided + base_service = BaseService(httpxclient, webhook_url=None) + + # Mock a 201 pending response (normal polling behavior) + # This represents a response that would trigger retry in _send_post_request + fake_pending_response = { + "data": {"request_id": "req_12345"}, + "usage": {"credits": 0}, + } + + fake_response = HttpResponse( + text=json.dumps(fake_pending_response), status_code=201 + ) + + # When validate_response receives a 201 without webhook_url, + # it should raise APIServerError since 201 is not a valid final status + # (it's meant to be handled by retry logic in _send_post_request) + from magicalapi.errors import APIServerError + + with pytest.raises(APIServerError): + base_service.validate_response( + response=fake_response, validate_model=WebhookCreatedResponse + ) + + +def test_client_initialization_with_webhook_url(): + """Test that AsyncClient accepts webhook_url parameter.""" + # Create client with webhook_url + webhook_url = "https://example.com/webhook" + client = AsyncClient(api_key="test_key", webhook_url=webhook_url) + + # Verify webhook_url is passed to services + assert client.profile_data._webhook_url == webhook_url + assert client.company_data._webhook_url == webhook_url + assert client.resume_parser._webhook_url == webhook_url + assert client.resume_score._webhook_url == webhook_url + assert client.resume_review._webhook_url == webhook_url + + +def test_client_initialization_without_webhook_url(): + """Test that AsyncClient works without webhook_url.""" + client = AsyncClient(api_key="test_key") + + # Verify webhook_url defaults to None + assert client.profile_data._webhook_url is None + assert client.company_data._webhook_url is None + assert client.resume_parser._webhook_url is None + assert client.resume_score._webhook_url is None + assert client.resume_review._webhook_url is None