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.
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
pip install -e .
# Infisical support needs no extra package (since 3.1.0 it uses Infisical's
# REST API through httpx); the empty `[infisical]` extra still installs for
# existing pins.
Add the app and run its migration:
INSTALLED_APPS = [
# ...
"django_api_utility",
]
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.
Version 3.1.0 removes the Infisical SDK dependency: InfisicalSecretProvider now calls Infisical's REST API directly with httpx (same settings, environment variables and reference formats), keeps one logged-in provider per process with token renewal and a short value cache. infisicalsdk and the AWS packages it pulled in (boto3/botocore) are no longer installed; the [infisical] extra remains as an empty alias.
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"}.
An Infisical provider ships with the package - django_api_utility.domain.secrets_infisical.InfisicalSecretProvider. It calls Infisical's REST API directly with httpx (Universal Auth login, then GET /api/v3/secrets/raw/{name}) - no Infisical SDK and none of the AWS packages it pulls in. One provider per process: it logs in once, renews the token before it expires or after a 401, and reuses a fetched value for INFISICAL_CACHE_SECONDS (default 60). It is selected automatically when INFISICAL_CLIENT_ID and INFISICAL_CLIENT_SECRET are both in the environment, or explicitly:
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.
# Optional: INFISICAL_CACHE_SECONDS (60), INFISICAL_TIMEOUT_SECONDS (10),
# INFISICAL_VERIFY (True / False / path to a CA bundle for a private CA).
For anything else, implement an object with get(reference) -> str and configure its dotted path the same way:
API_UTILITY_SECRET_PROVIDER = "my_project.secrets.CustomProvider"
The package does not store retrieved secret values in its models or logs, and its error messages name secrets and paths but never include their values.
Template variables
The request fields use a restricted placeholder resolver. It performs value substitution only and never evaluates Python or general template expressions.
${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:
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:
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:
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:
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:
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.
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:
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
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:
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
0009and0010after taking the normal database backup. Migration0010aligns 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.
PyJWTis no longer required; JWT assertions usejoserfc.
Metadata
Release files for django-api-utility 3.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_api_utility-3.1.0.tar.gz | 50.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_api_utility-3.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 113.2 kB
Release files / django_api_utility-3.1.0.tar.gz
| Download URL | django_api_utility-3.1.0.tar.gz |
|---|---|
| Size | 50.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1fc3a920afe3cf2e28c13522a86e9c32376ad02be82fd03df21abc5e84eb2179
|
|
BLAKE2b-256 checksum How to use checksums |
0ad25429929462ae27fa8d9e145e963cb89023c636f70a87025b343c1d8768c3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.13
|
Release files / django_api_utility-3.1.0-py3-none-any.whl
| Download URL | django_api_utility-3.1.0-py3-none-any.whl |
|---|---|
| Size | 62.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
30157fb94fdeceaa49e39a6ce9af0833a8bdb358d09590974a579cab0d178abf
|
|
BLAKE2b-256 checksum How to use checksums |
83dd8a4d5b283dead8705822b8896d52c326cdb5db9ecf971bbca9c0a08afc77
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.13
|