django-api-utility 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_api_utility/__init__.py +68 -0
- django_api_utility/apps.py +7 -0
- django_api_utility/domain/__init__.py +15 -0
- django_api_utility/domain/definition_keys.py +53 -0
- django_api_utility/domain/endpoint_service.py +34 -0
- django_api_utility/domain/schema_service.py +58 -0
- django_api_utility/domain/token_service.py +125 -0
- django_api_utility/domain/types.py +14 -0
- django_api_utility/exceptions.py +34 -0
- django_api_utility/migrations/0001_initial.py +110 -0
- django_api_utility/migrations/__init__.py +1 -0
- django_api_utility/models.py +142 -0
- django_api_utility/orchestration/__init__.py +12 -0
- django_api_utility/orchestration/api_service.py +234 -0
- django_api_utility/tests/__init__.py +0 -0
- django_api_utility/tests/settings.py +16 -0
- django_api_utility/tests/test_api_service.py +150 -0
- django_api_utility/tests/test_definition_keys.py +39 -0
- django_api_utility/tests/test_endpoint_service.py +27 -0
- django_api_utility/tests/test_http_client.py +44 -0
- django_api_utility/transport/__init__.py +6 -0
- django_api_utility/transport/http_client.py +31 -0
- django_api_utility/transport/outbound_service.py +25 -0
- django_api_utility-0.1.0.dist-info/METADATA +72 -0
- django_api_utility-0.1.0.dist-info/RECORD +28 -0
- django_api_utility-0.1.0.dist-info/WHEEL +5 -0
- django_api_utility-0.1.0.dist-info/licenses/LICENSE +21 -0
- django_api_utility-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Reusable API integration utility for Django services."""
|
|
2
|
+
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
ApiUtilityError,
|
|
5
|
+
ConfigurationError,
|
|
6
|
+
DefinitionKeyError,
|
|
7
|
+
EndpointConfigError,
|
|
8
|
+
EndpointRequestError,
|
|
9
|
+
HttpClientError,
|
|
10
|
+
TokenConfigError,
|
|
11
|
+
TokenRequestError,
|
|
12
|
+
SchemaValidationError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def request_by_key(*args, **kwargs):
|
|
17
|
+
from .orchestration.api_service import request_by_key as _request_by_key
|
|
18
|
+
|
|
19
|
+
return _request_by_key(*args, **kwargs)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_by_key(*args, **kwargs):
|
|
23
|
+
from .orchestration.api_service import get_by_key as _get_by_key
|
|
24
|
+
|
|
25
|
+
return _get_by_key(*args, **kwargs)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def post_by_key(*args, **kwargs):
|
|
29
|
+
from .orchestration.api_service import post_by_key as _post_by_key
|
|
30
|
+
|
|
31
|
+
return _post_by_key(*args, **kwargs)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def put_by_key(*args, **kwargs):
|
|
35
|
+
from .orchestration.api_service import put_by_key as _put_by_key
|
|
36
|
+
|
|
37
|
+
return _put_by_key(*args, **kwargs)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def patch_by_key(*args, **kwargs):
|
|
41
|
+
from .orchestration.api_service import patch_by_key as _patch_by_key
|
|
42
|
+
|
|
43
|
+
return _patch_by_key(*args, **kwargs)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def delete_by_key(*args, **kwargs):
|
|
47
|
+
from .orchestration.api_service import delete_by_key as _delete_by_key
|
|
48
|
+
|
|
49
|
+
return _delete_by_key(*args, **kwargs)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"ApiUtilityError",
|
|
54
|
+
"ConfigurationError",
|
|
55
|
+
"DefinitionKeyError",
|
|
56
|
+
"EndpointConfigError",
|
|
57
|
+
"EndpointRequestError",
|
|
58
|
+
"HttpClientError",
|
|
59
|
+
"TokenConfigError",
|
|
60
|
+
"TokenRequestError",
|
|
61
|
+
"SchemaValidationError",
|
|
62
|
+
"request_by_key",
|
|
63
|
+
"get_by_key",
|
|
64
|
+
"post_by_key",
|
|
65
|
+
"put_by_key",
|
|
66
|
+
"patch_by_key",
|
|
67
|
+
"delete_by_key",
|
|
68
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Domain layer: config lookup, endpoint resolution, and token flow."""
|
|
2
|
+
|
|
3
|
+
from .definition_keys import clear_definition_key_cache, get_definition_key
|
|
4
|
+
from .endpoint_service import build_url, resolve_endpoint
|
|
5
|
+
from .token_service import get_or_refresh_service_token
|
|
6
|
+
from .types import ResolvedEndpoint
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"get_definition_key",
|
|
10
|
+
"clear_definition_key_cache",
|
|
11
|
+
"build_url",
|
|
12
|
+
"resolve_endpoint",
|
|
13
|
+
"get_or_refresh_service_token",
|
|
14
|
+
"ResolvedEndpoint",
|
|
15
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
|
|
8
|
+
from django_api_utility.exceptions import DefinitionKeyError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_DEFAULT_KEYS_FILE = Path(__file__).resolve().parents[2] / "config" / "external_definition_keys.json"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _keys_file_path() -> Path:
|
|
15
|
+
configured = (
|
|
16
|
+
getattr(settings, "EXTERNAL_DEFINITION_KEYS_FILE", "")
|
|
17
|
+
or os.getenv("EXTERNAL_DEFINITION_KEYS_FILE", "")
|
|
18
|
+
or str(_DEFAULT_KEYS_FILE)
|
|
19
|
+
)
|
|
20
|
+
return Path(configured)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@lru_cache(maxsize=1)
|
|
24
|
+
def _load_keys() -> dict:
|
|
25
|
+
keys_file = _keys_file_path()
|
|
26
|
+
if not keys_file.exists():
|
|
27
|
+
raise DefinitionKeyError(
|
|
28
|
+
f"Definition key config file not found at {keys_file}. "
|
|
29
|
+
"Set EXTERNAL_DEFINITION_KEYS_FILE to a valid JSON file."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
with keys_file.open("r", encoding="utf-8") as file_obj:
|
|
34
|
+
data = json.load(file_obj) or {}
|
|
35
|
+
except json.JSONDecodeError as exc:
|
|
36
|
+
raise DefinitionKeyError(f"Definition key file is invalid JSON at {keys_file}: {exc}") from exc
|
|
37
|
+
|
|
38
|
+
if not isinstance(data, dict):
|
|
39
|
+
raise DefinitionKeyError("external_definition_keys.json must contain a JSON object")
|
|
40
|
+
return data
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_definition_key(name: str) -> str:
|
|
44
|
+
"""Resolve runtime key alias to configured endpoint definition key."""
|
|
45
|
+
keys = _load_keys()
|
|
46
|
+
value = keys.get(name)
|
|
47
|
+
if not value:
|
|
48
|
+
raise DefinitionKeyError(f"Definition key '{name}' not found in {_keys_file_path()}")
|
|
49
|
+
return str(value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def clear_definition_key_cache() -> None:
|
|
53
|
+
_load_keys.cache_clear()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from django_api_utility.exceptions import EndpointConfigError
|
|
2
|
+
from django_api_utility.models import ExternalServiceEndpoint
|
|
3
|
+
|
|
4
|
+
from .token_service import get_or_refresh_service_token
|
|
5
|
+
from .types import ResolvedEndpoint
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_url(base_url: str, path: str) -> str:
|
|
9
|
+
"""Join base URL and endpoint path with normalized slashes."""
|
|
10
|
+
return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_endpoint(definition_key: str, force_token_refresh: bool = False) -> ResolvedEndpoint:
|
|
14
|
+
"""Resolve active endpoint config and authorization headers for execution."""
|
|
15
|
+
endpoint = (
|
|
16
|
+
ExternalServiceEndpoint.objects.select_related("service")
|
|
17
|
+
.filter(definition_key=definition_key, is_active=True, service__is_active=True)
|
|
18
|
+
.first()
|
|
19
|
+
)
|
|
20
|
+
if not endpoint:
|
|
21
|
+
raise EndpointConfigError(f"Active endpoint definition_key '{definition_key}' is not configured")
|
|
22
|
+
|
|
23
|
+
headers: dict[str, str] = {}
|
|
24
|
+
if endpoint.service.auth_required:
|
|
25
|
+
token = get_or_refresh_service_token(endpoint.service, force_refresh=force_token_refresh)
|
|
26
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
27
|
+
|
|
28
|
+
timeout = endpoint.timeout_seconds or endpoint.service.default_timeout_seconds or 10
|
|
29
|
+
return ResolvedEndpoint(
|
|
30
|
+
endpoint=endpoint,
|
|
31
|
+
url=build_url(endpoint.service.base_url, endpoint.path),
|
|
32
|
+
timeout=timeout,
|
|
33
|
+
headers=headers,
|
|
34
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
|
|
7
|
+
from django_api_utility.exceptions import SchemaValidationError
|
|
8
|
+
from django_api_utility.models import ExternalEndpointSchema
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from jsonschema import ValidationError as JsonSchemaValidationError
|
|
12
|
+
from jsonschema import validate as jsonschema_validate
|
|
13
|
+
except Exception: # pragma: no cover - import failure path
|
|
14
|
+
JsonSchemaValidationError = Exception
|
|
15
|
+
jsonschema_validate = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def schema_validation_enabled() -> bool:
|
|
19
|
+
return bool(getattr(settings, "API_UTILITY_VALIDATE_SCHEMAS", True))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_active_schema(endpoint, schema_kind: str) -> Optional[ExternalEndpointSchema]:
|
|
23
|
+
# Supports tests that pass mocked/non-model endpoints.
|
|
24
|
+
endpoint_pk = getattr(endpoint, "pk", None)
|
|
25
|
+
if not endpoint_pk:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
ExternalEndpointSchema.objects.filter(
|
|
30
|
+
endpoint=endpoint,
|
|
31
|
+
schema_kind=schema_kind,
|
|
32
|
+
is_active=True,
|
|
33
|
+
)
|
|
34
|
+
.order_by("-updated_at")
|
|
35
|
+
.first()
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_payload_schema(*, definition_key: str, endpoint, schema_kind: str, payload: Any) -> None:
|
|
40
|
+
if not schema_validation_enabled():
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
schema_row = _get_active_schema(endpoint, schema_kind)
|
|
44
|
+
if not schema_row:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
if jsonschema_validate is None:
|
|
48
|
+
raise SchemaValidationError(
|
|
49
|
+
f"Schema validation requested for '{definition_key}' but jsonschema dependency is unavailable"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
schema_json = schema_row.schema_json or {}
|
|
53
|
+
try:
|
|
54
|
+
jsonschema_validate(instance=payload, schema=schema_json)
|
|
55
|
+
except JsonSchemaValidationError as exc:
|
|
56
|
+
raise SchemaValidationError(
|
|
57
|
+
f"{schema_kind.title()} schema validation failed for '{definition_key}': {exc.message}"
|
|
58
|
+
) from exc
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.utils import timezone
|
|
5
|
+
|
|
6
|
+
from django_api_utility.exceptions import HttpClientError, TokenConfigError, TokenRequestError
|
|
7
|
+
from django_api_utility.models import ExternalServiceEndpoint, IntegrationServiceTokenConfig
|
|
8
|
+
from django_api_utility.transport.outbound_service import execute_outbound_request
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_token_config_for_service(service):
|
|
12
|
+
return (
|
|
13
|
+
IntegrationServiceTokenConfig.objects.filter(service=service, is_active=True)
|
|
14
|
+
.order_by("-updated_at")
|
|
15
|
+
.first()
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve_token_url(service) -> str:
|
|
20
|
+
configured_key = str(getattr(settings, "API_UTILITY_TOKEN_DEFINITION_KEY", "request_service_token") or "").strip()
|
|
21
|
+
if not configured_key:
|
|
22
|
+
configured_key = "request_service_token"
|
|
23
|
+
|
|
24
|
+
service_key = str(service.name or "").strip().lower().replace(" ", "_")
|
|
25
|
+
candidate_keys = [
|
|
26
|
+
configured_key,
|
|
27
|
+
"request_service_token",
|
|
28
|
+
"request_token",
|
|
29
|
+
f"{service_key}_{configured_key}",
|
|
30
|
+
f"{service_key}_request_service_token",
|
|
31
|
+
f"{service_key}_request_token",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
token_endpoint = None
|
|
35
|
+
for key in candidate_keys:
|
|
36
|
+
token_endpoint = (
|
|
37
|
+
ExternalServiceEndpoint.objects.filter(
|
|
38
|
+
service=service,
|
|
39
|
+
is_active=True,
|
|
40
|
+
method__iexact="POST",
|
|
41
|
+
definition_key__iexact=key,
|
|
42
|
+
)
|
|
43
|
+
.order_by("id")
|
|
44
|
+
.first()
|
|
45
|
+
)
|
|
46
|
+
if token_endpoint:
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
if not token_endpoint:
|
|
50
|
+
raise TokenConfigError(
|
|
51
|
+
f"Token endpoint not configured in ExternalServiceEndpoint for service '{service.name}'. "
|
|
52
|
+
f"Expected definition_key one of: {candidate_keys}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return f"{service.base_url.rstrip('/')}/{token_endpoint.path.lstrip('/')}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _request_new_service_token(config, service) -> str:
|
|
59
|
+
token_service = config.service or service
|
|
60
|
+
if config.service_id and config.service_id != service.id:
|
|
61
|
+
raise TokenConfigError(
|
|
62
|
+
f"Token config service mismatch. Config is for '{config.service.name}', "
|
|
63
|
+
f"requested service is '{service.name}'."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
token_url = _resolve_token_url(token_service)
|
|
67
|
+
grant_type = str(getattr(settings, "API_UTILITY_TOKEN_GRANT_TYPE", "client_credentials") or "client_credentials")
|
|
68
|
+
scope = str(getattr(settings, "API_UTILITY_TOKEN_SCOPE", "") or "").strip()
|
|
69
|
+
|
|
70
|
+
payload = {
|
|
71
|
+
"grant_type": grant_type,
|
|
72
|
+
"client_id": config.client_id,
|
|
73
|
+
"client_secret": config.get_client_secret(),
|
|
74
|
+
}
|
|
75
|
+
if scope:
|
|
76
|
+
payload["scope"] = scope
|
|
77
|
+
|
|
78
|
+
timeout = int(getattr(settings, "API_UTILITY_TOKEN_TIMEOUT_SECONDS", 10) or 10)
|
|
79
|
+
try:
|
|
80
|
+
response = execute_outbound_request(
|
|
81
|
+
method="POST",
|
|
82
|
+
url=token_url,
|
|
83
|
+
form_data=payload,
|
|
84
|
+
timeout=timeout,
|
|
85
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
86
|
+
)
|
|
87
|
+
except HttpClientError as exc:
|
|
88
|
+
raise TokenRequestError(f"Token request failed for service '{token_service.name}': {exc}") from exc
|
|
89
|
+
|
|
90
|
+
if response.status_code >= 400:
|
|
91
|
+
raise TokenRequestError(
|
|
92
|
+
f"Token request failed for service '{token_service.name}' "
|
|
93
|
+
f"with status {response.status_code}: {response.text[:500]}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
body = response.json() or {}
|
|
98
|
+
except ValueError as exc:
|
|
99
|
+
raise TokenRequestError(f"Token response for service '{token_service.name}' is not valid JSON") from exc
|
|
100
|
+
|
|
101
|
+
access_token = str(body.get("access_token") or "").strip()
|
|
102
|
+
if not access_token:
|
|
103
|
+
raise TokenRequestError(f"Token response for service '{token_service.name}' missing access_token")
|
|
104
|
+
|
|
105
|
+
expires_in = int(body.get("expires_in") or getattr(settings, "API_UTILITY_DEFAULT_TOKEN_EXPIRES_IN", 3600))
|
|
106
|
+
config.set_service_token(access_token)
|
|
107
|
+
config.token_expiry = timezone.now() + timedelta(seconds=max(expires_in, 1))
|
|
108
|
+
config.save(update_fields=["service_token", "token_expiry", "updated_at"])
|
|
109
|
+
return access_token
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_or_refresh_service_token(service, force_refresh: bool = False) -> str:
|
|
113
|
+
"""Return valid token for service and refresh it when required."""
|
|
114
|
+
config = _get_token_config_for_service(service)
|
|
115
|
+
if not config:
|
|
116
|
+
raise TokenConfigError(
|
|
117
|
+
f"No active IntegrationServiceTokenConfig found for service '{service.name}'. "
|
|
118
|
+
"Save token config with matching service first."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
buffer_seconds = int(getattr(settings, "API_UTILITY_TOKEN_EXPIRY_BUFFER_SECONDS", 60) or 60)
|
|
122
|
+
if not force_refresh and config.has_valid_service_token(buffer_seconds=buffer_seconds):
|
|
123
|
+
return config.get_service_token()
|
|
124
|
+
|
|
125
|
+
return _request_new_service_token(config=config, service=service)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Dict
|
|
3
|
+
|
|
4
|
+
from django_api_utility.models import ExternalServiceEndpoint
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class ResolvedEndpoint:
|
|
9
|
+
"""Resolved endpoint metadata required to execute a request."""
|
|
10
|
+
|
|
11
|
+
endpoint: ExternalServiceEndpoint
|
|
12
|
+
url: str
|
|
13
|
+
timeout: int
|
|
14
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
class ApiUtilityError(Exception):
|
|
2
|
+
"""Base exception for the utility."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConfigurationError(ApiUtilityError):
|
|
6
|
+
"""Raised when static utility configuration is invalid."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DefinitionKeyError(ConfigurationError):
|
|
10
|
+
"""Raised when a definition key cannot be loaded/resolved."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EndpointConfigError(ConfigurationError):
|
|
14
|
+
"""Raised when endpoint config is missing or invalid."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TokenConfigError(ConfigurationError):
|
|
18
|
+
"""Raised when token config is missing or invalid."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HttpClientError(ApiUtilityError):
|
|
22
|
+
"""Raised when an outbound HTTP call fails at transport layer."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TokenRequestError(ApiUtilityError):
|
|
26
|
+
"""Raised when token endpoint request/response is invalid."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EndpointRequestError(ApiUtilityError):
|
|
30
|
+
"""Raised when endpoint request execution fails."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SchemaValidationError(EndpointRequestError):
|
|
34
|
+
"""Raised when request/response schema validation fails."""
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from django.db import migrations, models
|
|
2
|
+
import django.db.models.deletion
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Migration(migrations.Migration):
|
|
6
|
+
initial = True
|
|
7
|
+
|
|
8
|
+
dependencies = []
|
|
9
|
+
|
|
10
|
+
operations = [
|
|
11
|
+
migrations.CreateModel(
|
|
12
|
+
name="ExternalServiceConfig",
|
|
13
|
+
fields=[
|
|
14
|
+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
15
|
+
("name", models.CharField(max_length=100, unique=True)),
|
|
16
|
+
("base_url", models.CharField(max_length=500)),
|
|
17
|
+
("default_timeout_seconds", models.PositiveIntegerField(default=10)),
|
|
18
|
+
("auth_required", models.BooleanField(default=True)),
|
|
19
|
+
("is_active", models.BooleanField(default=True)),
|
|
20
|
+
("description", models.TextField(blank=True, default="")),
|
|
21
|
+
],
|
|
22
|
+
options={
|
|
23
|
+
"ordering": ["name"],
|
|
24
|
+
},
|
|
25
|
+
),
|
|
26
|
+
migrations.CreateModel(
|
|
27
|
+
name="ExternalServiceEndpoint",
|
|
28
|
+
fields=[
|
|
29
|
+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
30
|
+
(
|
|
31
|
+
"service",
|
|
32
|
+
models.ForeignKey(
|
|
33
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
34
|
+
related_name="endpoints",
|
|
35
|
+
to="django_api_utility.externalserviceconfig",
|
|
36
|
+
),
|
|
37
|
+
),
|
|
38
|
+
("definition_key", models.CharField(db_index=True, max_length=150, unique=True)),
|
|
39
|
+
("path", models.CharField(max_length=500)),
|
|
40
|
+
("method", models.CharField(default="GET", max_length=10)),
|
|
41
|
+
("timeout_seconds", models.PositiveIntegerField(blank=True, null=True)),
|
|
42
|
+
("is_active", models.BooleanField(default=True)),
|
|
43
|
+
("description", models.TextField(blank=True, default="")),
|
|
44
|
+
],
|
|
45
|
+
options={
|
|
46
|
+
"ordering": ["service__name", "definition_key"],
|
|
47
|
+
},
|
|
48
|
+
),
|
|
49
|
+
migrations.CreateModel(
|
|
50
|
+
name="IntegrationServiceTokenConfig",
|
|
51
|
+
fields=[
|
|
52
|
+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
53
|
+
("key", models.CharField(db_index=True, max_length=120, unique=True)),
|
|
54
|
+
(
|
|
55
|
+
"service",
|
|
56
|
+
models.ForeignKey(
|
|
57
|
+
blank=True,
|
|
58
|
+
null=True,
|
|
59
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
60
|
+
related_name="token_configs",
|
|
61
|
+
to="django_api_utility.externalserviceconfig",
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
("client_id", models.CharField(max_length=200)),
|
|
65
|
+
("client_secret", models.CharField(max_length=255)),
|
|
66
|
+
("service_token", models.TextField(blank=True, default="")),
|
|
67
|
+
("token_expiry", models.DateTimeField(blank=True, null=True)),
|
|
68
|
+
("is_active", models.BooleanField(default=True)),
|
|
69
|
+
("description", models.TextField(blank=True, default="")),
|
|
70
|
+
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
71
|
+
("updated_at", models.DateTimeField(auto_now=True)),
|
|
72
|
+
],
|
|
73
|
+
options={
|
|
74
|
+
"ordering": ["key"],
|
|
75
|
+
},
|
|
76
|
+
),
|
|
77
|
+
migrations.CreateModel(
|
|
78
|
+
name="ExternalEndpointSchema",
|
|
79
|
+
fields=[
|
|
80
|
+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
81
|
+
(
|
|
82
|
+
"endpoint",
|
|
83
|
+
models.ForeignKey(
|
|
84
|
+
on_delete=django.db.models.deletion.CASCADE,
|
|
85
|
+
related_name="schemas",
|
|
86
|
+
to="django_api_utility.externalserviceendpoint",
|
|
87
|
+
),
|
|
88
|
+
),
|
|
89
|
+
(
|
|
90
|
+
"schema_kind",
|
|
91
|
+
models.CharField(
|
|
92
|
+
choices=[("request", "Request"), ("response", "Response")],
|
|
93
|
+
max_length=20,
|
|
94
|
+
),
|
|
95
|
+
),
|
|
96
|
+
("schema_version", models.CharField(default="v1", max_length=40)),
|
|
97
|
+
("schema_json", models.JSONField(blank=True, default=dict)),
|
|
98
|
+
("sample_payload", models.JSONField(blank=True, default=dict)),
|
|
99
|
+
("is_active", models.BooleanField(default=True)),
|
|
100
|
+
("description", models.TextField(blank=True, default="")),
|
|
101
|
+
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
102
|
+
("updated_at", models.DateTimeField(auto_now=True)),
|
|
103
|
+
],
|
|
104
|
+
options={
|
|
105
|
+
"ordering": ["endpoint__definition_key", "schema_kind", "-updated_at"],
|
|
106
|
+
"unique_together": {("endpoint", "schema_kind", "schema_version")},
|
|
107
|
+
},
|
|
108
|
+
),
|
|
109
|
+
]
|
|
110
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
|
|
5
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
6
|
+
from django.db import models
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
from django.utils import timezone
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExternalServiceConfig(models.Model):
|
|
12
|
+
name = models.CharField(max_length=100, unique=True)
|
|
13
|
+
base_url = models.CharField(max_length=500)
|
|
14
|
+
default_timeout_seconds = models.PositiveIntegerField(default=10)
|
|
15
|
+
auth_required = models.BooleanField(default=True)
|
|
16
|
+
is_active = models.BooleanField(default=True)
|
|
17
|
+
description = models.TextField(blank=True, default="")
|
|
18
|
+
|
|
19
|
+
class Meta:
|
|
20
|
+
ordering = ["name"]
|
|
21
|
+
|
|
22
|
+
def __str__(self):
|
|
23
|
+
return self.name
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ExternalServiceEndpoint(models.Model):
|
|
27
|
+
service = models.ForeignKey(
|
|
28
|
+
ExternalServiceConfig,
|
|
29
|
+
on_delete=models.CASCADE,
|
|
30
|
+
related_name="endpoints",
|
|
31
|
+
)
|
|
32
|
+
definition_key = models.CharField(max_length=150, unique=True, db_index=True)
|
|
33
|
+
path = models.CharField(max_length=500)
|
|
34
|
+
method = models.CharField(max_length=10, default="GET")
|
|
35
|
+
timeout_seconds = models.PositiveIntegerField(null=True, blank=True)
|
|
36
|
+
is_active = models.BooleanField(default=True)
|
|
37
|
+
description = models.TextField(blank=True, default="")
|
|
38
|
+
|
|
39
|
+
class Meta:
|
|
40
|
+
ordering = ["service__name", "definition_key"]
|
|
41
|
+
|
|
42
|
+
def __str__(self):
|
|
43
|
+
return f"{self.definition_key} ({self.service.name})"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ExternalEndpointSchema(models.Model):
|
|
47
|
+
SCHEMA_KIND_CHOICES = (
|
|
48
|
+
("request", "Request"),
|
|
49
|
+
("response", "Response"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
endpoint = models.ForeignKey(
|
|
53
|
+
ExternalServiceEndpoint,
|
|
54
|
+
on_delete=models.CASCADE,
|
|
55
|
+
related_name="schemas",
|
|
56
|
+
)
|
|
57
|
+
schema_kind = models.CharField(max_length=20, choices=SCHEMA_KIND_CHOICES)
|
|
58
|
+
schema_version = models.CharField(max_length=40, default="v1")
|
|
59
|
+
schema_json = models.JSONField(default=dict, blank=True)
|
|
60
|
+
sample_payload = models.JSONField(default=dict, blank=True)
|
|
61
|
+
is_active = models.BooleanField(default=True)
|
|
62
|
+
description = models.TextField(blank=True, default="")
|
|
63
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
64
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
65
|
+
|
|
66
|
+
class Meta:
|
|
67
|
+
ordering = ["endpoint__definition_key", "schema_kind", "-updated_at"]
|
|
68
|
+
unique_together = ("endpoint", "schema_kind", "schema_version")
|
|
69
|
+
|
|
70
|
+
def __str__(self):
|
|
71
|
+
return f"{self.endpoint.definition_key}:{self.schema_kind}:{self.schema_version}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _get_fernet() -> Fernet:
|
|
75
|
+
key = getattr(settings, "API_UTILITY_FERNET_KEY", None)
|
|
76
|
+
if key:
|
|
77
|
+
raw_key = str(key).encode("utf-8")
|
|
78
|
+
else:
|
|
79
|
+
digest = hashlib.sha256(str(settings.SECRET_KEY).encode("utf-8")).digest()
|
|
80
|
+
raw_key = base64.urlsafe_b64encode(digest)
|
|
81
|
+
return Fernet(raw_key)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class IntegrationServiceTokenConfig(models.Model):
|
|
85
|
+
key = models.CharField(max_length=120, unique=True, db_index=True)
|
|
86
|
+
service = models.ForeignKey(
|
|
87
|
+
"django_api_utility.ExternalServiceConfig",
|
|
88
|
+
on_delete=models.SET_NULL,
|
|
89
|
+
null=True,
|
|
90
|
+
blank=True,
|
|
91
|
+
related_name="token_configs",
|
|
92
|
+
)
|
|
93
|
+
client_id = models.CharField(max_length=200)
|
|
94
|
+
client_secret = models.CharField(max_length=255)
|
|
95
|
+
service_token = models.TextField(blank=True, default="")
|
|
96
|
+
token_expiry = models.DateTimeField(null=True, blank=True)
|
|
97
|
+
|
|
98
|
+
is_active = models.BooleanField(default=True)
|
|
99
|
+
description = models.TextField(blank=True, default="")
|
|
100
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
101
|
+
updated_at = models.DateTimeField(auto_now=True)
|
|
102
|
+
|
|
103
|
+
class Meta:
|
|
104
|
+
ordering = ["key"]
|
|
105
|
+
|
|
106
|
+
def __str__(self):
|
|
107
|
+
return self.key
|
|
108
|
+
|
|
109
|
+
def set_client_secret(self, raw_secret: str):
|
|
110
|
+
if not raw_secret:
|
|
111
|
+
self.client_secret = ""
|
|
112
|
+
return
|
|
113
|
+
self.client_secret = _get_fernet().encrypt(raw_secret.encode("utf-8")).decode("utf-8")
|
|
114
|
+
|
|
115
|
+
def get_client_secret(self) -> str:
|
|
116
|
+
if not self.client_secret:
|
|
117
|
+
return ""
|
|
118
|
+
try:
|
|
119
|
+
return _get_fernet().decrypt(self.client_secret.encode("utf-8")).decode("utf-8")
|
|
120
|
+
except (InvalidToken, ValueError, TypeError):
|
|
121
|
+
# Backward compatibility: allow legacy plaintext value.
|
|
122
|
+
return self.client_secret
|
|
123
|
+
|
|
124
|
+
def set_service_token(self, raw_token: str):
|
|
125
|
+
if not raw_token:
|
|
126
|
+
self.service_token = ""
|
|
127
|
+
return
|
|
128
|
+
self.service_token = _get_fernet().encrypt(raw_token.encode("utf-8")).decode("utf-8")
|
|
129
|
+
|
|
130
|
+
def get_service_token(self) -> str:
|
|
131
|
+
if not self.service_token:
|
|
132
|
+
return ""
|
|
133
|
+
try:
|
|
134
|
+
return _get_fernet().decrypt(self.service_token.encode("utf-8")).decode("utf-8")
|
|
135
|
+
except (InvalidToken, ValueError, TypeError):
|
|
136
|
+
# Backward compatibility: allow legacy plaintext value.
|
|
137
|
+
return self.service_token
|
|
138
|
+
|
|
139
|
+
def has_valid_service_token(self, buffer_seconds: int = 60) -> bool:
|
|
140
|
+
if not self.service_token or not self.token_expiry:
|
|
141
|
+
return False
|
|
142
|
+
return self.token_expiry > timezone.now() + timedelta(seconds=max(int(buffer_seconds), 0))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Orchestration layer: high-level API request flows."""
|
|
2
|
+
|
|
3
|
+
from .api_service import delete_by_key, get_by_key, patch_by_key, post_by_key, put_by_key, request_by_key
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"request_by_key",
|
|
7
|
+
"get_by_key",
|
|
8
|
+
"post_by_key",
|
|
9
|
+
"put_by_key",
|
|
10
|
+
"patch_by_key",
|
|
11
|
+
"delete_by_key",
|
|
12
|
+
]
|