Metadata-Version: 2.4
Name: django-api-utility
Version: 3.0.0
Summary: Reusable API integration utility for Django services
Author: Nexgensis
License: MIT
Project-URL: Repository, https://github.com/Nexgensis/api-utility
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Django>=5.2
Requires-Dist: httpx<1.0,>=0.28
Requires-Dist: joserfc<2.0,>=1.6
Requires-Dist: cryptography>=43.0
Requires-Dist: jsonschema>=4.23
Requires-Dist: tenacity>=8.2
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == "test"
Requires-Dist: pytest-django>=4.5; extra == "test"
Requires-Dist: infisicalsdk<2.0,>=1.0; extra == "test"
Provides-Extra: infisical
Requires-Dist: infisicalsdk<2.0,>=1.0; extra == "infisical"
Dynamic: license-file

# django-api-utility

A reusable Django package for database-configured outbound API integrations. Services and endpoints stay in Django, secret references point to Infisical-injected values, and the request pipeline handles authentication, pooling, retries, validation, caching, refresh, and safe logs.

## Supported authentication

| Type | Credential mode | Typical use |
|---|---|---|
| `NONE` | Static | Public/internal endpoint without authentication |
| `API_KEY` | Static | Key sent in a header or query parameter |
| `BASIC` | Static | Username and password through HTTP Basic |
| `STATIC_BEARER` | Static | Long-lived bearer token |
| `OAUTH2_CLIENT_CREDENTIALS` | Dynamic | OAuth/OIDC machine client, including `client_secret_basic`, `client_secret_post`, and public clients |
| `JWT_BEARER_GRANT` | Dynamic | Signed JWT assertion, including Zitadel service users |
| `CUSTOM_TOKEN` | Dynamic | Provider-specific JSON, form, or query token endpoint |
| `SESSION_COOKIE` | Dynamic | Login endpoint that returns one or more cookies |

Token requests and normal API requests use a pooled HTTPX client. JWT signing uses the focused `joserfc` package. Full Authlib is not required because token requests are database-templated and its HTTPX client integration is deprecated.

File upload support is intentionally outside the current scope.

### Mutual TLS

mTLS is a separate, process-wide transport setting - not a per-profile `AuthType` - because it's a client identity the whole service presents, not something that varies per integration. Enable it with `MTLS_ENABLED=True` plus `MTLS_CERT`/`MTLS_KEY`/`MTLS_CA` (file paths); it combines with any `AuthenticationProfile` type above, or `NONE` if the mTLS handshake is the only authentication a given destination needs.

```python
MTLS_ENABLED = True
MTLS_CERT = "/run/secrets/client.crt"
MTLS_KEY = "/run/secrets/client.key"
MTLS_CA = "/run/secrets/ca.crt"
```

