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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,22 @@ client = AsyncClinet()

<br>

### 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).

<br>

Here is an example of how to parse a resume using [Resume Parser](https://magicalapi.com/resume/) service:

```python
Expand Down
45 changes: 45 additions & 0 deletions examples/webhook_example.py
Original file line number Diff line number Diff line change
@@ -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())
53 changes: 39 additions & 14 deletions magicalapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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}")

Expand Down
107 changes: 87 additions & 20 deletions magicalapi/services/base_service.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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.")
Expand All @@ -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(
Expand Down Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions magicalapi/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions magicalapi/types/schemas.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from dataclasses import dataclass
from typing import Literal

from pydantic import BaseModel

Expand All @@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
2 changes: 2 additions & 0 deletions smoke/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading