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
1 change: 1 addition & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ MPT_API_TOKEN=<api-token>
MPT_API_TOKEN_CLIENT=<client-api-token>
MPT_API_TOKEN_OPERATIONS=<operations-api-token>
MPT_API_TOKEN_VENDOR=<vendor-api-token>
MPT_API_TOKEN_EXTENSION=<extension-api-key>
RP_API_KEY=<rp-api-token>
RP_ENDPOINT=https://reportportal.example.com
RP_LAUNCH=dev-env
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Start with these documents:
## Quick Start

```bash
cp .env.sample .env # configure MPT_API_BASE_URL and MPT_API_TOKEN
cp .env.sample .env # configure MPT_API_BASE_URL
make build
make test
```
Expand Down
9 changes: 5 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ Each client holds an HTTP client instance and exposes domain-specific resource g
properties:

```python
client = MPTClient.from_config(api_token="...", base_url="...")
client = MPTClient.from_config(authentication=BearerTokenAuthentication("..."), base_url="...")
client.catalog # Catalog
client.commerce # Commerce
client.billing # Billing
Expand Down Expand Up @@ -155,14 +155,15 @@ class ProductsService(

`HTTPClient` and `AsyncHTTPClient` wrap `httpx.Client` / `httpx.AsyncClient` with:

- automatic Bearer token authentication
- pluggable authentication via an `Authentication` provider (`BearerTokenAuthentication`,
`ExtensionFrameworkAuthentication`)
- base URL resolution
- retry transport (configurable)
- error transformation into `MPTHttpError` / `MPTAPIError`
- multipart file upload support

Configuration is read from constructor arguments or environment variables
(`MPT_API_TOKEN`, `MPT_API_BASE_URL`).
The base URL is read from a constructor argument or the `MPT_API_BASE_URL` environment
variable; the authentication provider is always passed explicitly.

## Cross-Cutting Concerns

Expand Down
13 changes: 7 additions & 6 deletions docs/e2e_tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@ E2E suites use `pytest` markers and live API credentials, so they run outside th

## Environment Variables

| Variable | Description |
|----------------------------|----------------------|
| `MPT_API_BASE_URL` | MPT API base URL |
| `MPT_API_TOKEN_VENDOR` | Vendor API token |
| `MPT_API_TOKEN_CLIENT` | Client API token |
| `MPT_API_TOKEN_OPERATIONS` | Operations API token |
| Variable | Description |
|----------------------------|----------------------------------------------------|
| `MPT_API_BASE_URL` | MPT API base URL |
| `MPT_API_TOKEN_VENDOR` | Vendor API token |
| `MPT_API_TOKEN_CLIENT` | Client API token |
| `MPT_API_TOKEN_OPERATIONS` | Operations API token |
| `MPT_API_TOKEN_EXTENSION` | Extension API key (installation token / extension framework auth) |

### Optional ReportPortal Integration

Expand Down
7 changes: 5 additions & 2 deletions docs/rql.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ appends filters immutably, allowing expression composition without shared mutati
You can build complex predicates by joining queries with `&` (AND), `|` (OR), and `~` (NOT):

```python
from mpt_api_client import MPTClient, RQLQuery
from mpt_api_client import MPTClient, BearerTokenAuthentication, RQLQuery

client = MPTClient()
client = MPTClient.from_config(
authentication=BearerTokenAuthentication("<token>"),
base_url="https://api.s1.show/public",
)
products = client.catalog.products

target_ids = RQLQuery("id").in_([
Expand Down
74 changes: 57 additions & 17 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,39 +20,67 @@ uv add mpt-api-client

## Configuration

The client reads configuration from constructor arguments or the environment.
The client requires a base URL and an authentication provider.

Environment variables:

| Variable | Required | Description |
|--------------------|----------|------------------------------------|
| `MPT_API_BASE_URL` | yes | SoftwareONE Marketplace API URL |
| `MPT_API_TOKEN` | yes | SoftwareONE Marketplace API token |

The base URL can be read from the environment; the authentication provider is always passed
explicitly.

Example `.env` snippet:

```env
MPT_API_BASE_URL=<YOUR_MPT_API_BASE_URL>
MPT_API_TOKEN=<YOUR_API_TOKEN>
```

## Authentication

Authentication is provided through an `Authentication` provider passed to the client. Two
implementations are available:

- `BearerTokenAuthentication` β€” a single, long-lived token.
- `ExtensionFrameworkAuthentication` β€” a short-lived installation or account-scoped token
fetched from an extension secret via `POST /installations/-/token`. It refreshes
proactively once the token nears its JWT `exp` (default leeway 60s) and reactively on
`401`. Pass `account_id` to request a token scoped to a specific account
(`?account.id=<id>`); use one provider instance per account scope.

## Instantiate The Client

You can rely on environment variables:
With a long-lived bearer token:

```python
from mpt_api_client import MPTClient
from mpt_api_client import MPTClient, BearerTokenAuthentication

client = MPTClient()
client = MPTClient.from_config(
authentication=BearerTokenAuthentication("<token>"),
base_url="https://api.s1.show/public",
)
```

Or pass configuration explicitly:
With the extension framework (short-lived installation tokens):

```python
from mpt_api_client import MPTClient
from mpt_api_client import MPTClient, ExtensionFrameworkAuthentication

client = MPTClient.from_config(
api_token="token",
authentication=ExtensionFrameworkAuthentication(secret="<extension-secret>"),
base_url="https://api.s1.show/public",
)
```

For an account-scoped token, pass `account_id`:

```python
client = MPTClient.from_config(
authentication=ExtensionFrameworkAuthentication(
secret="<extension-secret>",
account_id="<account-id>",
),
base_url="https://api.s1.show/public",
)
```
Expand All @@ -64,9 +92,12 @@ client = MPTClient.from_config(
Read a single resource:

```python
from mpt_api_client import MPTClient
from mpt_api_client import MPTClient, BearerTokenAuthentication

client = MPTClient()
client = MPTClient.from_config(
authentication=BearerTokenAuthentication("<token>"),
base_url="https://api.s1.show/public",
)

product = client.catalog.products.get("PRD-123-456")
print(product.name)
Expand All @@ -75,9 +106,12 @@ print(product.name)
Iterate through a collection:

```python
from mpt_api_client import MPTClient
from mpt_api_client import MPTClient, BearerTokenAuthentication

client = MPTClient()
client = MPTClient.from_config(
authentication=BearerTokenAuthentication("<token>"),
base_url="https://api.s1.show/public",
)

for invoice in client.billing.invoices.iterate():
print(invoice.id)
Expand All @@ -88,11 +122,14 @@ for invoice in client.billing.invoices.iterate():
```python
import asyncio

from mpt_api_client import AsyncMPTClient
from mpt_api_client import AsyncMPTClient, BearerTokenAuthentication


async def main():
client = AsyncMPTClient()
client = AsyncMPTClient.from_config(
authentication=BearerTokenAuthentication("<token>"),
base_url="https://api.s1.show/public",
)

product = await client.catalog.products.get("PRD-123-456")
print(product.name)
Expand Down Expand Up @@ -134,9 +171,12 @@ the source of truth for query composition.
Typical example:

```python
from mpt_api_client import MPTClient, RQLQuery
from mpt_api_client import MPTClient, BearerTokenAuthentication, RQLQuery

client = MPTClient()
client = MPTClient.from_config(
authentication=BearerTokenAuthentication("<token>"),
base_url="https://api.s1.show/public",
)

target_ids = RQLQuery("id").in_(["PRD-123-456", "PRD-789-012"])
active = RQLQuery(status="active")
Expand Down
14 changes: 13 additions & 1 deletion mpt_api_client/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
from mpt_api_client.auth import (
Authentication,
BearerTokenAuthentication,
ExtensionFrameworkAuthentication,
)
from mpt_api_client.mpt_client import AsyncMPTClient, MPTClient
from mpt_api_client.rql import RQLQuery

__all__ = ["AsyncMPTClient", "MPTClient", "RQLQuery"] # noqa: WPS410
__all__ = [ # noqa: WPS410
"AsyncMPTClient",
"Authentication",
"BearerTokenAuthentication",
"ExtensionFrameworkAuthentication",
"MPTClient",
"RQLQuery",
]
8 changes: 8 additions & 0 deletions mpt_api_client/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from mpt_api_client.auth.base import Authentication, BearerTokenAuthentication
from mpt_api_client.auth.extension_framework import ExtensionFrameworkAuthentication

__all__ = [ # noqa: WPS410
"Authentication",
"BearerTokenAuthentication",
"ExtensionFrameworkAuthentication",
]
40 changes: 40 additions & 0 deletions mpt_api_client/auth/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Generic authentication providers for the MPT API client.

Providers are :class:`httpx.Auth` subclasses, so the same implementation is used by both
the sync and the async HTTP clients.
"""

from collections.abc import Generator
from typing import override

import httpx


class Authentication(httpx.Auth):
"""Base class for MPT API authentication providers."""

def configure(self, *, base_url: str, timeout: float, retries: int) -> None:
"""Receive the owning HTTP client's configuration.

Called once by ``HTTPClient``/``AsyncHTTPClient`` at construction time. The base
implementation is a no-op; providers that need the client's configuration (such as
``ExtensionFrameworkAuthentication``) override it.

Args:
base_url: Resolved base URL of the owning client.
timeout: HTTP request timeout in seconds.
retries: Number of retries configured on the owning client.
"""


class BearerTokenAuthentication(Authentication):
"""Authenticate every request with a single long-lived bearer token."""

def __init__(self, token: str) -> None:
self._token = token

@override
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
"""Attach the bearer token to the outgoing request."""
request.headers["Authorization"] = f"Bearer {self._token}"
yield request
Loading