Verified end-to-end against a real mTLS-requiring server (see `integration/docker/`'s `mtls_resource` service) - earlier builds of `_build_client()` passed `cert=` and `verify=` to `httpx.HTTPTransport` as two separate arguments, which httpx silently drops the client certificate for; it now builds one `ssl.SSLContext` with both the CA and client cert chain loaded together and passes that as `verify`.

## Install

```bash
pip install -e .
# Optional only when your custom secret-provider adapter imports infisical-sdk:
pip install -e '.[infisical]'
```

Add the app and run its migration:

```python
INSTALLED_APPS = [
    # ...
    "django_api_utility",
]
```

```bash
python manage.py migrate django_api_utility
```

Migration `0009_authentication_profiles` adds `AuthenticationProfile` and optional profile foreign keys to services and endpoints. `IntegrationServiceTokenConfig` is deprecated as of this migration - nothing in `domain`/`orchestration`/`transport` reads or writes it anymore (confirmed: it's reachable only from `admin.py`). It's kept, unused, purely so an already-deployed consumer's existing rows stay migration-compatible and visible in admin; there's no compatibility proxy routing new traffic through it. New integrations should use `AuthenticationProfile` directly.

Version `3.0.0` is the next major release after the published `2.0.0`. It changes the response implementation from Requests to HTTPX and adds the authentication-profile architecture, so consumers should treat it as a breaking upgrade.

## Data ownership

Store non-secret integration configuration in `AuthenticationProfile`:

- token URL and HTTP method
- request encoding, headers, query, and body templates
- response extraction rules
- target API credential placement
- JWT claims and algorithm settings
- timeout, scope, and other provider configuration
- logical secret references

Store values such as API keys, passwords, client secrets, private keys, and static bearer tokens in Infisical. The default provider reads secrets from environment variables, which fits Infisical CLI or Agent injection. A reference can be `MY_SECRET`, `env:MY_SECRET`, or `{"provider": "env", "name": "MY_SECRET"}`.

A direct-SDK Infisical provider ships with the package - `django_api_utility.domain.secrets_infisical.InfisicalSecretProvider` (Universal Auth machine identity, `infisicalsdk`'s `get_secret_by_name`; verified end-to-end against a real self-hosted instance - see `integration/docker/INFISICAL_VERIFICATION.md`):

```python
API_UTILITY_SECRET_PROVIDER = "django_api_utility.domain.secrets_infisical.InfisicalSecretProvider"
INFISICAL_PROJECT_ID = "..."
INFISICAL_ENVIRONMENT_SLUG = "prod"  # default "dev"
# INFISICAL_HOST defaults to https://app.infisical.com; set it for self-hosted.
# INFISICAL_CLIENT_ID / INFISICAL_CLIENT_SECRET (machine identity) come from
# the environment, same as any other deployment secret - never from settings.py.
```

For anything else, implement an object with `get(reference) -> str` and configure its dotted path the same way:

```python
API_UTILITY_SECRET_PROVIDER = "my_project.secrets.CustomProvider"
```

Install the optional `infisical` dependency (`pip install django-api-utility[infisical]`, the real PyPI distribution is `infisicalsdk`) to use the shipped adapter. The package does not store retrieved secret values in its models or logs.

## Template variables

The request fields use a restricted placeholder resolver. It performs value substitution only and never evaluates Python or general template expressions.

```text
${secret.*}       -> secret provider using secret_references
${config.*}       -> AuthenticationProfile.configuration
${generated.*}    -> generated JWT assertion and similar internal values
${runtime.*}      -> values passed by the caller for this request
```

An exact placeholder keeps its native JSON type. A placeholder embedded in a longer string is converted to text.

## Configuration examples

A static API key in a header:

```python
AuthenticationProfile.objects.create(
    name="vendor-api-key",
    auth_type="API_KEY",
    credential_mode="STATIC",
    secret_references={"api_key": "VENDOR_API_KEY"},
    target_auth_config={
        "location": "header",
        "name": "X-API-Key",
        "format": "{token}",
    },
)
```

OAuth2 client credentials:

```python
AuthenticationProfile.objects.create(
    name="vendor-oauth",
    auth_type="OAUTH2_CLIENT_CREDENTIALS",
    credential_mode="DYNAMIC",
    token_url="https://identity.example.com/oauth/token",
    configuration={
        "client_id": "machine-client",
        "token_endpoint_auth_method": "client_secret_post",
        "scope": ["read", "write"],
        "token_timeout_seconds": 10,
    },
    secret_references={"client_secret": "VENDOR_CLIENT_SECRET"},
)
```

The code supplies the normal client-credentials request and response defaults: form encoding, `grant_type=client_credentials`, `access_token`, `token_type`, `expires_in`, and `Authorization: Bearer ...`. Database values override these defaults.

A Zitadel-style JWT bearer grant:

```python
AuthenticationProfile.objects.create(
    name="zitadel-service-user",
    auth_type="JWT_BEARER_GRANT",
    credential_mode="DYNAMIC",
    token_url="https://identity.example.com/oauth/v2/token",
    configuration={
        "client_id": "service-user-id",
        "scope": ["openid", "urn:zitadel:iam:org:project:id:aud"],
    },
    secret_references={"private_key": "ZITADEL_PRIVATE_KEY_PEM"},
    jwt_config={
        "issuer": "service-user-id",
        "subject": "service-user-id",
        "audience": "https://identity.example.com",
        "key_id": "key-id",
        "algorithm": "RS256",
        "lifetime_seconds": 300,
    },
)
```

A custom token response and runtime tenant:

```python
AuthenticationProfile.objects.create(
    name="tenant-session",
    auth_type="CUSTOM_TOKEN",
    credential_mode="DYNAMIC",
    token_url="https://login.example.com/${runtime.tenant}/token",
    request_encoding="JSON",
    request_body={
        "username": "${config.username}",
        "password": "${secret.password}",
    },
    configuration={"username": "integration-user"},
    secret_references={"password": "TENANT_API_PASSWORD"},
    response_config={
        "token_source": "json",
        "token_path": "data.access.token",
        "expires_in_path": "data.expires_in",
    },
    target_auth_config={
        "location": "header",
        "name": "Authorization",
        "format": "Bearer {token}",
    },
)
```

Attach a profile to `ExternalServiceConfig` for all endpoints. Set `ExternalServiceEndpoint.authentication_profile` to override it for one endpoint.

## Calling endpoints

Provide the existing definition-key JSON mapping through `EXTERNAL_DEFINITION_KEYS_FILE`, then call:

```python
from django_api_utility import get_by_key, post_by_key

response = get_by_key("users_get", params={"page": 1})
created = post_by_key(
    "users_create",
    payload={"name": "Alice"},
    auth_runtime={"tenant": "acme"},
)
```

`post_by_key`, `put_by_key`, `patch_by_key`, and `request_by_key` continue to accept `form_data`. Existing endpoint fields such as `content_type`, `payload_key_map`, and `token_flow` remain available for compatibility with 2.x registry data.

Authentication query parameters take precedence over caller query parameters so a caller cannot replace a configured credential.

## Caching and rotation

Static keys and passwords are read from the secret provider for each resolved request, so an Infisical rotation is used on the next call. Dynamic tokens and cookies are cached until their reported expiry minus `refresh_buffer_seconds`; a 401 invalidates the cached value, reads current secrets again, and retries once.

Use a shared Django cache such as Redis in multi-worker production deployments. A short cache lock limits simultaneous token fetches across workers, while an in-process lock covers threads. Runtime template values are included in the cache key to prevent credentials from being reused across tenants.

```python
API_UTILITY_AUTH_CACHE_ALIAS = "default"
API_UTILITY_TOKEN_TIMEOUT_SECONDS = 10
API_UTILITY_REQUIRE_HTTPS = True
API_UTILITY_ALLOWED_AUTH_HOSTS = ["identity.example.com", "vendor.example.com"]
API_UTILITY_JWT_ALGORITHMS = ["RS256", "ES256"]
```

Existing 2.x services without an authentication profile continue to obtain their bearer token through `API_UTILITY_MACHINE_TOKEN_DEFINITION_KEY`. Assigning a profile switches that service or endpoint to the new direct, template-driven authentication flow.

## Logging and failure behavior

The package logs request lifecycle events, upstream status, retry attempts, token refresh results, timeouts, and transport failures with module loggers under `django_api_utility`. Authorization, cookie, and API-key header values are redacted, and token response bodies are not included in errors. Inactive authentication profiles fail closed.

A typical Django logging configuration is:

```python
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {"console": {"class": "logging.StreamHandler"}},
    "loggers": {
        "django_api_utility": {
            "handlers": ["console"],
            "level": "INFO",
            "propagate": False,
        },
    },
}
```

Safe HTTP methods retry configured transient status codes with exponential jitter. Transport errors become typed utility exceptions, request and response JSON schemas remain supported, and a 401 triggers one credential refresh before the response returns.

## Tests

```bash
pip install -e '.[test]'
pytest -q
python -m django makemigrations django_api_utility --check --dry-run
```

The four-service Docker integration harness runs an authentication provider,
an independent protected resource API, a client-certificate-required mTLS
resource, and a consumer Django project:

```bash
sh integration/docker/generate_test_credentials.sh
docker compose -f integration/docker/compose.yaml up \
  --build --abort-on-container-exit --exit-code-from consumer
```

It covers every supported `AuthenticationProfile` type including JWT bearer
grant, mTLS (a real client-certificate handshake against `mtls_resource`,
not a mock), all five request methods, local-memory token caching, endpoint
overrides, runtime templates, cookies and CSRF, and forced refresh following
a 401. Zitadel itself and the Infisical secret provider aren't part of this
harness - the latter is heavy enough (a full self-hosted instance plus
bootstrap) that it's verified separately; see
`integration/docker/INFISICAL_VERIFICATION.md` for the reproducible steps.
See `integration/docker/README.md` for cleanup instructions.

## 3.0.0 upgrade notes

- The distribution and runtime response type move from Requests to HTTPX.
- Apply migrations `0009` and `0010` after taking the normal database backup. Migration `0010` aligns the database with the 2.0 model state by removing the obsolete session-tracking table and preserving the nullable endpoint content-type behavior.
- Services without a new authentication profile continue through the 2.x machine-token proxy flow.
- Assign and test authentication profiles before disabling the legacy proxy configuration.
- `PyJWT` is no longer required; JWT assertions use `joserfc`.
