mirror of
https://github.com/apidoorman/doorman.git
synced 2026-01-12 12:39:39 -06:00
formatting and style fixes. lint fixes
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,10 @@
|
||||
from __future__ import annotations
|
||||
import requests
|
||||
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class LiveClient:
|
||||
def __init__(self, base_url: str):
|
||||
self.base_url = base_url.rstrip('/') + '/'
|
||||
@@ -30,7 +33,9 @@ class LiveClient:
|
||||
def post(self, path: str, json=None, data=None, files=None, headers=None, **kwargs):
|
||||
url = urljoin(self.base_url, path.lstrip('/'))
|
||||
hdrs = self._headers_with_csrf(headers)
|
||||
return self.sess.post(url, json=json, data=data, files=files, headers=hdrs, allow_redirects=False, **kwargs)
|
||||
return self.sess.post(
|
||||
url, json=json, data=data, files=files, headers=hdrs, allow_redirects=False, **kwargs
|
||||
)
|
||||
|
||||
def put(self, path: str, json=None, headers=None, **kwargs):
|
||||
url = urljoin(self.base_url, path.lstrip('/'))
|
||||
|
||||
@@ -2,15 +2,24 @@ import os
|
||||
|
||||
BASE_URL = os.getenv('DOORMAN_BASE_URL', 'http://localhost:3001').rstrip('/')
|
||||
ADMIN_EMAIL = os.getenv('DOORMAN_ADMIN_EMAIL', 'admin@doorman.dev')
|
||||
|
||||
# Resolve admin password from environment or the repo root .env.
|
||||
# Search order:
|
||||
# 1) Environment variable DOORMAN_ADMIN_PASSWORD
|
||||
# 2) Repo root .env (two levels up from live-tests)
|
||||
# 3) Default test password
|
||||
ADMIN_PASSWORD = os.getenv('DOORMAN_ADMIN_PASSWORD')
|
||||
if not ADMIN_PASSWORD:
|
||||
env_file = os.path.join(os.path.dirname(__file__), '..', '.env')
|
||||
if os.path.exists(env_file):
|
||||
with open(env_file) as f:
|
||||
for line in f:
|
||||
if line.startswith('DOORMAN_ADMIN_PASSWORD='):
|
||||
ADMIN_PASSWORD = line.split('=', 1)[1].strip()
|
||||
break
|
||||
env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '.env'))
|
||||
try:
|
||||
if os.path.exists(env_path):
|
||||
with open(env_path, encoding='utf-8') as f:
|
||||
for line in f:
|
||||
if line.startswith('DOORMAN_ADMIN_PASSWORD='):
|
||||
ADMIN_PASSWORD = line.split('=', 1)[1].strip()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not ADMIN_PASSWORD:
|
||||
ADMIN_PASSWORD = 'test-only-password-12chars'
|
||||
|
||||
@@ -18,6 +27,7 @@ ENABLE_GRAPHQL = True
|
||||
ENABLE_GRPC = True
|
||||
STRICT_HEALTH = True
|
||||
|
||||
|
||||
def require_env():
|
||||
missing = []
|
||||
if not BASE_URL:
|
||||
@@ -25,4 +35,4 @@ def require_env():
|
||||
if not ADMIN_EMAIL:
|
||||
missing.append('DOORMAN_ADMIN_EMAIL')
|
||||
if missing:
|
||||
raise RuntimeError(f"Missing required env vars: {', '.join(missing)}")
|
||||
raise RuntimeError(f'Missing required env vars: {", ".join(missing)}')
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
from config import BASE_URL, ADMIN_EMAIL, ADMIN_PASSWORD, require_env, STRICT_HEALTH
|
||||
from client import LiveClient
|
||||
from config import ADMIN_EMAIL, ADMIN_PASSWORD, BASE_URL, STRICT_HEALTH, require_env
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def base_url() -> str:
|
||||
require_env()
|
||||
return BASE_URL
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def client(base_url) -> LiveClient:
|
||||
c = LiveClient(base_url)
|
||||
@@ -32,7 +34,7 @@ def client(base_url) -> LiveClient:
|
||||
ok = data.get('status') in ('online', 'healthy')
|
||||
if ok:
|
||||
break
|
||||
last_err = f"status json={j}"
|
||||
last_err = f'status json={j}'
|
||||
except Exception as e:
|
||||
last_err = f'json parse error: {e}'
|
||||
else:
|
||||
@@ -48,6 +50,7 @@ def client(base_url) -> LiveClient:
|
||||
assert 'access_token' in auth.get('response', auth), 'login did not return access_token'
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def ensure_session_and_relaxed_limits(client: LiveClient):
|
||||
"""Per-test guard: ensure we're authenticated and not rate-limited.
|
||||
@@ -59,23 +62,29 @@ def ensure_session_and_relaxed_limits(client: LiveClient):
|
||||
r = client.get('/platform/authorization/status')
|
||||
if r.status_code not in (200, 204):
|
||||
from config import ADMIN_EMAIL, ADMIN_PASSWORD
|
||||
|
||||
client.login(ADMIN_EMAIL, ADMIN_PASSWORD)
|
||||
except Exception:
|
||||
from config import ADMIN_EMAIL, ADMIN_PASSWORD
|
||||
|
||||
client.login(ADMIN_EMAIL, ADMIN_PASSWORD)
|
||||
try:
|
||||
client.put('/platform/user/admin', json={
|
||||
'rate_limit_duration': 1000000,
|
||||
'rate_limit_duration_type': 'second',
|
||||
'throttle_duration': 1000000,
|
||||
'throttle_duration_type': 'second',
|
||||
'throttle_queue_limit': 1000000,
|
||||
'throttle_wait_duration': 0,
|
||||
'throttle_wait_duration_type': 'second'
|
||||
})
|
||||
client.put(
|
||||
'/platform/user/admin',
|
||||
json={
|
||||
'rate_limit_duration': 1000000,
|
||||
'rate_limit_duration_type': 'second',
|
||||
'throttle_duration': 1000000,
|
||||
'throttle_duration_type': 'second',
|
||||
'throttle_queue_limit': 1000000,
|
||||
'throttle_wait_duration': 0,
|
||||
'throttle_wait_duration_type': 'second',
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption('--graph', action='store_true', default=False, help='Force GraphQL tests')
|
||||
parser.addoption('--grpc', action='store_true', default=False, help='Force gRPC tests')
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
import threading
|
||||
import socket
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
|
||||
def _find_free_port() -> int:
|
||||
s = socket.socket()
|
||||
s.bind(('0.0.0.0', 0))
|
||||
@@ -11,6 +13,7 @@ def _find_free_port() -> int:
|
||||
s.close()
|
||||
return port
|
||||
|
||||
|
||||
def _get_host_from_container() -> str:
|
||||
"""Get the hostname to use when referring to the host machine from a Docker container.
|
||||
|
||||
@@ -38,6 +41,7 @@ def _get_host_from_container() -> str:
|
||||
# This is the most common development setup
|
||||
return '127.0.0.1'
|
||||
|
||||
|
||||
class _ThreadedHTTPServer:
|
||||
def __init__(self, handler_cls, host='0.0.0.0', port=None):
|
||||
self.bind_host = host
|
||||
@@ -61,6 +65,7 @@ class _ThreadedHTTPServer:
|
||||
def url(self):
|
||||
return f'http://{self.host}:{self.port}'
|
||||
|
||||
|
||||
def start_rest_echo_server():
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def _json(self, status=200, payload=None):
|
||||
@@ -76,7 +81,7 @@ def start_rest_echo_server():
|
||||
'method': 'GET',
|
||||
'path': self.path,
|
||||
'headers': {k: v for k, v in self.headers.items()},
|
||||
'query': self.path.split('?', 1)[1] if '?' in self.path else ''
|
||||
'query': self.path.split('?', 1)[1] if '?' in self.path else '',
|
||||
}
|
||||
self._json(200, payload)
|
||||
|
||||
@@ -91,7 +96,7 @@ def start_rest_echo_server():
|
||||
'method': 'POST',
|
||||
'path': self.path,
|
||||
'headers': {k: v for k, v in self.headers.items()},
|
||||
'json': parsed
|
||||
'json': parsed,
|
||||
}
|
||||
self._json(200, payload)
|
||||
|
||||
@@ -106,7 +111,7 @@ def start_rest_echo_server():
|
||||
'method': 'PUT',
|
||||
'path': self.path,
|
||||
'headers': {k: v for k, v in self.headers.items()},
|
||||
'json': parsed
|
||||
'json': parsed,
|
||||
}
|
||||
self._json(200, payload)
|
||||
|
||||
@@ -120,6 +125,7 @@ def start_rest_echo_server():
|
||||
|
||||
return _ThreadedHTTPServer(Handler).start()
|
||||
|
||||
|
||||
def start_soap_echo_server():
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def _xml(self, status=200, content=''):
|
||||
@@ -134,12 +140,11 @@ def start_soap_echo_server():
|
||||
content_length = int(self.headers.get('Content-Length', '0') or '0')
|
||||
_ = self.rfile.read(content_length) if content_length else b''
|
||||
resp = (
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
"<soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
" <soap:Body><EchoResponse><message>ok</message></EchoResponse></soap:Body>"
|
||||
"</soap:Envelope>"
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">'
|
||||
' <soap:Body><EchoResponse><message>ok</message></EchoResponse></soap:Body>'
|
||||
'</soap:Envelope>'
|
||||
)
|
||||
self._xml(200, resp)
|
||||
|
||||
return _ThreadedHTTPServer(Handler).start()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
|
||||
|
||||
def test_status_ok(client):
|
||||
r = client.get('/api/health')
|
||||
assert r.status_code == 200
|
||||
@@ -12,6 +13,7 @@ def test_status_ok(client):
|
||||
else:
|
||||
assert 'error_code' in (j or {})
|
||||
|
||||
|
||||
def test_auth_status_me(client):
|
||||
r = client.get('/platform/authorization/status')
|
||||
assert r.status_code in (200, 204)
|
||||
@@ -21,5 +23,6 @@ def test_auth_status_me(client):
|
||||
me = r.json().get('response', r.json())
|
||||
assert me.get('username') == 'admin'
|
||||
assert me.get('ui_access') is True
|
||||
import pytest
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.health, pytest.mark.auth]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
import time
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
|
||||
|
||||
def _strong_password() -> str:
|
||||
upp = random.choice(string.ascii_uppercase)
|
||||
@@ -12,9 +12,10 @@ def _strong_password() -> str:
|
||||
raw = upp + low + dig + spc + tail
|
||||
return ''.join(random.sample(raw, len(raw)))
|
||||
|
||||
|
||||
def test_user_onboarding_lifecycle(client):
|
||||
username = f"user_{int(time.time())}_{random.randint(1000,9999)}"
|
||||
email = f"{username}@example.com"
|
||||
username = f'user_{int(time.time())}_{random.randint(1000, 9999)}'
|
||||
email = f'{username}@example.com'
|
||||
pwd = _strong_password()
|
||||
|
||||
payload = {
|
||||
@@ -23,7 +24,7 @@ def test_user_onboarding_lifecycle(client):
|
||||
'password': pwd,
|
||||
'role': 'developer',
|
||||
'groups': ['ALL'],
|
||||
'ui_access': False
|
||||
'ui_access': False,
|
||||
}
|
||||
r = client.post('/platform/user', json=payload)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
@@ -38,13 +39,14 @@ def test_user_onboarding_lifecycle(client):
|
||||
assert r.status_code in (200, 204), r.text
|
||||
|
||||
new_pwd = _strong_password()
|
||||
r = client.put(f'/platform/user/{username}/update-password', json={
|
||||
'old_password': pwd,
|
||||
'new_password': new_pwd
|
||||
})
|
||||
r = client.put(
|
||||
f'/platform/user/{username}/update-password',
|
||||
json={'old_password': pwd, 'new_password': new_pwd},
|
||||
)
|
||||
assert r.status_code in (200, 204, 400), r.text
|
||||
|
||||
from client import LiveClient
|
||||
|
||||
user_client = LiveClient(client.base_url)
|
||||
auth = user_client.login(email, new_pwd if r.status_code in (200, 204) else pwd)
|
||||
assert 'access_token' in auth.get('response', auth)
|
||||
@@ -56,5 +58,8 @@ def test_user_onboarding_lifecycle(client):
|
||||
|
||||
r = client.delete(f'/platform/user/{username}')
|
||||
assert r.status_code in (200, 204), r.text
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.users, pytest.mark.auth]
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
import time
|
||||
|
||||
|
||||
def test_credit_def_create_and_assign(client):
|
||||
group = f"credits_{int(time.time())}"
|
||||
group = f'credits_{int(time.time())}'
|
||||
api_key = 'TEST_API_KEY_123456789'
|
||||
payload = {
|
||||
'api_credit_group': group,
|
||||
'api_key': api_key,
|
||||
'api_key_header': 'x-api-key',
|
||||
'credit_tiers': [
|
||||
{ 'tier_name': 'default', 'credits': 5, 'input_limit': 0, 'output_limit': 0, 'reset_frequency': 'monthly' }
|
||||
]
|
||||
{
|
||||
'tier_name': 'default',
|
||||
'credits': 5,
|
||||
'input_limit': 0,
|
||||
'output_limit': 0,
|
||||
'reset_frequency': 'monthly',
|
||||
}
|
||||
],
|
||||
}
|
||||
r = client.post('/platform/credit', json=payload)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
payload2 = {
|
||||
'username': 'admin',
|
||||
'users_credits': {
|
||||
group: {
|
||||
'tier_name': 'default',
|
||||
'available_credits': 5
|
||||
}
|
||||
}
|
||||
'users_credits': {group: {'tier_name': 'default', 'available_credits': 5}},
|
||||
}
|
||||
r = client.post(f'/platform/credit/admin', json=payload2)
|
||||
r = client.post('/platform/credit/admin', json=payload2)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
r = client.get(f'/platform/credit/defs/{group}')
|
||||
@@ -32,5 +34,8 @@ def test_credit_def_create_and_assign(client):
|
||||
assert r.status_code == 200
|
||||
data = r.json().get('response', r.json())
|
||||
assert group in (data.get('users_credits') or {})
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.credits]
|
||||
|
||||
@@ -1,28 +1,39 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.auth]
|
||||
|
||||
|
||||
def test_subscribe_list_unsubscribe(client):
|
||||
api_name = f"subs-{int(time.time())}"
|
||||
api_name = f'subs-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'subs',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'active': True
|
||||
})
|
||||
r = client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'subs',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
r = client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
r = client.get('/platform/subscription/subscriptions')
|
||||
assert r.status_code == 200
|
||||
payload = r.json().get('response', r.json())
|
||||
apis = payload.get('apis') or []
|
||||
assert any(f"{api_name}/{api_version}" == a for a in apis)
|
||||
r = client.post('/platform/subscription/unsubscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
assert any(f'{api_name}/{api_version}' == a for a in apis)
|
||||
r = client.post(
|
||||
'/platform/subscription/unsubscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
client.delete(f'/platform/api/{api_name}/{api_version}')
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import time
|
||||
|
||||
from servers import start_rest_echo_server
|
||||
|
||||
|
||||
def test_rest_gateway_basic_flow(client):
|
||||
srv = start_rest_echo_server()
|
||||
try:
|
||||
@@ -17,7 +19,7 @@ def test_rest_gateway_basic_flow(client):
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
'active': True,
|
||||
'api_cors_allow_origins': ['*']
|
||||
'api_cors_allow_origins': ['*'],
|
||||
}
|
||||
r = client.post('/platform/api', json=api_payload)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
@@ -27,7 +29,7 @@ def test_rest_gateway_basic_flow(client):
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/status',
|
||||
'endpoint_description': 'status'
|
||||
'endpoint_description': 'status',
|
||||
}
|
||||
r = client.post('/platform/endpoint', json=ep_payload)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
@@ -49,6 +51,7 @@ def test_rest_gateway_basic_flow(client):
|
||||
finally:
|
||||
srv.stop()
|
||||
|
||||
|
||||
def test_rest_gateway_with_credits_and_header_injection(client):
|
||||
srv = start_rest_echo_server()
|
||||
try:
|
||||
@@ -58,45 +61,68 @@ def test_rest_gateway_with_credits_and_header_injection(client):
|
||||
credit_group = f'cg-{ts}'
|
||||
api_key_val = 'DUMMY_API_KEY_ABC'
|
||||
|
||||
r = client.post('/platform/credit', json={
|
||||
'api_credit_group': credit_group,
|
||||
'api_key': api_key_val,
|
||||
'api_key_header': 'x-api-key',
|
||||
'credit_tiers': [{ 'tier_name': 'default', 'credits': 2, 'input_limit': 0, 'output_limit': 0, 'reset_frequency': 'monthly' }]
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/credit',
|
||||
json={
|
||||
'api_credit_group': credit_group,
|
||||
'api_key': api_key_val,
|
||||
'api_key_header': 'x-api-key',
|
||||
'credit_tiers': [
|
||||
{
|
||||
'tier_name': 'default',
|
||||
'credits': 2,
|
||||
'input_limit': 0,
|
||||
'output_limit': 0,
|
||||
'reset_frequency': 'monthly',
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
r = client.post('/platform/credit/admin', json={
|
||||
'username': 'admin',
|
||||
'users_credits': { credit_group: { 'tier_name': 'default', 'available_credits': 2 } }
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/credit/admin',
|
||||
json={
|
||||
'username': 'admin',
|
||||
'users_credits': {credit_group: {'tier_name': 'default', 'available_credits': 2}},
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
r = client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'REST with credits',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
'active': True,
|
||||
'api_credits_enabled': True,
|
||||
'api_credit_group': credit_group
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'REST with credits',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
'active': True,
|
||||
'api_credits_enabled': True,
|
||||
'api_credit_group': credit_group,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
r = client.post('/platform/endpoint', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/echo',
|
||||
'endpoint_description': 'echo with header'
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/echo',
|
||||
'endpoint_description': 'echo with header',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
r = client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
r = client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
r = client.post(f'/api/rest/{api_name}/{api_version}/echo', json={'ping': 'pong'})
|
||||
@@ -120,5 +146,8 @@ def test_rest_gateway_with_credits_and_header_injection(client):
|
||||
except Exception:
|
||||
pass
|
||||
srv.stop()
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.rest, pytest.mark.gateway]
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.rest]
|
||||
|
||||
|
||||
def test_endpoints_update_list_delete(client):
|
||||
api_name = f"epcrud-{int(time.time())}"
|
||||
api_name = f'epcrud-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
client.post('/platform/api', json={
|
||||
'api_name': api_name, 'api_version': api_version, 'api_description': 'ep',
|
||||
'api_allowed_roles': ['admin'], 'api_allowed_groups': ['ALL'], 'api_servers': ['http://127.0.0.1:9'], 'api_type': 'REST', 'active': True
|
||||
})
|
||||
client.post('/platform/endpoint', json={
|
||||
'api_name': api_name, 'api_version': api_version, 'endpoint_method': 'GET', 'endpoint_uri': '/z', 'endpoint_description': 'z'
|
||||
})
|
||||
r = client.put(f'/platform/endpoint/GET/{api_name}/{api_version}/z', json={'endpoint_description': 'zzz'})
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'ep',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/z',
|
||||
'endpoint_description': 'z',
|
||||
},
|
||||
)
|
||||
r = client.put(
|
||||
f'/platform/endpoint/GET/{api_name}/{api_version}/z', json={'endpoint_description': 'zzz'}
|
||||
)
|
||||
assert r.status_code in (200, 204)
|
||||
r = client.get(f'/platform/endpoint/{api_name}/{api_version}')
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import time
|
||||
|
||||
from servers import start_rest_echo_server
|
||||
|
||||
|
||||
def test_user_specific_credit_api_key_overrides_group_key(client):
|
||||
srv = start_rest_echo_server()
|
||||
try:
|
||||
@@ -11,42 +13,71 @@ def test_user_specific_credit_api_key_overrides_group_key(client):
|
||||
group_key = 'GROUP_KEY_ABC'
|
||||
user_key = 'USER_KEY_DEF'
|
||||
|
||||
r = client.post('/platform/credit', json={
|
||||
'api_credit_group': group,
|
||||
'api_key': group_key,
|
||||
'api_key_header': 'x-api-key',
|
||||
'credit_tiers': [{ 'tier_name': 'default', 'credits': 3, 'input_limit': 0, 'output_limit': 0, 'reset_frequency': 'monthly' }]
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/credit',
|
||||
json={
|
||||
'api_credit_group': group,
|
||||
'api_key': group_key,
|
||||
'api_key_header': 'x-api-key',
|
||||
'credit_tiers': [
|
||||
{
|
||||
'tier_name': 'default',
|
||||
'credits': 3,
|
||||
'input_limit': 0,
|
||||
'output_limit': 0,
|
||||
'reset_frequency': 'monthly',
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
r = client.post('/platform/credit/admin', json={
|
||||
'username': 'admin',
|
||||
'users_credits': { group: { 'tier_name': 'default', 'available_credits': 3, 'user_api_key': user_key } }
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/credit/admin',
|
||||
json={
|
||||
'username': 'admin',
|
||||
'users_credits': {
|
||||
group: {
|
||||
'tier_name': 'default',
|
||||
'available_credits': 3,
|
||||
'user_api_key': user_key,
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
r = client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'credit user override',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_credits_enabled': True,
|
||||
'api_credit_group': group
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'credit user override',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_credits_enabled': True,
|
||||
'api_credit_group': group,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
r = client.post('/platform/endpoint', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/whoami',
|
||||
'endpoint_description': 'whoami'
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/whoami',
|
||||
'endpoint_description': 'whoami',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
|
||||
r = client.get(f'/api/rest/{api_name}/{api_version}/whoami')
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -1,39 +1,53 @@
|
||||
import time
|
||||
|
||||
from servers import start_rest_echo_server
|
||||
|
||||
|
||||
def test_rate_limiting_blocks_excess_requests(client):
|
||||
srv = start_rest_echo_server()
|
||||
try:
|
||||
api_name = f'rl-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
client.put('/platform/user/admin', json={
|
||||
'rate_limit_duration': 1,
|
||||
'rate_limit_duration_type': 'second',
|
||||
'throttle_duration': 999,
|
||||
'throttle_duration_type': 'second',
|
||||
'throttle_queue_limit': 999,
|
||||
'throttle_wait_duration': 0,
|
||||
'throttle_wait_duration_type': 'second'
|
||||
})
|
||||
client.put(
|
||||
'/platform/user/admin',
|
||||
json={
|
||||
'rate_limit_duration': 1,
|
||||
'rate_limit_duration_type': 'second',
|
||||
'throttle_duration': 999,
|
||||
'throttle_duration_type': 'second',
|
||||
'throttle_queue_limit': 999,
|
||||
'throttle_wait_duration': 0,
|
||||
'throttle_wait_duration_type': 'second',
|
||||
},
|
||||
)
|
||||
|
||||
client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'rl test',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True
|
||||
})
|
||||
client.post('/platform/endpoint', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/hit',
|
||||
'endpoint_description': 'hit'
|
||||
})
|
||||
client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'rl test',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/hit',
|
||||
'endpoint_description': 'hit',
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
|
||||
time.sleep(1.1)
|
||||
|
||||
@@ -55,14 +69,17 @@ def test_rate_limiting_blocks_excess_requests(client):
|
||||
pass
|
||||
srv.stop()
|
||||
try:
|
||||
client.put('/platform/user/admin', json={
|
||||
'rate_limit_duration': 1000000,
|
||||
'rate_limit_duration_type': 'second',
|
||||
'throttle_duration': 1000000,
|
||||
'throttle_duration_type': 'second',
|
||||
'throttle_queue_limit': 1000000,
|
||||
'throttle_wait_duration': 0,
|
||||
'throttle_wait_duration_type': 'second'
|
||||
})
|
||||
client.put(
|
||||
'/platform/user/admin',
|
||||
json={
|
||||
'rate_limit_duration': 1000000,
|
||||
'rate_limit_duration_type': 'second',
|
||||
'throttle_duration': 1000000,
|
||||
'throttle_duration_type': 'second',
|
||||
'throttle_queue_limit': 1000000,
|
||||
'throttle_wait_duration': 0,
|
||||
'throttle_wait_duration_type': 'second',
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import time
|
||||
import threading
|
||||
import socket
|
||||
import requests
|
||||
import pytest
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from config import ENABLE_GRAPHQL
|
||||
from servers import start_rest_echo_server, start_soap_echo_server
|
||||
from config import ENABLE_GRAPHQL, ENABLE_GRPC
|
||||
|
||||
|
||||
def _find_port():
|
||||
s = socket.socket()
|
||||
@@ -16,6 +17,7 @@ def _find_port():
|
||||
s.close()
|
||||
return p
|
||||
|
||||
|
||||
def _get_host_from_container():
|
||||
"""Get the hostname to use when referring to the host machine from a Docker container."""
|
||||
docker_env = os.getenv('DOORMAN_IN_DOCKER', '').lower()
|
||||
@@ -27,6 +29,7 @@ def _get_host_from_container():
|
||||
return '172.17.0.1'
|
||||
return '127.0.0.1'
|
||||
|
||||
|
||||
def test_bulk_public_rest_crud(client):
|
||||
srv = start_rest_echo_server()
|
||||
try:
|
||||
@@ -35,29 +38,40 @@ def test_bulk_public_rest_crud(client):
|
||||
for i in range(3):
|
||||
api_name = f'pub-rest-{ts}-{i}'
|
||||
api_version = 'v1'
|
||||
r = client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'public rest',
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': [],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_public': True
|
||||
})
|
||||
assert r.status_code in (200, 201), r.text
|
||||
for m, uri in [('GET', '/items'), ('POST', '/items'), ('PUT', '/items'), ('DELETE', '/items')]:
|
||||
r = client.post('/platform/endpoint', json={
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': m,
|
||||
'endpoint_uri': uri,
|
||||
'endpoint_description': f'{m} {uri}'
|
||||
})
|
||||
'api_description': 'public rest',
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': [],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_public': True,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
for m, uri in [
|
||||
('GET', '/items'),
|
||||
('POST', '/items'),
|
||||
('PUT', '/items'),
|
||||
('DELETE', '/items'),
|
||||
]:
|
||||
r = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': m,
|
||||
'endpoint_uri': uri,
|
||||
'endpoint_description': f'{m} {uri}',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
s = requests.Session()
|
||||
url = f"{base}/api/rest/{api_name}/{api_version}/items"
|
||||
url = f'{base}/api/rest/{api_name}/{api_version}/items'
|
||||
assert s.get(url).status_code == 200
|
||||
assert s.post(url, json={'name': 'x'}).status_code == 200
|
||||
assert s.put(url, json={'name': 'y'}).status_code == 200
|
||||
@@ -65,69 +79,78 @@ def test_bulk_public_rest_crud(client):
|
||||
finally:
|
||||
srv.stop()
|
||||
|
||||
|
||||
def test_bulk_public_soap_crud(client):
|
||||
srv = start_soap_echo_server()
|
||||
try:
|
||||
base = client.base_url.rstrip('/')
|
||||
ts = int(time.time())
|
||||
envelope = (
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
"<soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
" <soap:Body><Op/></soap:Body>"
|
||||
"</soap:Envelope>"
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">'
|
||||
' <soap:Body><Op/></soap:Body>'
|
||||
'</soap:Envelope>'
|
||||
)
|
||||
for i in range(3):
|
||||
api_name = f'pub-soap-{ts}-{i}'
|
||||
api_version = 'v1'
|
||||
r = client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'public soap',
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': [],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_public': True
|
||||
})
|
||||
assert r.status_code in (200, 201), r.text
|
||||
for uri in ['/create', '/read', '/update', '/delete']:
|
||||
r = client.post('/platform/endpoint', json={
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': uri,
|
||||
'endpoint_description': f'SOAP {uri}'
|
||||
})
|
||||
'api_description': 'public soap',
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': [],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_public': True,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
for uri in ['/create', '/read', '/update', '/delete']:
|
||||
r = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': uri,
|
||||
'endpoint_description': f'SOAP {uri}',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
s = requests.Session()
|
||||
headers = {'Content-Type': 'text/xml'}
|
||||
for uri in ['create', 'read', 'update', 'delete']:
|
||||
url = f"{base}/api/soap/{api_name}/{api_version}/{uri}"
|
||||
url = f'{base}/api/soap/{api_name}/{api_version}/{uri}'
|
||||
resp = s.post(url, data=envelope, headers=headers)
|
||||
assert resp.status_code == 200
|
||||
finally:
|
||||
srv.stop()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not ENABLE_GRAPHQL, reason='GraphQL disabled')
|
||||
def test_bulk_public_graphql_crud(client):
|
||||
try:
|
||||
import uvicorn
|
||||
from ariadne import gql as _gql, make_executable_schema, MutationType, QueryType
|
||||
from ariadne import MutationType, QueryType, make_executable_schema
|
||||
from ariadne import gql as _gql
|
||||
from ariadne.asgi import GraphQL
|
||||
except Exception as e:
|
||||
pytest.skip(f'Missing GraphQL deps: {e}')
|
||||
|
||||
def start_gql_server():
|
||||
data_store = {'items': {}, 'seq': 0}
|
||||
type_defs = _gql('''
|
||||
type_defs = _gql("""
|
||||
type Query { read(id: Int!): String! }
|
||||
type Mutation {
|
||||
create(name: String!): String!
|
||||
update(id: Int!, name: String!): String!
|
||||
delete(id: Int!): Boolean!
|
||||
}
|
||||
''')
|
||||
""")
|
||||
query = QueryType()
|
||||
mutation = MutationType()
|
||||
|
||||
@@ -168,30 +191,53 @@ def test_bulk_public_graphql_crud(client):
|
||||
api_name = f'pub-gql-{ts}-{i}'
|
||||
api_version = 'v1'
|
||||
try:
|
||||
r = client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'public gql',
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': [],
|
||||
'api_servers': [f'http://{host}:{port}'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_public': True
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'public gql',
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': [],
|
||||
'api_servers': [f'http://{host}:{port}'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_public': True,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
r = client.post('/platform/endpoint', json={'api_name': api_name, 'api_version': api_version, 'endpoint_method': 'POST', 'endpoint_uri': '/graphql', 'endpoint_description': 'graphql'})
|
||||
r = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/graphql',
|
||||
'endpoint_description': 'graphql',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
s = requests.Session()
|
||||
url = f"{base}/api/graphql/{api_name}"
|
||||
url = f'{base}/api/graphql/{api_name}'
|
||||
q_create = {'query': 'mutation { create(name:"A") }'}
|
||||
assert s.post(url, json=q_create, headers={'X-API-Version': api_version}).status_code == 200
|
||||
assert (
|
||||
s.post(url, json=q_create, headers={'X-API-Version': api_version}).status_code
|
||||
== 200
|
||||
)
|
||||
q_update = {'query': 'mutation { update(id:1, name:"B") }'}
|
||||
assert s.post(url, json=q_update, headers={'X-API-Version': api_version}).status_code == 200
|
||||
assert (
|
||||
s.post(url, json=q_update, headers={'X-API-Version': api_version}).status_code
|
||||
== 200
|
||||
)
|
||||
q_read = {'query': '{ read(id:1) }'}
|
||||
assert s.post(url, json=q_read, headers={'X-API-Version': api_version}).status_code == 200
|
||||
assert (
|
||||
s.post(url, json=q_read, headers={'X-API-Version': api_version}).status_code == 200
|
||||
)
|
||||
q_delete = {'query': 'mutation { delete(id:1) }'}
|
||||
assert s.post(url, json=q_delete, headers={'X-API-Version': api_version}).status_code == 200
|
||||
assert (
|
||||
s.post(url, json=q_delete, headers={'X-API-Version': api_version}).status_code
|
||||
== 200
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
client.delete(f'/platform/endpoint/POST/{api_name}/{api_version}/graphql')
|
||||
@@ -206,19 +252,29 @@ def test_bulk_public_graphql_crud(client):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
import os as _os
|
||||
_RUN_LIVE = _os.getenv('DOORMAN_RUN_LIVE', '0') in ('1','true','True')
|
||||
@pytest.mark.skipif(not _RUN_LIVE, reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable')
|
||||
|
||||
_RUN_LIVE = _os.getenv('DOORMAN_RUN_LIVE', '0') in ('1', 'true', 'True')
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _RUN_LIVE, reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable'
|
||||
)
|
||||
def test_bulk_public_grpc_crud(client):
|
||||
try:
|
||||
import importlib
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
from concurrent import futures
|
||||
|
||||
import grpc
|
||||
from grpc_tools import protoc
|
||||
from concurrent import futures
|
||||
import tempfile, pathlib, importlib, sys
|
||||
except Exception as e:
|
||||
pytest.skip(f'Missing gRPC deps: {e}')
|
||||
|
||||
PROTO = '''
|
||||
PROTO = """
|
||||
syntax = "proto3";
|
||||
package {pkg};
|
||||
service Resource {
|
||||
@@ -235,9 +291,9 @@ message UpdateRequest { int32 id = 1; string name = 2; }
|
||||
message UpdateReply { string message = 1; }
|
||||
message DeleteRequest { int32 id = 1; }
|
||||
message DeleteReply { bool ok = 1; }
|
||||
'''
|
||||
"""
|
||||
|
||||
base = client.base_url.rstrip('/')
|
||||
client.base_url.rstrip('/')
|
||||
ts = int(time.time())
|
||||
for i in range(0):
|
||||
api_name = f'pub-grpc-{ts}-{i}'
|
||||
@@ -248,7 +304,15 @@ message DeleteReply { bool ok = 1; }
|
||||
(tmp / 'svc.proto').write_text(PROTO.replace('{pkg}', pkg))
|
||||
out = tmp / 'gen'
|
||||
out.mkdir()
|
||||
code = protoc.main(['protoc', f'--proto_path={td}', f'--python_out={out}', f'--grpc_python_out={out}', str(tmp / 'svc.proto')])
|
||||
code = protoc.main(
|
||||
[
|
||||
'protoc',
|
||||
f'--proto_path={td}',
|
||||
f'--python_out={out}',
|
||||
f'--grpc_python_out={out}',
|
||||
str(tmp / 'svc.proto'),
|
||||
]
|
||||
)
|
||||
assert code == 0
|
||||
(out / '__init__.py').write_text('')
|
||||
sys.path.insert(0, str(out))
|
||||
@@ -258,35 +322,51 @@ message DeleteReply { bool ok = 1; }
|
||||
class Resource(pb2_grpc.ResourceServicer):
|
||||
def Create(self, request, context):
|
||||
return pb2.CreateReply(message=f'created {request.name}')
|
||||
|
||||
def Read(self, request, context):
|
||||
return pb2.ReadReply(message=f'read {request.id}')
|
||||
|
||||
def Update(self, request, context):
|
||||
return pb2.UpdateReply(message=f'updated {request.id}:{request.name}')
|
||||
|
||||
def Delete(self, request, context):
|
||||
return pb2.DeleteReply(ok=True)
|
||||
|
||||
server = grpc.server(futures.ThreadPoolExecutor(max_workers=2))
|
||||
pb2_grpc.add_ResourceServicer_to_server(Resource(), server)
|
||||
s = socket.socket(); s.bind(('127.0.0.1', 0)); port = s.getsockname()[1]; s.close()
|
||||
s = socket.socket()
|
||||
s.bind(('127.0.0.1', 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
server.add_insecure_port(f'127.0.0.1:{port}')
|
||||
server.start()
|
||||
try:
|
||||
r = client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'public grpc',
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': [],
|
||||
'api_servers': [f'grpc://127.0.0.1:{port}'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_public': True
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'public grpc',
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': [],
|
||||
'api_servers': [f'grpc://127.0.0.1:{port}'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_public': True,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
r = client.post('/platform/endpoint', json={'api_name': api_name, 'api_version': api_version, 'endpoint_method': 'POST', 'endpoint_uri': '/grpc', 'endpoint_description': 'grpc'})
|
||||
r = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/grpc',
|
||||
'endpoint_description': 'grpc',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
url = f"{base}/api/grpc/{api_name}"
|
||||
hdr = {'X-API-Version': api_version}
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
@@ -299,4 +379,5 @@ message DeleteReply { bool ok = 1; }
|
||||
pass
|
||||
server.stop(0)
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.gateway]
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import time
|
||||
import socket
|
||||
import requests
|
||||
import pytest
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from config import ENABLE_GRPC
|
||||
|
||||
|
||||
def _find_port() -> int:
|
||||
s = socket.socket()
|
||||
s.bind(('0.0.0.0', 0))
|
||||
@@ -12,6 +13,7 @@ def _find_port() -> int:
|
||||
s.close()
|
||||
return p
|
||||
|
||||
|
||||
def _get_host_from_container() -> str:
|
||||
"""Get the hostname to use when referring to the host machine from a Docker container.
|
||||
|
||||
@@ -39,17 +41,22 @@ def _get_host_from_container() -> str:
|
||||
# This is the most common development setup
|
||||
return '127.0.0.1'
|
||||
|
||||
|
||||
@pytest.mark.skipif(not ENABLE_GRPC, reason='gRPC disabled')
|
||||
def test_public_grpc_with_proto_upload(client):
|
||||
try:
|
||||
import importlib
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
from concurrent import futures
|
||||
|
||||
import grpc
|
||||
from grpc_tools import protoc
|
||||
from concurrent import futures
|
||||
import tempfile, pathlib, importlib, sys
|
||||
except Exception as e:
|
||||
pytest.skip(f'Missing gRPC deps: {e}')
|
||||
|
||||
PROTO = '''
|
||||
PROTO = """
|
||||
syntax = "proto3";
|
||||
package {pkg};
|
||||
service Resource {
|
||||
@@ -66,7 +73,7 @@ message UpdateRequest { int32 id = 1; string name = 2; }
|
||||
message UpdateReply { string message = 1; }
|
||||
message DeleteRequest { int32 id = 1; }
|
||||
message DeleteReply { bool ok = 1; }
|
||||
'''
|
||||
"""
|
||||
|
||||
base = client.base_url.rstrip('/')
|
||||
ts = int(time.time())
|
||||
@@ -79,7 +86,15 @@ message DeleteReply { bool ok = 1; }
|
||||
(tmp / 'svc.proto').write_text(PROTO.replace('{pkg}', pkg))
|
||||
out = tmp / 'gen'
|
||||
out.mkdir()
|
||||
code = protoc.main(['protoc', f'--proto_path={td}', f'--python_out={out}', f'--grpc_python_out={out}', str(tmp / 'svc.proto')])
|
||||
code = protoc.main(
|
||||
[
|
||||
'protoc',
|
||||
f'--proto_path={td}',
|
||||
f'--python_out={out}',
|
||||
f'--grpc_python_out={out}',
|
||||
str(tmp / 'svc.proto'),
|
||||
]
|
||||
)
|
||||
assert code == 0
|
||||
(out / '__init__.py').write_text('')
|
||||
sys.path.insert(0, str(out))
|
||||
@@ -112,35 +127,63 @@ message DeleteReply { bool ok = 1; }
|
||||
r_up = client.post(f'/platform/proto/{api_name}/{api_version}', files=files)
|
||||
assert r_up.status_code in (200, 201), r_up.text
|
||||
|
||||
r_api = client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'public grpc with uploaded proto',
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': [],
|
||||
'api_servers': [f'grpc://{host_ref}:{port}'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_public': True,
|
||||
'api_grpc_package': pkg
|
||||
})
|
||||
r_api = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'public grpc with uploaded proto',
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': [],
|
||||
'api_servers': [f'grpc://{host_ref}:{port}'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_public': True,
|
||||
'api_grpc_package': pkg,
|
||||
},
|
||||
)
|
||||
assert r_api.status_code in (200, 201), r_api.text
|
||||
|
||||
r_ep = client.post('/platform/endpoint', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/grpc',
|
||||
'endpoint_description': 'grpc'
|
||||
})
|
||||
r_ep = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/grpc',
|
||||
'endpoint_description': 'grpc',
|
||||
},
|
||||
)
|
||||
assert r_ep.status_code in (200, 201), r_ep.text
|
||||
|
||||
url = f"{base}/api/grpc/{api_name}"
|
||||
url = f'{base}/api/grpc/{api_name}'
|
||||
hdr = {'X-API-Version': api_version}
|
||||
assert requests.post(url, json={'method': 'Resource.Create', 'message': {'name': 'A'}}, headers=hdr).status_code == 200
|
||||
assert requests.post(url, json={'method': 'Resource.Read', 'message': {'id': 1}}, headers=hdr).status_code == 200
|
||||
assert requests.post(url, json={'method': 'Resource.Update', 'message': {'id': 1, 'name': 'B'}}, headers=hdr).status_code == 200
|
||||
assert requests.post(url, json={'method': 'Resource.Delete', 'message': {'id': 1}}, headers=hdr).status_code == 200
|
||||
assert (
|
||||
requests.post(
|
||||
url, json={'method': 'Resource.Create', 'message': {'name': 'A'}}, headers=hdr
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert (
|
||||
requests.post(
|
||||
url, json={'method': 'Resource.Read', 'message': {'id': 1}}, headers=hdr
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert (
|
||||
requests.post(
|
||||
url,
|
||||
json={'method': 'Resource.Update', 'message': {'id': 1, 'name': 'B'}},
|
||||
headers=hdr,
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert (
|
||||
requests.post(
|
||||
url, json={'method': 'Resource.Delete', 'message': {'id': 1}}, headers=hdr
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
client.delete(f'/platform/endpoint/POST/{api_name}/{api_version}/grpc')
|
||||
@@ -151,4 +194,3 @@ message DeleteReply { bool ok = 1; }
|
||||
except Exception:
|
||||
pass
|
||||
server.stop(0)
|
||||
|
||||
|
||||
@@ -1,35 +1,46 @@
|
||||
import time
|
||||
|
||||
from servers import start_soap_echo_server
|
||||
|
||||
|
||||
def test_soap_gateway_basic_flow(client):
|
||||
srv = start_soap_echo_server()
|
||||
try:
|
||||
api_name = f'soap-demo-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
|
||||
r = client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'SOAP demo',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
'active': True
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'SOAP demo',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
r = client.post('/platform/endpoint', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/soap',
|
||||
'endpoint_description': 'soap'
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/soap',
|
||||
'endpoint_description': 'soap',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
r = client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
r = client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
body = """
|
||||
@@ -40,7 +51,11 @@ def test_soap_gateway_basic_flow(client):
|
||||
</soap:Body>
|
||||
</soap:Envelope>
|
||||
""".strip()
|
||||
r = client.post(f'/api/soap/{api_name}/{api_version}/soap', data=body, headers={'Content-Type': 'text/xml'})
|
||||
r = client.post(
|
||||
f'/api/soap/{api_name}/{api_version}/soap',
|
||||
data=body,
|
||||
headers={'Content-Type': 'text/xml'},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
finally:
|
||||
try:
|
||||
@@ -52,5 +67,8 @@ def test_soap_gateway_basic_flow(client):
|
||||
except Exception:
|
||||
pass
|
||||
srv.stop()
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.soap, pytest.mark.gateway]
|
||||
|
||||
@@ -1,40 +1,54 @@
|
||||
import time
|
||||
|
||||
from servers import start_soap_echo_server
|
||||
|
||||
|
||||
def test_soap_validation_blocks_missing_field(client):
|
||||
srv = start_soap_echo_server()
|
||||
try:
|
||||
api_name = f'soapval-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'soap val',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True
|
||||
})
|
||||
client.post('/platform/endpoint', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/soap',
|
||||
'endpoint_description': 'soap'
|
||||
})
|
||||
client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'soap val',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/soap',
|
||||
'endpoint_description': 'soap',
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
|
||||
r = client.get(f'/platform/endpoint/POST/{api_name}/{api_version}/soap')
|
||||
ep = r.json().get('response', r.json()); endpoint_id = ep.get('endpoint_id'); assert endpoint_id
|
||||
schema = {
|
||||
'validation_schema': {
|
||||
'message': { 'required': True, 'type': 'string', 'min': 2 }
|
||||
}
|
||||
}
|
||||
r = client.post('/platform/endpoint/endpoint/validation', json={
|
||||
'endpoint_id': endpoint_id, 'validation_enabled': True, 'validation_schema': schema
|
||||
})
|
||||
ep = r.json().get('response', r.json())
|
||||
endpoint_id = ep.get('endpoint_id')
|
||||
assert endpoint_id
|
||||
schema = {'validation_schema': {'message': {'required': True, 'type': 'string', 'min': 2}}}
|
||||
r = client.post(
|
||||
'/platform/endpoint/endpoint/validation',
|
||||
json={
|
||||
'endpoint_id': endpoint_id,
|
||||
'validation_enabled': True,
|
||||
'validation_schema': schema,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
xml = """
|
||||
@@ -45,11 +59,19 @@ def test_soap_validation_blocks_missing_field(client):
|
||||
</soap:Body>
|
||||
</soap:Envelope>
|
||||
""".strip()
|
||||
r = client.post(f'/api/soap/{api_name}/{api_version}/soap', data=xml, headers={'Content-Type': 'text/xml'})
|
||||
r = client.post(
|
||||
f'/api/soap/{api_name}/{api_version}/soap',
|
||||
data=xml,
|
||||
headers={'Content-Type': 'text/xml'},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
xml2 = xml.replace('>A<', '>AB<')
|
||||
r = client.post(f'/api/soap/{api_name}/{api_version}/soap', data=xml2, headers={'Content-Type': 'text/xml'})
|
||||
r = client.post(
|
||||
f'/api/soap/{api_name}/{api_version}/soap',
|
||||
data=xml2,
|
||||
headers={'Content-Type': 'text/xml'},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
finally:
|
||||
try:
|
||||
|
||||
@@ -1,22 +1,50 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.soap]
|
||||
|
||||
|
||||
def test_soap_cors_preflight(client):
|
||||
api_name = f'soap-pre-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
client.post('/platform/api', json={
|
||||
'api_name': api_name, 'api_version': api_version, 'api_description': 'soap pre',
|
||||
'api_allowed_roles': ['admin'], 'api_allowed_groups': ['ALL'], 'api_servers': ['http://127.0.0.1:9'], 'api_type': 'REST', 'active': True,
|
||||
'api_cors_allow_origins': ['http://example.com'], 'api_cors_allow_methods': ['POST'], 'api_cors_allow_headers': ['Content-Type']
|
||||
})
|
||||
client.post('/platform/endpoint', json={
|
||||
'api_name': api_name, 'api_version': api_version, 'endpoint_method': 'POST', 'endpoint_uri': '/soap', 'endpoint_description': 's'
|
||||
})
|
||||
client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'soap pre',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_cors_allow_origins': ['http://example.com'],
|
||||
'api_cors_allow_methods': ['POST'],
|
||||
'api_cors_allow_headers': ['Content-Type'],
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/soap',
|
||||
'endpoint_description': 's',
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
|
||||
r = client.options(f'/api/soap/{api_name}/{api_version}/soap', headers={
|
||||
'Origin': 'http://example.com', 'Access-Control-Request-Method': 'POST', 'Access-Control-Request-Headers': 'Content-Type'
|
||||
})
|
||||
r = client.options(
|
||||
f'/api/soap/{api_name}/{api_version}/soap',
|
||||
headers={
|
||||
'Origin': 'http://example.com',
|
||||
'Access-Control-Request-Method': 'POST',
|
||||
'Access-Control-Request-Headers': 'Content-Type',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 204)
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.rest]
|
||||
|
||||
|
||||
def test_nonexistent_endpoint_returns_gw_error(client):
|
||||
api_name = f'gwerr-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
client.post('/platform/api', json={
|
||||
'api_name': api_name, 'api_version': api_version, 'api_description': 'gw',
|
||||
'api_allowed_roles': ['admin'], 'api_allowed_groups': ['ALL'], 'api_servers': ['http://127.0.0.1:9'], 'api_type': 'REST', 'active': True
|
||||
})
|
||||
client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'gw',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
r = client.get(f'/api/rest/{api_name}/{api_version}/nope')
|
||||
assert r.status_code in (404, 400, 500)
|
||||
data = r.json()
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
import os
|
||||
import time
|
||||
import pytest
|
||||
|
||||
import pytest
|
||||
from config import ENABLE_GRAPHQL
|
||||
|
||||
pytestmark = pytest.mark.skipif(not ENABLE_GRAPHQL, reason='GraphQL test disabled (set DOORMAN_TEST_GRAPHQL=1 to enable)')
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not ENABLE_GRAPHQL, reason='GraphQL test disabled (set DOORMAN_TEST_GRAPHQL=1 to enable)'
|
||||
)
|
||||
|
||||
|
||||
def test_graphql_gateway_basic_flow(client):
|
||||
try:
|
||||
import uvicorn
|
||||
from ariadne import gql, make_executable_schema, QueryType
|
||||
from ariadne import QueryType, gql, make_executable_schema
|
||||
from ariadne.asgi import GraphQL
|
||||
except Exception as e:
|
||||
pytest.skip(f'Missing GraphQL deps: {e}')
|
||||
|
||||
type_defs = gql('''
|
||||
type_defs = gql("""
|
||||
type Query {
|
||||
hello(name: String): String!
|
||||
}
|
||||
''')
|
||||
""")
|
||||
query = QueryType()
|
||||
|
||||
@query.field('hello')
|
||||
def resolve_hello(*_, name=None):
|
||||
return f"Hello, {name or 'world'}!"
|
||||
return f'Hello, {name or "world"}!'
|
||||
|
||||
schema = make_executable_schema(type_defs, query)
|
||||
app = GraphQL(schema, debug=True)
|
||||
|
||||
import threading
|
||||
import socket
|
||||
import threading
|
||||
|
||||
def _find_port():
|
||||
s = socket.socket()
|
||||
@@ -41,6 +44,7 @@ def test_graphql_gateway_basic_flow(client):
|
||||
def _get_host_from_container():
|
||||
"""Get the hostname to use when referring to the host machine from a Docker container."""
|
||||
import platform
|
||||
|
||||
docker_env = os.getenv('DOORMAN_IN_DOCKER', '').lower()
|
||||
if docker_env in ('1', 'true', 'yes'):
|
||||
system = platform.system()
|
||||
@@ -62,29 +66,38 @@ def test_graphql_gateway_basic_flow(client):
|
||||
api_name = f'gql-demo-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
|
||||
r = client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'GraphQL demo',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [f'http://{host}:{port}'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
'active': True
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'GraphQL demo',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [f'http://{host}:{port}'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
r = client.post('/platform/endpoint', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/graphql',
|
||||
'endpoint_description': 'graphql'
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/graphql',
|
||||
'endpoint_description': 'graphql',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
r = client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
r = client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
q = {'query': '{ hello(name:"Doorman") }'}
|
||||
@@ -97,5 +110,8 @@ def test_graphql_gateway_basic_flow(client):
|
||||
|
||||
client.delete(f'/platform/endpoint/POST/{api_name}/{api_version}/graphql')
|
||||
client.delete(f'/platform/api/{api_name}/{api_version}')
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.graphql, pytest.mark.gateway]
|
||||
|
||||
@@ -1,33 +1,47 @@
|
||||
import time
|
||||
import pytest
|
||||
|
||||
import pytest
|
||||
from config import ENABLE_GRAPHQL
|
||||
|
||||
pytestmark = pytest.mark.skipif(not ENABLE_GRAPHQL, reason='GraphQL validation test disabled (set DOORMAN_TEST_GRAPHQL=1)')
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not ENABLE_GRAPHQL, reason='GraphQL validation test disabled (set DOORMAN_TEST_GRAPHQL=1)'
|
||||
)
|
||||
|
||||
|
||||
def test_graphql_validation_blocks_invalid_variables(client):
|
||||
try:
|
||||
import uvicorn
|
||||
from ariadne import gql, make_executable_schema, QueryType
|
||||
from ariadne import QueryType, gql, make_executable_schema
|
||||
from ariadne.asgi import GraphQL
|
||||
except Exception as e:
|
||||
pytest.skip(f'Missing GraphQL deps: {e}')
|
||||
|
||||
type_defs = gql('''
|
||||
type_defs = gql("""
|
||||
type Query { hello(name: String!): String! }
|
||||
''')
|
||||
""")
|
||||
query = QueryType()
|
||||
|
||||
@query.field('hello')
|
||||
def resolve_hello(*_, name):
|
||||
return f"Hello, {name}!"
|
||||
return f'Hello, {name}!'
|
||||
|
||||
schema = make_executable_schema(type_defs, query)
|
||||
import threading, socket, uvicorn, platform
|
||||
import platform
|
||||
import socket
|
||||
import threading
|
||||
|
||||
import uvicorn
|
||||
|
||||
def _free_port():
|
||||
s = socket.socket(); s.bind(('0.0.0.0', 0)); p = s.getsockname()[1]; s.close(); return p
|
||||
s = socket.socket()
|
||||
s.bind(('0.0.0.0', 0))
|
||||
p = s.getsockname()[1]
|
||||
s.close()
|
||||
return p
|
||||
|
||||
def _get_host_from_container():
|
||||
import os
|
||||
|
||||
docker_env = os.getenv('DOORMAN_IN_DOCKER', '').lower()
|
||||
if docker_env in ('1', 'true', 'yes'):
|
||||
system = platform.system()
|
||||
@@ -36,36 +50,56 @@ def test_graphql_validation_blocks_invalid_variables(client):
|
||||
else:
|
||||
return '172.17.0.1'
|
||||
return '127.0.0.1'
|
||||
|
||||
port = _free_port()
|
||||
host = _get_host_from_container()
|
||||
server = uvicorn.Server(uvicorn.Config(GraphQL(schema), host='0.0.0.0', port=port, log_level='warning'))
|
||||
t = threading.Thread(target=server.run, daemon=True); t.start()
|
||||
server = uvicorn.Server(
|
||||
uvicorn.Config(GraphQL(schema), host='0.0.0.0', port=port, log_level='warning')
|
||||
)
|
||||
t = threading.Thread(target=server.run, daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.4)
|
||||
|
||||
api_name = f'gqlval-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
client.post('/platform/api', json={
|
||||
'api_name': api_name, 'api_version': api_version,
|
||||
'api_description': 'gql val', 'api_allowed_roles': ['admin'], 'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [f'http://{host}:{port}'], 'api_type': 'REST', 'active': True
|
||||
})
|
||||
client.post('/platform/endpoint', json={
|
||||
'api_name': api_name, 'api_version': api_version,
|
||||
'endpoint_method': 'POST','endpoint_uri': '/graphql','endpoint_description': 'gql'
|
||||
})
|
||||
client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'gql val',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [f'http://{host}:{port}'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/graphql',
|
||||
'endpoint_description': 'gql',
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
|
||||
r = client.get(f'/platform/endpoint/POST/{api_name}/{api_version}/graphql')
|
||||
ep = r.json().get('response', r.json()); endpoint_id = ep.get('endpoint_id'); assert endpoint_id
|
||||
ep = r.json().get('response', r.json())
|
||||
endpoint_id = ep.get('endpoint_id')
|
||||
assert endpoint_id
|
||||
|
||||
schema = {
|
||||
'validation_schema': {
|
||||
'HelloOp.x': { 'required': True, 'type': 'string', 'min': 2 }
|
||||
}
|
||||
}
|
||||
r = client.post('/platform/endpoint/endpoint/validation', json={
|
||||
'endpoint_id': endpoint_id, 'validation_enabled': True, 'validation_schema': schema
|
||||
})
|
||||
schema = {'validation_schema': {'HelloOp.x': {'required': True, 'type': 'string', 'min': 2}}}
|
||||
r = client.post(
|
||||
'/platform/endpoint/endpoint/validation',
|
||||
json={'endpoint_id': endpoint_id, 'validation_enabled': True, 'validation_schema': schema},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
q = {'query': 'query HelloOp($x: String!) { hello(name: $x) }', 'variables': {'x': 'A'}}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from config import ENABLE_GRPC
|
||||
|
||||
pytestmark = [pytest.mark.grpc]
|
||||
|
||||
|
||||
def test_grpc_invalid_method_returns_error(client):
|
||||
if not ENABLE_GRPC:
|
||||
pytest.skip('gRPC disabled')
|
||||
try:
|
||||
import grpc_tools
|
||||
pass
|
||||
except Exception as e:
|
||||
pytest.skip(f'Missing gRPC deps: {e}')
|
||||
|
||||
@@ -19,15 +21,41 @@ syntax = "proto3";
|
||||
package {pkg};
|
||||
service Greeter {}
|
||||
""".replace('{pkg}', f'{api_name}_{api_version}')
|
||||
r = client.post(f'/platform/proto/{api_name}/{api_version}', files={'file': ('s.proto', proto.encode('utf-8'))})
|
||||
r = client.post(
|
||||
f'/platform/proto/{api_name}/{api_version}',
|
||||
files={'file': ('s.proto', proto.encode('utf-8'))},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
client.post('/platform/api', json={
|
||||
'api_name': api_name, 'api_version': api_version, 'api_description': 'g', 'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'], 'api_servers': ['grpc://127.0.0.1:9'], 'api_type': 'REST', 'active': True
|
||||
})
|
||||
client.post('/platform/endpoint', json={
|
||||
'api_name': api_name, 'api_version': api_version, 'endpoint_method': 'POST', 'endpoint_uri': '/grpc', 'endpoint_description': 'g'
|
||||
})
|
||||
client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
r = client.post(f'/api/grpc/{api_name}', json={'method': 'Nope.Do', 'message': {}}, headers={'X-API-Version': api_version})
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'g',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['grpc://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/grpc',
|
||||
'endpoint_description': 'g',
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
r = client.post(
|
||||
f'/api/grpc/{api_name}',
|
||||
json={'method': 'Nope.Do', 'message': {}},
|
||||
headers={'X-API-Version': api_version},
|
||||
)
|
||||
assert r.status_code in (404, 500)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
import pytest
|
||||
|
||||
import pytest
|
||||
from config import ENABLE_GRPC
|
||||
|
||||
pytestmark = pytest.mark.skipif(not ENABLE_GRPC, reason='gRPC test disabled (set DOORMAN_TEST_GRPC=1 to enable)')
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not ENABLE_GRPC, reason='gRPC test disabled (set DOORMAN_TEST_GRPC=1 to enable)'
|
||||
)
|
||||
|
||||
PROTO_TEMPLATE = """
|
||||
syntax = "proto3";
|
||||
@@ -25,22 +25,23 @@ message HelloReply {
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _start_grpc_server(port: int):
|
||||
import grpc
|
||||
from concurrent import futures
|
||||
|
||||
import grpc
|
||||
|
||||
class GreeterServicer:
|
||||
def Hello(self, request, context):
|
||||
from google.protobuf import struct_pb2
|
||||
pass
|
||||
|
||||
server = grpc.server(futures.ThreadPoolExecutor(max_workers=2))
|
||||
return server
|
||||
|
||||
|
||||
def test_grpc_gateway_basic_flow(client):
|
||||
try:
|
||||
import grpc
|
||||
import grpc_tools
|
||||
except Exception as e:
|
||||
pytest.skip(f'Missing gRPC deps: {e}')
|
||||
|
||||
@@ -54,26 +55,38 @@ def test_grpc_gateway_basic_flow(client):
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
try:
|
||||
import importlib
|
||||
import pathlib
|
||||
import tempfile
|
||||
|
||||
from grpc_tools import protoc
|
||||
import tempfile, pathlib, importlib
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
tmp = pathlib.Path(td)
|
||||
(tmp / 'svc.proto').write_text(proto)
|
||||
out = tmp / 'gen'
|
||||
out.mkdir()
|
||||
code = protoc.main([
|
||||
'protoc', f'--proto_path={td}', f'--python_out={out}', f'--grpc_python_out={out}', str(tmp / 'svc.proto')
|
||||
])
|
||||
code = protoc.main(
|
||||
[
|
||||
'protoc',
|
||||
f'--proto_path={td}',
|
||||
f'--python_out={out}',
|
||||
f'--grpc_python_out={out}',
|
||||
str(tmp / 'svc.proto'),
|
||||
]
|
||||
)
|
||||
assert code == 0
|
||||
(out / '__init__.py').write_text('')
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(out))
|
||||
pb2 = importlib.import_module('svc_pb2')
|
||||
pb2_grpc = importlib.import_module('svc_pb2_grpc')
|
||||
|
||||
import grpc
|
||||
from concurrent import futures
|
||||
|
||||
import grpc
|
||||
|
||||
class Greeter(pb2_grpc.GreeterServicer):
|
||||
def Hello(self, request, context):
|
||||
return pb2.HelloReply(message=f'Hello, {request.name}!')
|
||||
@@ -81,41 +94,53 @@ def test_grpc_gateway_basic_flow(client):
|
||||
server = grpc.server(futures.ThreadPoolExecutor(max_workers=2))
|
||||
pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
|
||||
import socket
|
||||
s = socket.socket(); s.bind(('127.0.0.1', 0)); port = s.getsockname()[1]; s.close()
|
||||
|
||||
s = socket.socket()
|
||||
s.bind(('127.0.0.1', 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
server.add_insecure_port(f'127.0.0.1:{port}')
|
||||
server.start()
|
||||
|
||||
try:
|
||||
r = client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'gRPC demo',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [f'grpc://127.0.0.1:{port}'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
'active': True
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'gRPC demo',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [f'grpc://127.0.0.1:{port}'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
r = client.post('/platform/endpoint', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/grpc',
|
||||
'endpoint_description': 'grpc'
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/grpc',
|
||||
'endpoint_description': 'grpc',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
r = client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
r = client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
body = {
|
||||
'method': 'Greeter.Hello',
|
||||
'message': {'name': 'Doorman'}
|
||||
}
|
||||
r = client.post(f'/api/grpc/{api_name}', json=body, headers={'X-API-Version': api_version})
|
||||
body = {'method': 'Greeter.Hello', 'message': {'name': 'Doorman'}}
|
||||
r = client.post(
|
||||
f'/api/grpc/{api_name}', json=body, headers={'X-API-Version': api_version}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json().get('response', r.json())
|
||||
assert data.get('message') == 'Hello, Doorman!'
|
||||
@@ -131,5 +156,8 @@ def test_grpc_gateway_basic_flow(client):
|
||||
server.stop(0)
|
||||
except Exception as e:
|
||||
pytest.skip(f'Skipping gRPC due to setup failure: {e}')
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.grpc, pytest.mark.gateway]
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
import pytest
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from utils.ip_policy_util import _ip_in_list, _get_client_ip, enforce_api_ip_policy
|
||||
import pytest
|
||||
|
||||
from utils.ip_policy_util import _get_client_ip, _ip_in_list, enforce_api_ip_policy
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope='session')
|
||||
def ensure_session_and_relaxed_limits():
|
||||
yield
|
||||
|
||||
|
||||
def make_request(host: str | None = None, headers: dict | None = None):
|
||||
client = SimpleNamespace(host=host, port=None)
|
||||
return SimpleNamespace(client=client, headers=headers or {}, url=SimpleNamespace(path='/'))
|
||||
|
||||
|
||||
def test_ip_in_list_ipv4_exact_and_cidr():
|
||||
assert _ip_in_list('192.168.1.10', ['192.168.1.10'])
|
||||
assert _ip_in_list('10.1.2.3', ['10.0.0.0/8'])
|
||||
assert not _ip_in_list('11.1.2.3', ['10.0.0.0/8'])
|
||||
|
||||
|
||||
def test_ip_in_list_ipv6_exact_and_cidr():
|
||||
assert _ip_in_list('2001:db8::1', ['2001:db8::1'])
|
||||
assert _ip_in_list('2001:db8::abcd', ['2001:db8::/32'])
|
||||
assert not _ip_in_list('2001:db9::1', ['2001:db8::/32'])
|
||||
|
||||
|
||||
def test_get_client_ip_trusted_proxy(monkeypatch):
|
||||
monkeypatch.setattr('utils.ip_policy_util.get_cached_settings', lambda: {
|
||||
'xff_trusted_proxies': ['10.0.0.0/8']
|
||||
})
|
||||
monkeypatch.setattr(
|
||||
'utils.ip_policy_util.get_cached_settings', lambda: {'xff_trusted_proxies': ['10.0.0.0/8']}
|
||||
)
|
||||
|
||||
req1 = make_request('10.1.2.3', {'X-Forwarded-For': '1.2.3.4, 10.1.2.3'})
|
||||
assert _get_client_ip(req1, True) == '1.2.3.4'
|
||||
@@ -33,17 +38,21 @@ def test_get_client_ip_trusted_proxy(monkeypatch):
|
||||
req2 = make_request('8.8.8.8', {'X-Forwarded-For': '1.2.3.4'})
|
||||
assert _get_client_ip(req2, True) == '8.8.8.8'
|
||||
|
||||
|
||||
def test_enforce_api_policy_never_blocks_localhost(monkeypatch):
|
||||
monkeypatch.setattr('utils.ip_policy_util.get_cached_settings', lambda: {
|
||||
'trust_x_forwarded_for': False,
|
||||
'xff_trusted_proxies': [],
|
||||
'allow_localhost_bypass': True,
|
||||
})
|
||||
monkeypatch.setattr(
|
||||
'utils.ip_policy_util.get_cached_settings',
|
||||
lambda: {
|
||||
'trust_x_forwarded_for': False,
|
||||
'xff_trusted_proxies': [],
|
||||
'allow_localhost_bypass': True,
|
||||
},
|
||||
)
|
||||
|
||||
api = {
|
||||
'api_ip_mode': 'whitelist',
|
||||
'api_ip_whitelist': ['203.0.113.0/24'],
|
||||
'api_ip_blacklist': ['0.0.0.0/0']
|
||||
'api_ip_blacklist': ['0.0.0.0/0'],
|
||||
}
|
||||
|
||||
req_local_v4 = make_request('127.0.0.1', {})
|
||||
@@ -52,38 +61,46 @@ def test_enforce_api_policy_never_blocks_localhost(monkeypatch):
|
||||
req_local_v6 = make_request('::1', {})
|
||||
enforce_api_ip_policy(req_local_v6, api)
|
||||
|
||||
|
||||
def test_get_client_ip_secure_default_no_trust_when_empty_list(monkeypatch):
|
||||
monkeypatch.setattr('utils.ip_policy_util.get_cached_settings', lambda: {
|
||||
'trust_x_forwarded_for': True,
|
||||
'xff_trusted_proxies': []
|
||||
})
|
||||
monkeypatch.setattr(
|
||||
'utils.ip_policy_util.get_cached_settings',
|
||||
lambda: {'trust_x_forwarded_for': True, 'xff_trusted_proxies': []},
|
||||
)
|
||||
req = make_request('10.0.0.5', {'X-Forwarded-For': '203.0.113.9'})
|
||||
assert _get_client_ip(req, True) == '10.0.0.5'
|
||||
|
||||
|
||||
def test_get_client_ip_x_real_ip_and_cf_connecting(monkeypatch):
|
||||
monkeypatch.setattr('utils.ip_policy_util.get_cached_settings', lambda: {
|
||||
'trust_x_forwarded_for': True,
|
||||
'xff_trusted_proxies': ['10.0.0.0/8']
|
||||
})
|
||||
monkeypatch.setattr(
|
||||
'utils.ip_policy_util.get_cached_settings',
|
||||
lambda: {'trust_x_forwarded_for': True, 'xff_trusted_proxies': ['10.0.0.0/8']},
|
||||
)
|
||||
req1 = make_request('10.2.3.4', {'X-Real-IP': '198.51.100.7'})
|
||||
assert _get_client_ip(req1, True) == '198.51.100.7'
|
||||
req2 = make_request('10.2.3.4', {'CF-Connecting-IP': '2001:db8::2'})
|
||||
assert _get_client_ip(req2, True) == '2001:db8::2'
|
||||
|
||||
|
||||
def test_get_client_ip_ignores_headers_when_trust_disabled(monkeypatch):
|
||||
monkeypatch.setattr('utils.ip_policy_util.get_cached_settings', lambda: {
|
||||
'trust_x_forwarded_for': False,
|
||||
'xff_trusted_proxies': ['10.0.0.0/8']
|
||||
})
|
||||
monkeypatch.setattr(
|
||||
'utils.ip_policy_util.get_cached_settings',
|
||||
lambda: {'trust_x_forwarded_for': False, 'xff_trusted_proxies': ['10.0.0.0/8']},
|
||||
)
|
||||
req = make_request('10.2.3.4', {'X-Forwarded-For': '198.51.100.7'})
|
||||
assert _get_client_ip(req, False) == '10.2.3.4'
|
||||
|
||||
|
||||
def test_enforce_api_policy_whitelist_and_blacklist(monkeypatch):
|
||||
monkeypatch.setattr('utils.ip_policy_util.get_cached_settings', lambda: {
|
||||
'trust_x_forwarded_for': False,
|
||||
'xff_trusted_proxies': []
|
||||
})
|
||||
api = {'api_ip_mode': 'whitelist', 'api_ip_whitelist': ['203.0.113.0/24'], 'api_ip_blacklist': []}
|
||||
monkeypatch.setattr(
|
||||
'utils.ip_policy_util.get_cached_settings',
|
||||
lambda: {'trust_x_forwarded_for': False, 'xff_trusted_proxies': []},
|
||||
)
|
||||
api = {
|
||||
'api_ip_mode': 'whitelist',
|
||||
'api_ip_whitelist': ['203.0.113.0/24'],
|
||||
'api_ip_blacklist': [],
|
||||
}
|
||||
req = make_request('198.51.100.10', {})
|
||||
raised = False
|
||||
try:
|
||||
@@ -92,7 +109,11 @@ def test_enforce_api_policy_whitelist_and_blacklist(monkeypatch):
|
||||
raised = True
|
||||
assert raised
|
||||
|
||||
api2 = {'api_ip_mode': 'allow_all', 'api_ip_whitelist': [], 'api_ip_blacklist': ['198.51.100.0/24']}
|
||||
api2 = {
|
||||
'api_ip_mode': 'allow_all',
|
||||
'api_ip_whitelist': [],
|
||||
'api_ip_blacklist': ['198.51.100.0/24'],
|
||||
}
|
||||
req2 = make_request('198.51.100.10', {})
|
||||
raised2 = False
|
||||
try:
|
||||
@@ -101,12 +122,16 @@ def test_enforce_api_policy_whitelist_and_blacklist(monkeypatch):
|
||||
raised2 = True
|
||||
assert raised2
|
||||
|
||||
|
||||
def test_localhost_bypass_requires_no_forwarding_headers(monkeypatch):
|
||||
monkeypatch.setattr('utils.ip_policy_util.get_cached_settings', lambda: {
|
||||
'allow_localhost_bypass': True,
|
||||
'trust_x_forwarded_for': False,
|
||||
'xff_trusted_proxies': []
|
||||
})
|
||||
monkeypatch.setattr(
|
||||
'utils.ip_policy_util.get_cached_settings',
|
||||
lambda: {
|
||||
'allow_localhost_bypass': True,
|
||||
'trust_x_forwarded_for': False,
|
||||
'xff_trusted_proxies': [],
|
||||
},
|
||||
)
|
||||
api = {'api_ip_mode': 'whitelist', 'api_ip_whitelist': ['203.0.113.0/24']}
|
||||
req = make_request('::1', {'X-Forwarded-For': '1.2.3.4'})
|
||||
raised = False
|
||||
@@ -116,13 +141,17 @@ def test_localhost_bypass_requires_no_forwarding_headers(monkeypatch):
|
||||
raised = True
|
||||
assert raised, 'Expected enforcement when forwarding header present'
|
||||
|
||||
|
||||
def test_env_overrides_localhost_bypass(monkeypatch):
|
||||
monkeypatch.setenv('LOCAL_HOST_IP_BYPASS', 'true')
|
||||
monkeypatch.setattr('utils.ip_policy_util.get_cached_settings', lambda: {
|
||||
'allow_localhost_bypass': False,
|
||||
'trust_x_forwarded_for': False,
|
||||
'xff_trusted_proxies': []
|
||||
})
|
||||
monkeypatch.setattr(
|
||||
'utils.ip_policy_util.get_cached_settings',
|
||||
lambda: {
|
||||
'allow_localhost_bypass': False,
|
||||
'trust_x_forwarded_for': False,
|
||||
'xff_trusted_proxies': [],
|
||||
},
|
||||
)
|
||||
api = {'api_ip_mode': 'whitelist', 'api_ip_whitelist': ['203.0.113.0/24']}
|
||||
req = make_request('127.0.0.1', {})
|
||||
enforce_api_ip_policy(req, api)
|
||||
|
||||
@@ -1,41 +1,74 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from config import ENABLE_GRAPHQL
|
||||
|
||||
pytestmark = [pytest.mark.graphql]
|
||||
|
||||
|
||||
def test_graphql_missing_version_header_returns_400(client):
|
||||
if not ENABLE_GRAPHQL:
|
||||
pytest.skip('GraphQL disabled')
|
||||
try:
|
||||
from ariadne import gql, make_executable_schema, QueryType
|
||||
from ariadne.asgi import GraphQL
|
||||
import uvicorn
|
||||
from ariadne import QueryType, gql, make_executable_schema
|
||||
from ariadne.asgi import GraphQL
|
||||
except Exception as e:
|
||||
pytest.skip(f'Missing deps: {e}')
|
||||
|
||||
type_defs = gql('type Query { ok: String! }')
|
||||
query = QueryType()
|
||||
|
||||
@query.field('ok')
|
||||
def resolve_ok(*_):
|
||||
return 'ok'
|
||||
|
||||
schema = make_executable_schema(type_defs, query)
|
||||
import threading, socket
|
||||
s = socket.socket(); s.bind(('127.0.0.1', 0)); port = s.getsockname()[1]; s.close()
|
||||
server = uvicorn.Server(uvicorn.Config(GraphQL(schema), host='127.0.0.1', port=port, log_level='warning'))
|
||||
t = threading.Thread(target=server.run, daemon=True); t.start()
|
||||
import time as _t; _t.sleep(0.4)
|
||||
import socket
|
||||
import threading
|
||||
|
||||
s = socket.socket()
|
||||
s.bind(('127.0.0.1', 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
server = uvicorn.Server(
|
||||
uvicorn.Config(GraphQL(schema), host='127.0.0.1', port=port, log_level='warning')
|
||||
)
|
||||
t = threading.Thread(target=server.run, daemon=True)
|
||||
t.start()
|
||||
import time as _t
|
||||
|
||||
_t.sleep(0.4)
|
||||
|
||||
api_name = f'gql-novh-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
client.post('/platform/api', json={
|
||||
'api_name': api_name, 'api_version': api_version, 'api_description': 'gql',
|
||||
'api_allowed_roles': ['admin'], 'api_allowed_groups': ['ALL'], 'api_servers': [f'http://127.0.0.1:{port}'], 'api_type': 'REST', 'active': True
|
||||
})
|
||||
client.post('/platform/endpoint', json={
|
||||
'api_name': api_name, 'api_version': api_version, 'endpoint_method': 'POST', 'endpoint_uri': '/graphql', 'endpoint_description': 'gql'
|
||||
})
|
||||
client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'gql',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [f'http://127.0.0.1:{port}'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/graphql',
|
||||
'endpoint_description': 'gql',
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
|
||||
r = client.post(f'/api/graphql/{api_name}', json={'query': '{ ok }'})
|
||||
assert r.status_code == 400
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import time
|
||||
|
||||
from servers import start_rest_echo_server
|
||||
|
||||
|
||||
def test_endpoint_level_servers_override(client):
|
||||
srv_api = start_rest_echo_server()
|
||||
srv_ep = start_rest_echo_server()
|
||||
@@ -8,29 +10,38 @@ def test_endpoint_level_servers_override(client):
|
||||
api_name = f'combo-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
|
||||
r = client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'combo demo',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv_api.url],
|
||||
'api_type': 'REST',
|
||||
'active': True
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'combo demo',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv_api.url],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
r = client.post('/platform/endpoint', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/who',
|
||||
'endpoint_description': 'who am i',
|
||||
'endpoint_servers': [srv_ep.url]
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/who',
|
||||
'endpoint_description': 'who am i',
|
||||
'endpoint_servers': [srv_ep.url],
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
r = client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
r = client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
r = client.get(f'/api/rest/{api_name}/{api_version}/who')
|
||||
@@ -51,5 +62,8 @@ def test_endpoint_level_servers_override(client):
|
||||
pass
|
||||
srv_api.stop()
|
||||
srv_ep.stop()
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.gateway, pytest.mark.routing]
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import time
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
|
||||
|
||||
def _rand_user() -> tuple[str, str, str]:
|
||||
ts = int(time.time())
|
||||
uname = f"usr_{ts}_{random.randint(1000,9999)}"
|
||||
email = f"{uname}@example.com"
|
||||
uname = f'usr_{ts}_{random.randint(1000, 9999)}'
|
||||
email = f'{uname}@example.com'
|
||||
upp = random.choice(string.ascii_uppercase)
|
||||
low = ''.join(random.choices(string.ascii_lowercase, k=8))
|
||||
dig = ''.join(random.choices(string.digits, k=4))
|
||||
@@ -14,40 +15,47 @@ def _rand_user() -> tuple[str, str, str]:
|
||||
pwd = ''.join(random.sample(upp + low + dig + spc + tail, len(upp + low + dig + spc + tail)))
|
||||
return uname, email, pwd
|
||||
|
||||
|
||||
def test_role_permission_blocks_api_management(client):
|
||||
role_name = f"viewer_{int(time.time())}"
|
||||
r = client.post('/platform/role', json={
|
||||
'role_name': role_name,
|
||||
'role_description': 'temporary viewer',
|
||||
'view_logs': True
|
||||
})
|
||||
role_name = f'viewer_{int(time.time())}'
|
||||
r = client.post(
|
||||
'/platform/role',
|
||||
json={'role_name': role_name, 'role_description': 'temporary viewer', 'view_logs': True},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
uname, email, pwd = _rand_user()
|
||||
r = client.post('/platform/user', json={
|
||||
'username': uname,
|
||||
'email': email,
|
||||
'password': pwd,
|
||||
'role': role_name,
|
||||
'groups': ['ALL'],
|
||||
'ui_access': True
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/user',
|
||||
json={
|
||||
'username': uname,
|
||||
'email': email,
|
||||
'password': pwd,
|
||||
'role': role_name,
|
||||
'groups': ['ALL'],
|
||||
'ui_access': True,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
from client import LiveClient
|
||||
|
||||
user_client = LiveClient(client.base_url)
|
||||
user_client.login(email, pwd)
|
||||
|
||||
api_name = f"nope-{int(time.time())}"
|
||||
r = user_client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': 'v1',
|
||||
'api_description': 'should be blocked',
|
||||
'api_allowed_roles': [role_name],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:1'],
|
||||
'api_type': 'REST'
|
||||
})
|
||||
api_name = f'nope-{int(time.time())}'
|
||||
r = user_client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': 'v1',
|
||||
'api_description': 'should be blocked',
|
||||
'api_allowed_roles': [role_name],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:1'],
|
||||
'api_type': 'REST',
|
||||
},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
body = r.json()
|
||||
data = body.get('response', body)
|
||||
@@ -55,5 +63,8 @@ def test_role_permission_blocks_api_management(client):
|
||||
|
||||
client.delete(f'/platform/user/{uname}')
|
||||
client.delete(f'/platform/role/{role_name}')
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.security, pytest.mark.roles]
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import time
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.security]
|
||||
|
||||
|
||||
def _mk_user_payload(role_name: str) -> tuple[str, str, str, dict]:
|
||||
ts = int(time.time())
|
||||
uname = f"min_{ts}_{random.randint(1000,9999)}"
|
||||
email = f"{uname}@example.com"
|
||||
uname = f'min_{ts}_{random.randint(1000, 9999)}'
|
||||
email = f'{uname}@example.com'
|
||||
pwd = 'Strong!Passw0rd1234'
|
||||
payload = {
|
||||
'username': uname,
|
||||
@@ -16,16 +17,14 @@ def _mk_user_payload(role_name: str) -> tuple[str, str, str, dict]:
|
||||
'password': pwd,
|
||||
'role': role_name,
|
||||
'groups': ['ALL'],
|
||||
'ui_access': True
|
||||
'ui_access': True,
|
||||
}
|
||||
return uname, email, pwd, payload
|
||||
|
||||
|
||||
def test_negative_permissions_for_logs_and_config(client):
|
||||
role_name = f"minrole_{int(time.time())}"
|
||||
r = client.post('/platform/role', json={
|
||||
'role_name': role_name,
|
||||
'role_description': 'minimal'
|
||||
})
|
||||
role_name = f'minrole_{int(time.time())}'
|
||||
r = client.post('/platform/role', json={'role_name': role_name, 'role_description': 'minimal'})
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
uname, email, pwd, payload = _mk_user_payload(role_name)
|
||||
@@ -33,6 +32,7 @@ def test_negative_permissions_for_logs_and_config(client):
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
from client import LiveClient
|
||||
|
||||
u = LiveClient(client.base_url)
|
||||
u.login(email, pwd)
|
||||
|
||||
|
||||
@@ -1,69 +1,110 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.security, pytest.mark.roles]
|
||||
|
||||
|
||||
def _mk_user(client, role_name: str):
|
||||
ts = int(time.time())
|
||||
uname = f"perm_{ts}"
|
||||
email = f"{uname}@example.com"
|
||||
uname = f'perm_{ts}'
|
||||
email = f'{uname}@example.com'
|
||||
pwd = 'Strong!Passw0rd1234'
|
||||
r = client.post('/platform/user', json={
|
||||
'username': uname,
|
||||
'email': email,
|
||||
'password': pwd,
|
||||
'role': role_name,
|
||||
'groups': ['ALL'],
|
||||
'ui_access': True,
|
||||
'rate_limit_duration': 1000000,
|
||||
'rate_limit_duration_type': 'second',
|
||||
'throttle_duration': 1000000,
|
||||
'throttle_duration_type': 'second',
|
||||
'throttle_queue_limit': 1000000,
|
||||
'throttle_wait_duration': 0,
|
||||
'throttle_wait_duration_type': 'second'
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/user',
|
||||
json={
|
||||
'username': uname,
|
||||
'email': email,
|
||||
'password': pwd,
|
||||
'role': role_name,
|
||||
'groups': ['ALL'],
|
||||
'ui_access': True,
|
||||
'rate_limit_duration': 1000000,
|
||||
'rate_limit_duration_type': 'second',
|
||||
'throttle_duration': 1000000,
|
||||
'throttle_duration_type': 'second',
|
||||
'throttle_queue_limit': 1000000,
|
||||
'throttle_wait_duration': 0,
|
||||
'throttle_wait_duration_type': 'second',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
return uname, email, pwd
|
||||
|
||||
|
||||
def _login(base_client, email, pwd):
|
||||
from client import LiveClient
|
||||
|
||||
c = LiveClient(base_client.base_url)
|
||||
c.login(email, pwd)
|
||||
return c
|
||||
|
||||
|
||||
def test_permission_matrix_block_then_allow(client):
|
||||
api_name = f'permapi-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
client.post('/platform/api', json={
|
||||
'api_name': api_name, 'api_version': api_version, 'api_description': 'perm',
|
||||
'api_allowed_roles': ['admin'], 'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:9'], 'api_type': 'REST', 'active': True
|
||||
})
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'perm',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
|
||||
def try_manage_apis(c):
|
||||
return c.post('/platform/api', json={
|
||||
'api_name': f'pa-{int(time.time())}', 'api_version': 'v1', 'api_description': 'x',
|
||||
'api_allowed_roles': ['admin'], 'api_allowed_groups': ['ALL'], 'api_servers': ['http://127.0.0.1:9'], 'api_type': 'REST'
|
||||
})
|
||||
return c.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': f'pa-{int(time.time())}',
|
||||
'api_version': 'v1',
|
||||
'api_description': 'x',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
},
|
||||
)
|
||||
|
||||
def try_manage_endpoints(c):
|
||||
return c.post('/platform/endpoint', json={
|
||||
'api_name': api_name, 'api_version': api_version,
|
||||
'endpoint_method': 'GET', 'endpoint_uri': f'/p{int(time.time())}', 'endpoint_description': 'x'
|
||||
})
|
||||
return c.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': f'/p{int(time.time())}',
|
||||
'endpoint_description': 'x',
|
||||
},
|
||||
)
|
||||
|
||||
def try_manage_users(c):
|
||||
return c.post('/platform/user', json={
|
||||
'username': f'u{int(time.time())}', 'email': f'u{int(time.time())}@ex.com',
|
||||
'password': 'Strong!Passw0rd1234', 'role': 'viewer', 'groups': ['ALL'], 'ui_access': False
|
||||
})
|
||||
return c.post(
|
||||
'/platform/user',
|
||||
json={
|
||||
'username': f'u{int(time.time())}',
|
||||
'email': f'u{int(time.time())}@ex.com',
|
||||
'password': 'Strong!Passw0rd1234',
|
||||
'role': 'viewer',
|
||||
'groups': ['ALL'],
|
||||
'ui_access': False,
|
||||
},
|
||||
)
|
||||
|
||||
def try_manage_groups(c):
|
||||
return c.post('/platform/group', json={'group_name': f'g{int(time.time())}', 'group_description': 'x'})
|
||||
return c.post(
|
||||
'/platform/group', json={'group_name': f'g{int(time.time())}', 'group_description': 'x'}
|
||||
)
|
||||
|
||||
def try_manage_roles(c):
|
||||
return c.post('/platform/role', json={'role_name': f'r{int(time.time())}', 'role_description': 'x'})
|
||||
return c.post(
|
||||
'/platform/role', json={'role_name': f'r{int(time.time())}', 'role_description': 'x'}
|
||||
)
|
||||
|
||||
matrix = [
|
||||
('manage_apis', try_manage_apis, 'API007'),
|
||||
@@ -74,19 +115,23 @@ def test_permission_matrix_block_then_allow(client):
|
||||
]
|
||||
|
||||
for perm_field, attempt, expected_code in matrix:
|
||||
role_name = f"role_{perm_field}_{int(time.time())}"
|
||||
r = client.post('/platform/role', json={'role_name': role_name, 'role_description': 'matrix', perm_field: False})
|
||||
role_name = f'role_{perm_field}_{int(time.time())}'
|
||||
r = client.post(
|
||||
'/platform/role',
|
||||
json={'role_name': role_name, 'role_description': 'matrix', perm_field: False},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
uname, email, pwd = _mk_user(client, role_name)
|
||||
uc = _login(client, email, pwd)
|
||||
resp = attempt(uc)
|
||||
assert resp.status_code == 403, f"{perm_field} should be blocked: {resp.text}"
|
||||
data = resp.json(); code = data.get('error_code') or (data.get('response') or {}).get('error_code')
|
||||
assert resp.status_code == 403, f'{perm_field} should be blocked: {resp.text}'
|
||||
data = resp.json()
|
||||
code = data.get('error_code') or (data.get('response') or {}).get('error_code')
|
||||
assert code == expected_code
|
||||
|
||||
client.put(f'/platform/role/{role_name}', json={perm_field: True})
|
||||
resp2 = attempt(uc)
|
||||
assert resp2.status_code != 403, f"{perm_field} still blocked after enable: {resp2.text}"
|
||||
assert resp2.status_code != 403, f'{perm_field} still blocked after enable: {resp2.text}'
|
||||
|
||||
client.delete(f'/platform/user/{uname}')
|
||||
client.delete(f'/platform/role/{role_name}')
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.security, pytest.mark.roles]
|
||||
|
||||
|
||||
def test_roles_groups_crud_and_list(client):
|
||||
role = f"rolex-{int(time.time())}"
|
||||
group = f"groupx-{int(time.time())}"
|
||||
r = client.post('/platform/role', json={'role_name': role, 'role_description': 'x', 'manage_users': True})
|
||||
role = f'rolex-{int(time.time())}'
|
||||
group = f'groupx-{int(time.time())}'
|
||||
r = client.post(
|
||||
'/platform/role', json={'role_name': role, 'role_description': 'x', 'manage_users': True}
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
r = client.post('/platform/group', json={'group_name': group, 'group_description': 'x'})
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
import time
|
||||
|
||||
from servers import start_rest_echo_server
|
||||
|
||||
|
||||
def test_rest_endpoint_validation_blocks_invalid_payload(client):
|
||||
srv = start_rest_echo_server()
|
||||
try:
|
||||
api_name = f'val-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
|
||||
r = client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'validation test',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'validation test',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
r = client.post('/platform/endpoint', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/create',
|
||||
'endpoint_description': 'create'
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/create',
|
||||
'endpoint_description': 'create',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
r = client.get(f'/platform/endpoint/POST/{api_name}/{api_version}/create')
|
||||
@@ -36,17 +44,23 @@ def test_rest_endpoint_validation_blocks_invalid_payload(client):
|
||||
schema = {
|
||||
'validation_schema': {
|
||||
'user.name': {'required': True, 'type': 'string', 'min': 2},
|
||||
'user.age': {'required': True, 'type': 'number', 'min': 1}
|
||||
'user.age': {'required': True, 'type': 'number', 'min': 1},
|
||||
}
|
||||
}
|
||||
r = client.post('/platform/endpoint/endpoint/validation', json={
|
||||
'endpoint_id': endpoint_id,
|
||||
'validation_enabled': True,
|
||||
'validation_schema': schema
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/endpoint/endpoint/validation',
|
||||
json={
|
||||
'endpoint_id': endpoint_id,
|
||||
'validation_enabled': True,
|
||||
'validation_schema': schema,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
r = client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
r = client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
r = client.post(f'/api/rest/{api_name}/{api_version}/create', json={'user': {'name': 'A'}})
|
||||
@@ -55,7 +69,9 @@ def test_rest_endpoint_validation_blocks_invalid_payload(client):
|
||||
err = body.get('error_code') or body.get('response', {}).get('error_code')
|
||||
assert err == 'GTW011' or body.get('error_message')
|
||||
|
||||
r = client.post(f'/api/rest/{api_name}/{api_version}/create', json={'user': {'name': 'Alan', 'age': 33}})
|
||||
r = client.post(
|
||||
f'/api/rest/{api_name}/{api_version}/create', json={'user': {'name': 'Alan', 'age': 33}}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
finally:
|
||||
try:
|
||||
@@ -67,5 +83,8 @@ def test_rest_endpoint_validation_blocks_invalid_payload(client):
|
||||
except Exception:
|
||||
pass
|
||||
srv.stop()
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.validation, pytest.mark.rest]
|
||||
|
||||
@@ -1,61 +1,88 @@
|
||||
import time
|
||||
from servers import start_rest_echo_server
|
||||
|
||||
import pytest
|
||||
from servers import start_rest_echo_server
|
||||
|
||||
pytestmark = [pytest.mark.validation]
|
||||
|
||||
|
||||
def test_nested_array_and_format_validations(client):
|
||||
srv = start_rest_echo_server()
|
||||
try:
|
||||
api_name = f'valedge-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
client.post('/platform/api', json={
|
||||
'api_name': api_name, 'api_version': api_version,
|
||||
'api_description': 'edge validations', 'api_allowed_roles': ['admin'], 'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url], 'api_type': 'REST', 'active': True
|
||||
})
|
||||
client.post('/platform/endpoint', json={
|
||||
'api_name': api_name, 'api_version': api_version,
|
||||
'endpoint_method': 'POST', 'endpoint_uri': '/submit', 'endpoint_description': 'submit'
|
||||
})
|
||||
client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'edge validations',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/submit',
|
||||
'endpoint_description': 'submit',
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
|
||||
r = client.get(f'/platform/endpoint/POST/{api_name}/{api_version}/submit')
|
||||
ep = r.json().get('response', r.json()); endpoint_id = ep.get('endpoint_id'); assert endpoint_id
|
||||
ep = r.json().get('response', r.json())
|
||||
endpoint_id = ep.get('endpoint_id')
|
||||
assert endpoint_id
|
||||
schema = {
|
||||
'validation_schema': {
|
||||
'user.email': {'required': True, 'type': 'string', 'format': 'email'},
|
||||
'items': {
|
||||
'required': True, 'type': 'array', 'min': 1,
|
||||
'required': True,
|
||||
'type': 'array',
|
||||
'min': 1,
|
||||
'array_items': {
|
||||
'type': 'object',
|
||||
'nested_schema': {
|
||||
'id': {'required': True, 'type': 'string', 'format': 'uuid'},
|
||||
'quantity': {'required': True, 'type': 'number', 'min': 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
'quantity': {'required': True, 'type': 'number', 'min': 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
r = client.post('/platform/endpoint/endpoint/validation', json={
|
||||
'endpoint_id': endpoint_id, 'validation_enabled': True, 'validation_schema': schema
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/endpoint/endpoint/validation',
|
||||
json={
|
||||
'endpoint_id': endpoint_id,
|
||||
'validation_enabled': True,
|
||||
'validation_schema': schema,
|
||||
},
|
||||
)
|
||||
if r.status_code == 422:
|
||||
import pytest
|
||||
|
||||
pytest.skip('Validation schema shape not accepted by server (422)')
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
bad = {
|
||||
'user': {'email': 'not-an-email'},
|
||||
'items': [{'id': '123', 'quantity': 0}]
|
||||
}
|
||||
bad = {'user': {'email': 'not-an-email'}, 'items': [{'id': '123', 'quantity': 0}]}
|
||||
r = client.post(f'/api/rest/{api_name}/{api_version}/submit', json=bad)
|
||||
assert r.status_code == 400
|
||||
|
||||
import uuid
|
||||
|
||||
ok = {
|
||||
'user': {'email': 'u@example.com'},
|
||||
'items': [{'id': str(uuid.uuid4()), 'quantity': 2}]
|
||||
'items': [{'id': str(uuid.uuid4()), 'quantity': 2}],
|
||||
}
|
||||
r = client.post(f'/api/rest/{api_name}/{api_version}/submit', json=ok)
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -10,21 +10,29 @@ def test_security_settings_get_put(client):
|
||||
updated = r.json().get('response', r.json())
|
||||
assert bool(updated.get('enable_auto_save') or False) == desired
|
||||
|
||||
|
||||
def test_tools_cors_check(client):
|
||||
r = client.post('/platform/tools/cors/check', json={
|
||||
'origin': 'http://localhost:3000',
|
||||
'method': 'GET',
|
||||
'request_headers': ['Content-Type']
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/tools/cors/check',
|
||||
json={
|
||||
'origin': 'http://localhost:3000',
|
||||
'method': 'GET',
|
||||
'request_headers': ['Content-Type'],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
payload = r.json().get('response', r.json())
|
||||
assert 'config' in payload and 'preflight' in payload
|
||||
|
||||
|
||||
def test_clear_all_caches(client):
|
||||
r = client.delete('/api/caches')
|
||||
assert r.status_code == 200
|
||||
body = r.json().get('response', r.json())
|
||||
assert 'All caches cleared' in (body.get('message') or body.get('error_message') or 'All caches cleared')
|
||||
assert 'All caches cleared' in (
|
||||
body.get('message') or body.get('error_message') or 'All caches cleared'
|
||||
)
|
||||
|
||||
|
||||
def test_logging_endpoints(client):
|
||||
r = client.get('/platform/logging/logs?limit=10')
|
||||
@@ -36,5 +44,8 @@ def test_logging_endpoints(client):
|
||||
assert r.status_code == 200
|
||||
files = r.json().get('response', r.json())
|
||||
assert 'count' in files
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.security, pytest.mark.tools, pytest.mark.logging]
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.security]
|
||||
|
||||
|
||||
def test_memory_dump_restore_conditionally(client):
|
||||
mem_mode = os.environ.get('MEM_OR_EXTERNAL', os.environ.get('MEM_OR_REDIS', 'MEM')).upper() == 'MEM'
|
||||
mem_mode = (
|
||||
os.environ.get('MEM_OR_EXTERNAL', os.environ.get('MEM_OR_REDIS', 'MEM')).upper() == 'MEM'
|
||||
)
|
||||
key = os.environ.get('MEM_ENCRYPTION_KEY')
|
||||
if not mem_mode or not key:
|
||||
pytest.skip('Memory dump/restore only in memory mode with MEM_ENCRYPTION_KEY set')
|
||||
|
||||
@@ -10,24 +10,32 @@ def test_token_refresh_and_invalidate(client):
|
||||
assert r.status_code == 401
|
||||
|
||||
from config import ADMIN_EMAIL, ADMIN_PASSWORD
|
||||
|
||||
client.login(ADMIN_EMAIL, ADMIN_PASSWORD)
|
||||
|
||||
|
||||
def test_admin_revoke_tokens_for_user(client):
|
||||
import time, random, string
|
||||
import random
|
||||
import time
|
||||
|
||||
ts = int(time.time())
|
||||
uname = f'revoke_{ts}_{random.randint(1000,9999)}'
|
||||
uname = f'revoke_{ts}_{random.randint(1000, 9999)}'
|
||||
email = f'{uname}@example.com'
|
||||
pwd = 'Strong!Passw0rd1234'
|
||||
r = client.post('/platform/user', json={
|
||||
'username': uname,
|
||||
'email': email,
|
||||
'password': pwd,
|
||||
'role': 'admin',
|
||||
'groups': ['ALL'],
|
||||
'ui_access': True
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/user',
|
||||
json={
|
||||
'username': uname,
|
||||
'email': email,
|
||||
'password': pwd,
|
||||
'role': 'admin',
|
||||
'groups': ['ALL'],
|
||||
'ui_access': True,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
from client import LiveClient
|
||||
|
||||
user_client = LiveClient(client.base_url)
|
||||
user_client.login(email, pwd)
|
||||
r = client.post(f'/platform/authorization/admin/revoke/{uname}', json={})
|
||||
@@ -35,5 +43,8 @@ def test_admin_revoke_tokens_for_user(client):
|
||||
r = user_client.get('/platform/user/me')
|
||||
assert r.status_code == 401
|
||||
client.delete(f'/platform/user/{uname}')
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.auth]
|
||||
|
||||
@@ -1,31 +1,39 @@
|
||||
import time
|
||||
from servers import start_rest_echo_server
|
||||
|
||||
import requests
|
||||
from servers import start_rest_echo_server
|
||||
|
||||
|
||||
def test_public_api_no_auth_required(client):
|
||||
srv = start_rest_echo_server()
|
||||
try:
|
||||
api_name = f'public-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
r = client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'public',
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': [],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_public': True
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'public',
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': [],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_public': True,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
r = client.post('/platform/endpoint', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/status',
|
||||
'endpoint_description': 'status'
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/status',
|
||||
'endpoint_description': 'status',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
s = requests.Session()
|
||||
url = client.base_url.rstrip('/') + f'/api/rest/{api_name}/{api_version}/status'
|
||||
@@ -42,33 +50,41 @@ def test_public_api_no_auth_required(client):
|
||||
pass
|
||||
srv.stop()
|
||||
|
||||
|
||||
def test_auth_not_required_but_not_public_allows_unauthenticated(client):
|
||||
srv = start_rest_echo_server()
|
||||
try:
|
||||
api_name = f'authopt-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
r = client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'auth optional',
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': [],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_public': False,
|
||||
'api_auth_required': False
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'auth optional',
|
||||
'api_allowed_roles': [],
|
||||
'api_allowed_groups': [],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_public': False,
|
||||
'api_auth_required': False,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
r = client.post('/platform/endpoint', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/ping',
|
||||
'endpoint_description': 'ping'
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/ping',
|
||||
'endpoint_description': 'ping',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
import requests
|
||||
|
||||
s = requests.Session()
|
||||
url = client.base_url.rstrip('/') + f'/api/rest/{api_name}/{api_version}/ping'
|
||||
r = s.get(url)
|
||||
@@ -83,5 +99,8 @@ def test_auth_not_required_but_not_public_allows_unauthenticated(client):
|
||||
except Exception:
|
||||
pass
|
||||
srv.stop()
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.rest, pytest.mark.auth]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import time
|
||||
|
||||
from servers import start_rest_echo_server
|
||||
|
||||
|
||||
def test_client_routing_overrides_api_servers(client):
|
||||
srv_a = start_rest_echo_server()
|
||||
srv_b = start_rest_echo_server()
|
||||
@@ -9,38 +11,52 @@ def test_client_routing_overrides_api_servers(client):
|
||||
api_version = 'v1'
|
||||
client_key = f'ck-{int(time.time())}'
|
||||
|
||||
r = client.post('/platform/routing', json={
|
||||
'routing_name': 'test-routing',
|
||||
'routing_servers': [srv_b.url],
|
||||
'routing_description': 'test',
|
||||
'client_key': client_key,
|
||||
'server_index': 0
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/routing',
|
||||
json={
|
||||
'routing_name': 'test-routing',
|
||||
'routing_servers': [srv_b.url],
|
||||
'routing_description': 'test',
|
||||
'client_key': client_key,
|
||||
'server_index': 0,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
|
||||
r = client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'routing demo',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv_a.url],
|
||||
'api_type': 'REST',
|
||||
'active': True
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'routing demo',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv_a.url],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
r = client.post('/platform/endpoint', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/where',
|
||||
'endpoint_description': 'where'
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/where',
|
||||
'endpoint_description': 'where',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
|
||||
client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
|
||||
r = client.get(f'/api/rest/{api_name}/{api_version}/where', headers={'client-key': client_key})
|
||||
r = client.get(
|
||||
f'/api/rest/{api_name}/{api_version}/where', headers={'client-key': client_key}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json().get('response', r.json())
|
||||
hdrs = {k.lower(): v for k, v in (data.get('headers') or {}).items()}
|
||||
@@ -55,10 +71,12 @@ def test_client_routing_overrides_api_servers(client):
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
client.delete(f"/platform/routing/{client_key}")
|
||||
client.delete(f'/platform/routing/{client_key}')
|
||||
except Exception:
|
||||
pass
|
||||
srv_a.stop(); srv_b.stop()
|
||||
srv_a.stop()
|
||||
srv_b.stop()
|
||||
|
||||
|
||||
def test_authorization_field_swap_sets_auth_header(client):
|
||||
srv = start_rest_echo_server()
|
||||
@@ -67,30 +85,41 @@ def test_authorization_field_swap_sets_auth_header(client):
|
||||
api_version = 'v1'
|
||||
swap_header = 'x-up-auth'
|
||||
token_value = 'Bearer SHHH_TOKEN'
|
||||
r = client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'auth swap',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_allowed_headers': [swap_header],
|
||||
'api_authorization_field_swap': swap_header
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'auth swap',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': [srv.url],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_allowed_headers': [swap_header],
|
||||
'api_authorization_field_swap': swap_header,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
r = client.post('/platform/endpoint', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/secure',
|
||||
'endpoint_description': 'secure'
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/secure',
|
||||
'endpoint_description': 'secure',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
|
||||
r = client.get(f'/api/rest/{api_name}/{api_version}/secure', headers={swap_header: token_value})
|
||||
r = client.get(
|
||||
f'/api/rest/{api_name}/{api_version}/secure', headers={swap_header: token_value}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json().get('response', r.json())
|
||||
hdrs = {k.lower(): v for k, v in (data.get('headers') or {}).items()}
|
||||
@@ -105,5 +134,8 @@ def test_authorization_field_swap_sets_auth_header(client):
|
||||
except Exception:
|
||||
pass
|
||||
srv.stop()
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.routing, pytest.mark.gateway]
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
import time
|
||||
|
||||
|
||||
def test_single_api_export_import_roundtrip(client):
|
||||
api_name = f'cfg-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'cfg demo',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'active': True
|
||||
})
|
||||
client.post('/platform/endpoint', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/x',
|
||||
'endpoint_description': 'x'
|
||||
})
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'cfg demo',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/x',
|
||||
'endpoint_description': 'x',
|
||||
},
|
||||
)
|
||||
|
||||
r = client.get(f'/platform/config/export/apis?api_name={api_name}&api_version={api_version}')
|
||||
assert r.status_code == 200
|
||||
payload = r.json().get('response', r.json())
|
||||
exported_api = payload.get('api'); exported_eps = payload.get('endpoints')
|
||||
exported_api = payload.get('api')
|
||||
exported_eps = payload.get('endpoints')
|
||||
assert exported_api and exported_api.get('api_name') == api_name
|
||||
assert any(ep.get('endpoint_uri') == '/x' for ep in (exported_eps or []))
|
||||
|
||||
@@ -39,5 +47,8 @@ def test_single_api_export_import_roundtrip(client):
|
||||
assert r.status_code == 200
|
||||
r = client.get(f'/platform/endpoint/GET/{api_name}/{api_version}/x')
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.config]
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.order(-10)
|
||||
def test_redis_outage_during_requests(client):
|
||||
r = client.get('/platform/authorization/status')
|
||||
assert r.status_code in (200, 204)
|
||||
|
||||
r = client.post('/platform/tools/chaos/toggle', json={'backend': 'redis', 'enabled': True, 'duration_ms': 1500})
|
||||
r = client.post(
|
||||
'/platform/tools/chaos/toggle',
|
||||
json={'backend': 'redis', 'enabled': True, 'duration_ms': 1500},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
t0 = time.time()
|
||||
@@ -25,13 +30,17 @@ def test_redis_outage_during_requests(client):
|
||||
data = js.get('response', js)
|
||||
assert isinstance(data.get('error_budget_burn'), int)
|
||||
|
||||
|
||||
@pytest.mark.order(-9)
|
||||
def test_mongo_outage_during_requests(client):
|
||||
t0 = time.time()
|
||||
time.time()
|
||||
r0 = client.get('/platform/user/me')
|
||||
assert r0.status_code in (200, 204)
|
||||
|
||||
r = client.post('/platform/tools/chaos/toggle', json={'backend': 'mongo', 'enabled': True, 'duration_ms': 1500})
|
||||
r = client.post(
|
||||
'/platform/tools/chaos/toggle',
|
||||
json={'backend': 'mongo', 'enabled': True, 'duration_ms': 1500},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
t1 = time.time()
|
||||
@@ -49,4 +58,3 @@ def test_mongo_outage_during_requests(client):
|
||||
js = s.json()
|
||||
data = js.get('response', js)
|
||||
assert isinstance(data.get('error_budget_burn'), int)
|
||||
|
||||
|
||||
@@ -7,5 +7,8 @@ def test_config_export_import_roundtrip(client):
|
||||
assert r.status_code == 200
|
||||
data = r.json().get('response', r.json())
|
||||
assert 'imported' in data
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.config]
|
||||
|
||||
@@ -12,5 +12,8 @@ def test_monitor_endpoints(client):
|
||||
assert r.status_code == 200
|
||||
metrics = r.json().get('response', r.json())
|
||||
assert isinstance(metrics, dict)
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.monitor]
|
||||
|
||||
@@ -1,38 +1,51 @@
|
||||
def test_api_cors_preflight_and_response_headers(client):
|
||||
import time
|
||||
|
||||
api_name = f'cors-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
r = client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'cors test',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_cors_allow_origins': ['http://example.com'],
|
||||
'api_cors_allow_methods': ['GET','POST'],
|
||||
'api_cors_allow_headers': ['Content-Type','X-CSRF-Token'],
|
||||
'api_cors_allow_credentials': True
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'cors test',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_cors_allow_origins': ['http://example.com'],
|
||||
'api_cors_allow_methods': ['GET', 'POST'],
|
||||
'api_cors_allow_headers': ['Content-Type', 'X-CSRF-Token'],
|
||||
'api_cors_allow_credentials': True,
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
r = client.post('/platform/endpoint', json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/ok',
|
||||
'endpoint_description': 'ok'
|
||||
})
|
||||
r = client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/ok',
|
||||
'endpoint_description': 'ok',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 201)
|
||||
client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
|
||||
path = f'/api/rest/{api_name}/{api_version}/ok'
|
||||
r = client.options(path, headers={
|
||||
'Origin': 'http://example.com',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
'Access-Control-Request-Headers': 'Content-Type'
|
||||
})
|
||||
r = client.options(
|
||||
path,
|
||||
headers={
|
||||
'Origin': 'http://example.com',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
'Access-Control-Request-Headers': 'Content-Type',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 204)
|
||||
acao = r.headers.get('Access-Control-Allow-Origin')
|
||||
assert acao in (None, 'http://example.com') or True
|
||||
@@ -43,5 +56,8 @@ def test_api_cors_preflight_and_response_headers(client):
|
||||
|
||||
client.delete(f'/platform/endpoint/GET/{api_name}/{api_version}/ok')
|
||||
client.delete(f'/platform/api/{api_name}/{api_version}')
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.cors]
|
||||
|
||||
@@ -1,58 +1,116 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.cors]
|
||||
|
||||
|
||||
def test_cors_wildcard_with_credentials_true_sets_origin(client):
|
||||
api_name = f'corsw-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
client.post('/platform/api', json={
|
||||
'api_name': api_name, 'api_version': api_version, 'api_description': 'cors wild',
|
||||
'api_allowed_roles': ['admin'], 'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:9'], 'api_type': 'REST', 'active': True,
|
||||
'api_cors_allow_origins': ['*'], 'api_cors_allow_methods': ['GET','OPTIONS'], 'api_cors_allow_headers': ['Content-Type'],
|
||||
'api_cors_allow_credentials': True
|
||||
})
|
||||
client.post('/platform/endpoint', json={
|
||||
'api_name': api_name, 'api_version': api_version, 'endpoint_method': 'GET', 'endpoint_uri': '/c', 'endpoint_description': 'c'
|
||||
})
|
||||
client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'cors wild',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_cors_allow_origins': ['*'],
|
||||
'api_cors_allow_methods': ['GET', 'OPTIONS'],
|
||||
'api_cors_allow_headers': ['Content-Type'],
|
||||
'api_cors_allow_credentials': True,
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/c',
|
||||
'endpoint_description': 'c',
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
|
||||
path = f'/api/rest/{api_name}/{api_version}/c'
|
||||
r = client.options(path, headers={
|
||||
'Origin': 'http://foo.example', 'Access-Control-Request-Method': 'GET', 'Access-Control-Request-Headers': 'Content-Type'
|
||||
})
|
||||
r = client.options(
|
||||
path,
|
||||
headers={
|
||||
'Origin': 'http://foo.example',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
'Access-Control-Request-Headers': 'Content-Type',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 204)
|
||||
assert r.headers.get('Access-Control-Allow-Origin') in (None, 'http://foo.example') or True
|
||||
|
||||
client.delete(f'/platform/endpoint/GET/{api_name}/{api_version}/c')
|
||||
client.delete(f'/platform/api/{api_name}/{api_version}')
|
||||
|
||||
|
||||
def test_cors_specific_origin_and_headers(client):
|
||||
api_name = f'corss-{int(time.time())}'
|
||||
api_version = 'v1'
|
||||
client.post('/platform/api', json={
|
||||
'api_name': api_name, 'api_version': api_version, 'api_description': 'cors spec',
|
||||
'api_allowed_roles': ['admin'], 'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:9'], 'api_type': 'REST', 'active': True,
|
||||
'api_cors_allow_origins': ['http://ok.example'], 'api_cors_allow_methods': ['GET','POST','OPTIONS'],
|
||||
'api_cors_allow_headers': ['Content-Type','X-CSRF-Token'], 'api_cors_allow_credentials': False
|
||||
})
|
||||
client.post('/platform/endpoint', json={
|
||||
'api_name': api_name, 'api_version': api_version, 'endpoint_method': 'GET', 'endpoint_uri': '/d', 'endpoint_description': 'd'
|
||||
})
|
||||
client.post('/platform/subscription/subscribe', json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'})
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'api_description': 'cors spec',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'active': True,
|
||||
'api_cors_allow_origins': ['http://ok.example'],
|
||||
'api_cors_allow_methods': ['GET', 'POST', 'OPTIONS'],
|
||||
'api_cors_allow_headers': ['Content-Type', 'X-CSRF-Token'],
|
||||
'api_cors_allow_credentials': False,
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/d',
|
||||
'endpoint_description': 'd',
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'api_name': api_name, 'api_version': api_version, 'username': 'admin'},
|
||||
)
|
||||
|
||||
path = f'/api/rest/{api_name}/{api_version}/d'
|
||||
r = client.options(path, headers={
|
||||
'Origin': 'http://ok.example', 'Access-Control-Request-Method': 'GET', 'Access-Control-Request-Headers': 'X-CSRF-Token'
|
||||
})
|
||||
r = client.options(
|
||||
path,
|
||||
headers={
|
||||
'Origin': 'http://ok.example',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
'Access-Control-Request-Headers': 'X-CSRF-Token',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 204)
|
||||
assert r.headers.get('Access-Control-Allow-Origin') in (None, 'http://ok.example') or True
|
||||
|
||||
r = client.options(path, headers={
|
||||
'Origin': 'http://bad.example', 'Access-Control-Request-Method': 'GET', 'Access-Control-Request-Headers': 'X-CSRF-Token'
|
||||
})
|
||||
r = client.options(
|
||||
path,
|
||||
headers={
|
||||
'Origin': 'http://bad.example',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
'Access-Control-Request-Headers': 'X-CSRF-Token',
|
||||
},
|
||||
)
|
||||
assert r.status_code in (200, 204)
|
||||
|
||||
client.delete(f'/platform/endpoint/GET/{api_name}/{api_version}/d')
|
||||
|
||||
@@ -1,32 +1,59 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
_RUN_LIVE = os.getenv('DOORMAN_RUN_LIVE', '0') in ('1', 'true', 'True')
|
||||
if not _RUN_LIVE:
|
||||
pytestmark = pytest.mark.skip(reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable')
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable'
|
||||
)
|
||||
|
||||
|
||||
def test_api_cors_allow_origins_allow_methods_headers_credentials_expose_live(client):
|
||||
import time
|
||||
|
||||
api_name = f'corslive-{int(time.time())}'
|
||||
ver = 'v1'
|
||||
client.post('/platform/api', json={
|
||||
'api_name': api_name,
|
||||
'api_version': ver,
|
||||
'api_description': 'cors live',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://upstream.example'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
'api_cors_allow_origins': ['http://ok.example'],
|
||||
'api_cors_allow_methods': ['GET','POST'],
|
||||
'api_cors_allow_headers': ['Content-Type','X-CSRF-Token'],
|
||||
'api_cors_allow_credentials': True,
|
||||
'api_cors_expose_headers': ['X-Resp-Id'],
|
||||
})
|
||||
client.post('/platform/endpoint', json={'api_name': api_name, 'api_version': ver, 'endpoint_method': 'GET', 'endpoint_uri': '/q', 'endpoint_description': 'q'})
|
||||
client.post('/platform/subscription/subscribe', json={'username': 'admin', 'api_name': api_name, 'api_version': ver})
|
||||
r = client.options(f'/api/rest/{api_name}/{ver}/q', headers={'Origin': 'http://ok.example', 'Access-Control-Request-Method': 'GET', 'Access-Control-Request-Headers': 'X-CSRF-Token'})
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': ver,
|
||||
'api_description': 'cors live',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://upstream.example'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
'api_cors_allow_origins': ['http://ok.example'],
|
||||
'api_cors_allow_methods': ['GET', 'POST'],
|
||||
'api_cors_allow_headers': ['Content-Type', 'X-CSRF-Token'],
|
||||
'api_cors_allow_credentials': True,
|
||||
'api_cors_expose_headers': ['X-Resp-Id'],
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': api_name,
|
||||
'api_version': ver,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/q',
|
||||
'endpoint_description': 'q',
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'username': 'admin', 'api_name': api_name, 'api_version': ver},
|
||||
)
|
||||
r = client.options(
|
||||
f'/api/rest/{api_name}/{ver}/q',
|
||||
headers={
|
||||
'Origin': 'http://ok.example',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
'Access-Control-Request-Headers': 'X-CSRF-Token',
|
||||
},
|
||||
)
|
||||
assert r.status_code == 204
|
||||
assert r.headers.get('Access-Control-Allow-Origin') == 'http://ok.example'
|
||||
assert 'GET' in (r.headers.get('Access-Control-Allow-Methods') or '')
|
||||
|
||||
@@ -1,30 +1,57 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
_RUN_LIVE = os.getenv('DOORMAN_RUN_LIVE', '0') in ('1', 'true', 'True')
|
||||
if not _RUN_LIVE:
|
||||
pytestmark = pytest.mark.skip(reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable')
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable'
|
||||
)
|
||||
|
||||
|
||||
def test_bandwidth_limit_enforced_and_window_resets_live(client):
|
||||
name, ver = 'bwlive', 'v1'
|
||||
client.post('/platform/api', json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'api_description': 'bw live',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://up.example'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
})
|
||||
client.post('/platform/endpoint', json={'api_name': name, 'api_version': ver, 'endpoint_method': 'GET', 'endpoint_uri': '/p', 'endpoint_description': 'p'})
|
||||
client.post('/platform/subscription/subscribe', json={'username': 'admin', 'api_name': name, 'api_version': ver})
|
||||
client.put('/platform/user/admin', json={'bandwidth_limit_bytes': 1, 'bandwidth_limit_window': 'second', 'bandwidth_limit_enabled': True})
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'api_description': 'bw live',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://up.example'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/p',
|
||||
'endpoint_description': 'p',
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'username': 'admin', 'api_name': name, 'api_version': ver},
|
||||
)
|
||||
client.put(
|
||||
'/platform/user/admin',
|
||||
json={
|
||||
'bandwidth_limit_bytes': 1,
|
||||
'bandwidth_limit_window': 'second',
|
||||
'bandwidth_limit_enabled': True,
|
||||
},
|
||||
)
|
||||
client.delete('/api/caches')
|
||||
r1 = client.get(f'/api/rest/{name}/{ver}/p')
|
||||
r2 = client.get(f'/api/rest/{name}/{ver}/p')
|
||||
assert r1.status_code == 200 and r2.status_code == 429
|
||||
import time
|
||||
|
||||
time.sleep(1.1)
|
||||
r3 = client.get(f'/api/rest/{name}/{ver}/p')
|
||||
assert r3.status_code == 200
|
||||
|
||||
@@ -1,81 +1,123 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
_RUN_LIVE = os.getenv('DOORMAN_RUN_LIVE', '0') in ('1', 'true', 'True')
|
||||
if not _RUN_LIVE:
|
||||
pytestmark = pytest.mark.skip(reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable')
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable'
|
||||
)
|
||||
|
||||
|
||||
async def _setup(client, name='gllive', ver='v1'):
|
||||
await client.post('/platform/api', json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'api_description': f'{name} {ver}',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://gql.up'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
'api_public': True,
|
||||
})
|
||||
await client.post('/platform/endpoint', json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/graphql',
|
||||
'endpoint_description': 'gql'
|
||||
})
|
||||
await client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'api_description': f'{name} {ver}',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://gql.up'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
'api_public': True,
|
||||
},
|
||||
)
|
||||
await client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/graphql',
|
||||
'endpoint_description': 'gql',
|
||||
},
|
||||
)
|
||||
return name, ver
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graphql_client_fallback_to_httpx_live(monkeypatch, authed_client):
|
||||
import services.gateway_service as gs
|
||||
|
||||
name, ver = await _setup(authed_client, name='gll1')
|
||||
|
||||
class Dummy:
|
||||
pass
|
||||
|
||||
class FakeHTTPResp:
|
||||
def __init__(self, payload):
|
||||
self._p = payload
|
||||
|
||||
def json(self):
|
||||
return self._p
|
||||
|
||||
class H:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def post(self, url, json=None, headers=None):
|
||||
return FakeHTTPResp({'ok': True})
|
||||
|
||||
monkeypatch.setattr(gs, 'Client', Dummy)
|
||||
monkeypatch.setattr(gs.httpx, 'AsyncClient', H)
|
||||
r = await authed_client.post(f'/api/graphql/{name}', headers={'X-API-Version': ver, 'Content-Type': 'application/json'}, json={'query': '{ ping }', 'variables': {}})
|
||||
r = await authed_client.post(
|
||||
f'/api/graphql/{name}',
|
||||
headers={'X-API-Version': ver, 'Content-Type': 'application/json'},
|
||||
json={'query': '{ ping }', 'variables': {}},
|
||||
)
|
||||
assert r.status_code == 200 and r.json().get('ok') is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graphql_errors_live_strict_and_loose(monkeypatch, authed_client):
|
||||
import services.gateway_service as gs
|
||||
|
||||
name, ver = await _setup(authed_client, name='gll2')
|
||||
|
||||
class Dummy:
|
||||
pass
|
||||
|
||||
class FakeHTTPResp:
|
||||
def __init__(self, payload):
|
||||
self._p = payload
|
||||
|
||||
def json(self):
|
||||
return self._p
|
||||
|
||||
class H:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def post(self, url, json=None, headers=None):
|
||||
return FakeHTTPResp({'errors': [{'message': 'boom'}]})
|
||||
|
||||
monkeypatch.setattr(gs, 'Client', Dummy)
|
||||
monkeypatch.setattr(gs.httpx, 'AsyncClient', H)
|
||||
monkeypatch.delenv('STRICT_RESPONSE_ENVELOPE', raising=False)
|
||||
r1 = await authed_client.post(f'/api/graphql/{name}', headers={'X-API-Version': ver, 'Content-Type': 'application/json'}, json={'query': '{ err }', 'variables': {}})
|
||||
r1 = await authed_client.post(
|
||||
f'/api/graphql/{name}',
|
||||
headers={'X-API-Version': ver, 'Content-Type': 'application/json'},
|
||||
json={'query': '{ err }', 'variables': {}},
|
||||
)
|
||||
assert r1.status_code == 200 and isinstance(r1.json().get('errors'), list)
|
||||
monkeypatch.setenv('STRICT_RESPONSE_ENVELOPE', 'true')
|
||||
r2 = await authed_client.post(f'/api/graphql/{name}', headers={'X-API-Version': ver, 'Content-Type': 'application/json'}, json={'query': '{ err }', 'variables': {}})
|
||||
r2 = await authed_client.post(
|
||||
f'/api/graphql/{name}',
|
||||
headers={'X-API-Version': ver, 'Content-Type': 'application/json'},
|
||||
json={'query': '{ err }', 'variables': {}},
|
||||
)
|
||||
assert r2.status_code == 200 and r2.json().get('status_code') == 200
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
_RUN_LIVE = os.getenv('DOORMAN_RUN_LIVE', '0') in ('1', 'true', 'True')
|
||||
if not _RUN_LIVE:
|
||||
pytestmark = pytest.mark.skip(reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable')
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable'
|
||||
)
|
||||
|
||||
|
||||
def _fake_pb2_module(method_name='M'):
|
||||
class Req:
|
||||
pass
|
||||
|
||||
class Reply:
|
||||
DESCRIPTOR = type('D', (), {'fields': [type('F', (), {'name': 'ok'})()]})()
|
||||
|
||||
def __init__(self, ok=True):
|
||||
self.ok = ok
|
||||
|
||||
@staticmethod
|
||||
def FromString(b):
|
||||
return Reply(True)
|
||||
setattr(Req, '__name__', f'{method_name}Request')
|
||||
setattr(Reply, '__name__', f'{method_name}Reply')
|
||||
|
||||
Req.__name__ = f'{method_name}Request'
|
||||
Reply.__name__ = f'{method_name}Reply'
|
||||
return Req, Reply
|
||||
|
||||
|
||||
def _make_import_module_recorder(record, pb2_map):
|
||||
def _imp(name):
|
||||
record.append(name)
|
||||
@@ -27,31 +36,47 @@ def _make_import_module_recorder(record, pb2_map):
|
||||
mapping = pb2_map.get(name)
|
||||
if mapping is None:
|
||||
req_cls, rep_cls = _fake_pb2_module('M')
|
||||
setattr(mod, 'MRequest', req_cls)
|
||||
setattr(mod, 'MReply', rep_cls)
|
||||
mod.MRequest = req_cls
|
||||
mod.MReply = rep_cls
|
||||
else:
|
||||
req_cls, rep_cls = mapping
|
||||
if req_cls:
|
||||
setattr(mod, 'MRequest', req_cls)
|
||||
mod.MRequest = req_cls
|
||||
if rep_cls:
|
||||
setattr(mod, 'MReply', rep_cls)
|
||||
mod.MReply = rep_cls
|
||||
return mod
|
||||
if name.endswith('_pb2_grpc'):
|
||||
|
||||
class Stub:
|
||||
def __init__(self, ch):
|
||||
self._ch = ch
|
||||
|
||||
async def M(self, req):
|
||||
return type('R', (), {'DESCRIPTOR': type('D', (), {'fields': [type('F', (), {'name': 'ok'})()]})(), 'ok': True})()
|
||||
return type(
|
||||
'R',
|
||||
(),
|
||||
{
|
||||
'DESCRIPTOR': type(
|
||||
'D', (), {'fields': [type('F', (), {'name': 'ok'})()]}
|
||||
)(),
|
||||
'ok': True,
|
||||
},
|
||||
)()
|
||||
|
||||
mod = type('SVC', (), {'SvcStub': Stub})
|
||||
return mod
|
||||
raise ImportError(name)
|
||||
|
||||
return _imp
|
||||
|
||||
|
||||
def _make_fake_grpc_unary(sequence_codes, grpc_mod):
|
||||
counter = {'i': 0}
|
||||
|
||||
class AioChan:
|
||||
async def channel_ready(self):
|
||||
return True
|
||||
|
||||
class Chan(AioChan):
|
||||
def unary_unary(self, method, request_serializer=None, response_deserializer=None):
|
||||
async def _call(req):
|
||||
@@ -59,143 +84,214 @@ def _make_fake_grpc_unary(sequence_codes, grpc_mod):
|
||||
code = sequence_codes[idx]
|
||||
counter['i'] += 1
|
||||
if code is None:
|
||||
return type('R', (), {'DESCRIPTOR': type('D', (), {'fields': [type('F', (), {'name': 'ok'})()]})(), 'ok': True})()
|
||||
return type(
|
||||
'R',
|
||||
(),
|
||||
{
|
||||
'DESCRIPTOR': type(
|
||||
'D', (), {'fields': [type('F', (), {'name': 'ok'})()]}
|
||||
)(),
|
||||
'ok': True,
|
||||
},
|
||||
)()
|
||||
|
||||
class E(Exception):
|
||||
def code(self):
|
||||
return code
|
||||
|
||||
def details(self):
|
||||
return 'err'
|
||||
|
||||
raise E()
|
||||
|
||||
return _call
|
||||
|
||||
class aio:
|
||||
@staticmethod
|
||||
def insecure_channel(url):
|
||||
return Chan()
|
||||
|
||||
fake = type('G', (), {'aio': aio, 'StatusCode': grpc_mod.StatusCode, 'RpcError': Exception})
|
||||
return fake
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grpc_with_api_grpc_package_config(monkeypatch, authed_client):
|
||||
import services.gateway_service as gs
|
||||
|
||||
name, ver = 'gplive1', 'v1'
|
||||
await authed_client.post('/platform/api', json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'api_description': 'g',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['grpc://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
'api_grpc_package': 'api.pkg'
|
||||
})
|
||||
await authed_client.post('/platform/endpoint', json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/grpc',
|
||||
'endpoint_description': 'grpc'
|
||||
})
|
||||
await authed_client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'api_description': 'g',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['grpc://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
'api_grpc_package': 'api.pkg',
|
||||
},
|
||||
)
|
||||
await authed_client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/grpc',
|
||||
'endpoint_description': 'grpc',
|
||||
},
|
||||
)
|
||||
record = []
|
||||
req_cls, rep_cls = _fake_pb2_module('M')
|
||||
pb2_map = {'api.pkg_pb2': (req_cls, rep_cls)}
|
||||
monkeypatch.setattr(gs.importlib, 'import_module', _make_import_module_recorder(record, pb2_map))
|
||||
monkeypatch.setattr(
|
||||
gs.importlib, 'import_module', _make_import_module_recorder(record, pb2_map)
|
||||
)
|
||||
monkeypatch.setattr(gs.os.path, 'exists', lambda p: True)
|
||||
monkeypatch.setattr(gs, 'grpc', _make_fake_grpc_unary([None], gs.grpc))
|
||||
r = await authed_client.post(f'/api/grpc/{name}', headers={'X-API-Version': ver, 'Content-Type': 'application/json'}, json={'method': 'Svc.M', 'message': {}, 'package': 'req.pkg'})
|
||||
r = await authed_client.post(
|
||||
f'/api/grpc/{name}',
|
||||
headers={'X-API-Version': ver, 'Content-Type': 'application/json'},
|
||||
json={'method': 'Svc.M', 'message': {}, 'package': 'req.pkg'},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert any(n == 'api.pkg_pb2' for n in record)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grpc_with_request_package_override(monkeypatch, authed_client):
|
||||
import services.gateway_service as gs
|
||||
|
||||
name, ver = 'gplive2', 'v1'
|
||||
await authed_client.post('/platform/api', json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'api_description': 'g',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['grpc://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
})
|
||||
await authed_client.post('/platform/endpoint', json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/grpc',
|
||||
'endpoint_description': 'grpc'
|
||||
})
|
||||
await authed_client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'api_description': 'g',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['grpc://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
},
|
||||
)
|
||||
await authed_client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/grpc',
|
||||
'endpoint_description': 'grpc',
|
||||
},
|
||||
)
|
||||
record = []
|
||||
req_cls, rep_cls = _fake_pb2_module('M')
|
||||
pb2_map = {'req.pkg_pb2': (req_cls, rep_cls)}
|
||||
monkeypatch.setattr(gs.importlib, 'import_module', _make_import_module_recorder(record, pb2_map))
|
||||
monkeypatch.setattr(
|
||||
gs.importlib, 'import_module', _make_import_module_recorder(record, pb2_map)
|
||||
)
|
||||
monkeypatch.setattr(gs.os.path, 'exists', lambda p: True)
|
||||
monkeypatch.setattr(gs, 'grpc', _make_fake_grpc_unary([None], gs.grpc))
|
||||
r = await authed_client.post(f'/api/grpc/{name}', headers={'X-API-Version': ver, 'Content-Type': 'application/json'}, json={'method': 'Svc.M', 'message': {}, 'package': 'req.pkg'})
|
||||
r = await authed_client.post(
|
||||
f'/api/grpc/{name}',
|
||||
headers={'X-API-Version': ver, 'Content-Type': 'application/json'},
|
||||
json={'method': 'Svc.M', 'message': {}, 'package': 'req.pkg'},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert any(n == 'req.pkg_pb2' for n in record)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grpc_without_package_server_uses_fallback_path(monkeypatch, authed_client):
|
||||
import services.gateway_service as gs
|
||||
|
||||
name, ver = 'gplive3', 'v1'
|
||||
await authed_client.post('/platform/api', json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'api_description': 'g',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['grpc://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
})
|
||||
await authed_client.post('/platform/endpoint', json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/grpc',
|
||||
'endpoint_description': 'grpc'
|
||||
})
|
||||
await authed_client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'api_description': 'g',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['grpc://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
},
|
||||
)
|
||||
await authed_client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/grpc',
|
||||
'endpoint_description': 'grpc',
|
||||
},
|
||||
)
|
||||
record = []
|
||||
req_cls, rep_cls = _fake_pb2_module('M')
|
||||
default_pkg = f'{name}_{ver}'.replace('-', '_') + '_pb2'
|
||||
pb2_map = {default_pkg: (req_cls, rep_cls)}
|
||||
monkeypatch.setattr(gs.importlib, 'import_module', _make_import_module_recorder(record, pb2_map))
|
||||
monkeypatch.setattr(
|
||||
gs.importlib, 'import_module', _make_import_module_recorder(record, pb2_map)
|
||||
)
|
||||
monkeypatch.setattr(gs.os.path, 'exists', lambda p: True)
|
||||
monkeypatch.setattr(gs, 'grpc', _make_fake_grpc_unary([None], gs.grpc))
|
||||
r = await authed_client.post(f'/api/grpc/{name}', headers={'X-API-Version': ver, 'Content-Type': 'application/json'}, json={'method': 'Svc.M', 'message': {}})
|
||||
r = await authed_client.post(
|
||||
f'/api/grpc/{name}',
|
||||
headers={'X-API-Version': ver, 'Content-Type': 'application/json'},
|
||||
json={'method': 'Svc.M', 'message': {}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert any(n.endswith(default_pkg) for n in record)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grpc_unavailable_then_success_with_retry_live(monkeypatch, authed_client):
|
||||
import services.gateway_service as gs
|
||||
|
||||
name, ver = 'gplive4', 'v1'
|
||||
await authed_client.post('/platform/api', json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'api_description': 'g',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['grpc://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 1,
|
||||
})
|
||||
await authed_client.post('/platform/endpoint', json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/grpc',
|
||||
'endpoint_description': 'grpc'
|
||||
})
|
||||
await authed_client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'api_description': 'g',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['grpc://127.0.0.1:9'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 1,
|
||||
},
|
||||
)
|
||||
await authed_client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'endpoint_method': 'POST',
|
||||
'endpoint_uri': '/grpc',
|
||||
'endpoint_description': 'grpc',
|
||||
},
|
||||
)
|
||||
record = []
|
||||
req_cls, rep_cls = _fake_pb2_module('M')
|
||||
default_pkg = f'{name}_{ver}'.replace('-', '_') + '_pb2'
|
||||
pb2_map = {default_pkg: (req_cls, rep_cls)}
|
||||
monkeypatch.setattr(gs.importlib, 'import_module', _make_import_module_recorder(record, pb2_map))
|
||||
monkeypatch.setattr(
|
||||
gs.importlib, 'import_module', _make_import_module_recorder(record, pb2_map)
|
||||
)
|
||||
fake_grpc = _make_fake_grpc_unary([gs.grpc.StatusCode.UNAVAILABLE, None], gs.grpc)
|
||||
monkeypatch.setattr(gs, 'grpc', fake_grpc)
|
||||
r = await authed_client.post(f'/api/grpc/{name}', headers={'X-API-Version': ver, 'Content-Type': 'application/json'}, json={'method': 'Svc.M', 'message': {}})
|
||||
r = await authed_client.post(
|
||||
f'/api/grpc/{name}',
|
||||
headers={'X-API-Version': ver, 'Content-Type': 'application/json'},
|
||||
json={'method': 'Svc.M', 'message': {}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import pytest
|
||||
import os
|
||||
import platform
|
||||
|
||||
import pytest
|
||||
|
||||
_RUN_LIVE = os.getenv('DOORMAN_RUN_LIVE', '0') in ('1', 'true', 'True')
|
||||
if not _RUN_LIVE:
|
||||
pytestmark = pytest.mark.skip(reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable')
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(platform.system() == 'Windows', reason='SIGUSR1 not available on Windows')
|
||||
def test_sigusr1_dump_in_memory_mode_live(client, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv('MEM_ENCRYPTION_KEY', 'live-secret-xyz')
|
||||
monkeypatch.setenv('MEM_DUMP_PATH', str(tmp_path / 'live' / 'memory_dump.bin'))
|
||||
import signal, time
|
||||
import signal
|
||||
import time
|
||||
|
||||
os.kill(os.getpid(), signal.SIGUSR1)
|
||||
time.sleep(0.5)
|
||||
assert True
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
_RUN_LIVE = os.getenv('DOORMAN_RUN_LIVE', '0') in ('1', 'true', 'True')
|
||||
if not _RUN_LIVE:
|
||||
pytestmark = pytest.mark.skip(reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable')
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable'
|
||||
)
|
||||
|
||||
|
||||
def test_platform_cors_strict_wildcard_credentials_edges_live(client, monkeypatch):
|
||||
monkeypatch.setenv('ALLOWED_ORIGINS', '*')
|
||||
monkeypatch.setenv('ALLOW_CREDENTIALS', 'true')
|
||||
monkeypatch.setenv('CORS_STRICT', 'true')
|
||||
r = client.options('/platform/api', headers={'Origin': 'http://evil.example', 'Access-Control-Request-Method': 'GET'})
|
||||
r = client.options(
|
||||
'/platform/api',
|
||||
headers={'Origin': 'http://evil.example', 'Access-Control-Request-Method': 'GET'},
|
||||
)
|
||||
assert r.status_code == 204
|
||||
assert r.headers.get('Access-Control-Allow-Origin') in (None, '')
|
||||
|
||||
|
||||
def test_platform_cors_methods_headers_defaults_live(client, monkeypatch):
|
||||
monkeypatch.setenv('ALLOW_METHODS', '')
|
||||
monkeypatch.setenv('ALLOW_HEADERS', '*')
|
||||
r = client.options('/platform/api', headers={'Origin': 'http://localhost:3000', 'Access-Control-Request-Method': 'GET', 'Access-Control-Request-Headers': 'X-Rand'})
|
||||
r = client.options(
|
||||
'/platform/api',
|
||||
headers={
|
||||
'Origin': 'http://localhost:3000',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
'Access-Control-Request-Headers': 'X-Rand',
|
||||
},
|
||||
)
|
||||
assert r.status_code == 204
|
||||
methods = [m.strip() for m in (r.headers.get('Access-Control-Allow-Methods') or '').split(',') if m.strip()]
|
||||
assert set(methods) == {'GET','POST','PUT','DELETE','OPTIONS','PATCH','HEAD'}
|
||||
methods = [
|
||||
m.strip()
|
||||
for m in (r.headers.get('Access-Control-Allow-Methods') or '').split(',')
|
||||
if m.strip()
|
||||
]
|
||||
assert set(methods) == {'GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH', 'HEAD'}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
_RUN_LIVE = os.getenv('DOORMAN_RUN_LIVE', '0') in ('1', 'true', 'True')
|
||||
if not _RUN_LIVE:
|
||||
pytestmark = pytest.mark.skip(reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable')
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_allowed_headers_only(monkeypatch, authed_client):
|
||||
from conftest import create_api, create_endpoint, subscribe_self
|
||||
from conftest import create_endpoint, subscribe_self
|
||||
|
||||
import services.gateway_service as gs
|
||||
|
||||
name, ver = 'hforw', 'v1'
|
||||
payload = {
|
||||
'api_name': name,
|
||||
@@ -19,7 +25,7 @@ async def test_forward_allowed_headers_only(monkeypatch, authed_client):
|
||||
'api_servers': ['http://up'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
'api_allowed_headers': ['x-allowed', 'content-type']
|
||||
'api_allowed_headers': ['x-allowed', 'content-type'],
|
||||
}
|
||||
await authed_client.post('/platform/api', json=payload)
|
||||
await create_endpoint(authed_client, name, ver, 'GET', '/p')
|
||||
@@ -31,26 +37,37 @@ async def test_forward_allowed_headers_only(monkeypatch, authed_client):
|
||||
self._p = {'ok': True}
|
||||
self.headers = {'Content-Type': 'application/json'}
|
||||
self.text = ''
|
||||
|
||||
def json(self):
|
||||
return self._p
|
||||
|
||||
captured = {}
|
||||
|
||||
class CapClient:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def get(self, url, params=None, headers=None):
|
||||
captured['headers'] = headers or {}
|
||||
return Resp()
|
||||
|
||||
monkeypatch.setattr(gs.httpx, 'AsyncClient', CapClient)
|
||||
await authed_client.get(f'/api/rest/{name}/{ver}/p', headers={'X-Allowed': 'yes', 'X-Blocked': 'no'})
|
||||
await authed_client.get(
|
||||
f'/api/rest/{name}/{ver}/p', headers={'X-Allowed': 'yes', 'X-Blocked': 'no'}
|
||||
)
|
||||
ch = {k.lower(): v for k, v in (captured.get('headers') or {}).items()}
|
||||
assert 'x-allowed' in ch and 'x-blocked' not in ch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_headers_filtered_by_allowlist(monkeypatch, authed_client):
|
||||
from conftest import create_api, create_endpoint, subscribe_self
|
||||
from conftest import create_endpoint, subscribe_self
|
||||
|
||||
import services.gateway_service as gs
|
||||
|
||||
name, ver = 'hresp', 'v1'
|
||||
payload = {
|
||||
'api_name': name,
|
||||
@@ -61,7 +78,7 @@ async def test_response_headers_filtered_by_allowlist(monkeypatch, authed_client
|
||||
'api_servers': ['http://up'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
'api_allowed_headers': ['x-upstream']
|
||||
'api_allowed_headers': ['x-upstream'],
|
||||
}
|
||||
await authed_client.post('/platform/api', json=payload)
|
||||
await create_endpoint(authed_client, name, ver, 'GET', '/p')
|
||||
@@ -71,17 +88,26 @@ async def test_response_headers_filtered_by_allowlist(monkeypatch, authed_client
|
||||
def __init__(self):
|
||||
self.status_code = 200
|
||||
self._p = {'ok': True}
|
||||
self.headers = {'Content-Type': 'application/json', 'X-Upstream': 'yes', 'X-Secret': 'no'}
|
||||
self.headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Upstream': 'yes',
|
||||
'X-Secret': 'no',
|
||||
}
|
||||
self.text = ''
|
||||
|
||||
def json(self):
|
||||
return self._p
|
||||
|
||||
class HC:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def get(self, url, params=None, headers=None):
|
||||
return Resp()
|
||||
|
||||
monkeypatch.setattr(gs.httpx, 'AsyncClient', HC)
|
||||
r = await authed_client.get(f'/api/rest/{name}/{ver}/p')
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
_RUN_LIVE = os.getenv('DOORMAN_RUN_LIVE', '0') in ('1', 'true', 'True')
|
||||
if not _RUN_LIVE:
|
||||
pytestmark = pytest.mark.skip(reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable')
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable'
|
||||
)
|
||||
|
||||
from tests.test_gateway_routing_limits import _FakeAsyncClient
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rest_retries_on_500_then_success(monkeypatch, authed_client):
|
||||
from conftest import create_api, create_endpoint, subscribe_self
|
||||
|
||||
import services.gateway_service as gs
|
||||
|
||||
name, ver = 'rlive500', 'v1'
|
||||
await create_api(authed_client, name, ver)
|
||||
await create_endpoint(authed_client, name, ver, 'GET', '/r')
|
||||
await subscribe_self(authed_client, name, ver)
|
||||
|
||||
from utils.database import api_collection
|
||||
api_collection.update_one({'api_name': name, 'api_version': ver}, {'$set': {'api_allowed_retry_count': 1}})
|
||||
|
||||
api_collection.update_one(
|
||||
{'api_name': name, 'api_version': ver}, {'$set': {'api_allowed_retry_count': 1}}
|
||||
)
|
||||
await authed_client.delete('/api/caches')
|
||||
|
||||
class Resp:
|
||||
@@ -26,30 +33,42 @@ async def test_rest_retries_on_500_then_success(monkeypatch, authed_client):
|
||||
self._json = body or {}
|
||||
self.text = ''
|
||||
self.headers = headers or {'Content-Type': 'application/json'}
|
||||
|
||||
def json(self):
|
||||
return self._json
|
||||
|
||||
seq = [Resp(500), Resp(200, {'ok': True})]
|
||||
|
||||
class SeqClient:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def get(self, url, params=None, headers=None):
|
||||
return seq.pop(0)
|
||||
|
||||
monkeypatch.setattr(gs.httpx, 'AsyncClient', SeqClient)
|
||||
r = await authed_client.get(f'/api/rest/{name}/{ver}/r')
|
||||
assert r.status_code == 200 and r.json().get('ok') is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rest_retries_on_503_then_success(monkeypatch, authed_client):
|
||||
from conftest import create_api, create_endpoint, subscribe_self
|
||||
|
||||
import services.gateway_service as gs
|
||||
|
||||
name, ver = 'rlive503', 'v1'
|
||||
await create_api(authed_client, name, ver)
|
||||
await create_endpoint(authed_client, name, ver, 'GET', '/r')
|
||||
await subscribe_self(authed_client, name, ver)
|
||||
from utils.database import api_collection
|
||||
api_collection.update_one({'api_name': name, 'api_version': ver}, {'$set': {'api_allowed_retry_count': 1}})
|
||||
|
||||
api_collection.update_one(
|
||||
{'api_name': name, 'api_version': ver}, {'$set': {'api_allowed_retry_count': 1}}
|
||||
)
|
||||
await authed_client.delete('/api/caches')
|
||||
|
||||
class Resp:
|
||||
@@ -57,43 +76,58 @@ async def test_rest_retries_on_503_then_success(monkeypatch, authed_client):
|
||||
self.status_code = status
|
||||
self.headers = {'Content-Type': 'application/json'}
|
||||
self.text = ''
|
||||
|
||||
def json(self):
|
||||
return {}
|
||||
|
||||
seq = [Resp(503), Resp(200)]
|
||||
|
||||
class SeqClient:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def get(self, url, params=None, headers=None):
|
||||
return seq.pop(0)
|
||||
|
||||
monkeypatch.setattr(gs.httpx, 'AsyncClient', SeqClient)
|
||||
r = await authed_client.get(f'/api/rest/{name}/{ver}/r')
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rest_no_retry_when_retry_count_zero(monkeypatch, authed_client):
|
||||
from conftest import create_api, create_endpoint, subscribe_self
|
||||
|
||||
import services.gateway_service as gs
|
||||
|
||||
name, ver = 'rlivez0', 'v1'
|
||||
await create_api(authed_client, name, ver)
|
||||
await create_endpoint(authed_client, name, ver, 'GET', '/r')
|
||||
await subscribe_self(authed_client, name, ver)
|
||||
await authed_client.delete('/api/caches')
|
||||
|
||||
class Resp:
|
||||
def __init__(self, status):
|
||||
self.status_code = status
|
||||
self.headers = {'Content-Type': 'application/json'}
|
||||
self.text = ''
|
||||
|
||||
def json(self):
|
||||
return {}
|
||||
|
||||
class OneClient:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def get(self, url, params=None, headers=None):
|
||||
return Resp(500)
|
||||
|
||||
monkeypatch.setattr(gs.httpx, 'AsyncClient', OneClient)
|
||||
r = await authed_client.get(f'/api/rest/{name}/{ver}/r')
|
||||
assert r.status_code == 500
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
_RUN_LIVE = os.getenv('DOORMAN_RUN_LIVE', '0') in ('1', 'true', 'True')
|
||||
if not _RUN_LIVE:
|
||||
pytestmark = pytest.mark.skip(reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable')
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soap_content_types_matrix(monkeypatch, authed_client):
|
||||
from conftest import create_api, create_endpoint, subscribe_self
|
||||
|
||||
import services.gateway_service as gs
|
||||
|
||||
name, ver = 'soapct', 'v1'
|
||||
await create_api(authed_client, name, ver)
|
||||
await create_endpoint(authed_client, name, ver, 'POST', '/s')
|
||||
@@ -19,30 +25,43 @@ async def test_soap_content_types_matrix(monkeypatch, authed_client):
|
||||
self.status_code = 200
|
||||
self.headers = {'Content-Type': 'application/xml'}
|
||||
self.text = '<ok/>'
|
||||
|
||||
def json(self):
|
||||
return {'ok': True}
|
||||
|
||||
class HC:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def post(self, url, json=None, params=None, headers=None, content=None):
|
||||
return Resp()
|
||||
|
||||
monkeypatch.setattr(gs.httpx, 'AsyncClient', HC)
|
||||
for ct in ['application/xml', 'text/xml']:
|
||||
r = await authed_client.post(f'/api/soap/{name}/{ver}/s', headers={'Content-Type': ct}, content='<a/>')
|
||||
r = await authed_client.post(
|
||||
f'/api/soap/{name}/{ver}/s', headers={'Content-Type': ct}, content='<a/>'
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soap_retries_then_success(monkeypatch, authed_client):
|
||||
from conftest import create_api, create_endpoint, subscribe_self
|
||||
|
||||
import services.gateway_service as gs
|
||||
|
||||
name, ver = 'soaprt', 'v1'
|
||||
await create_api(authed_client, name, ver)
|
||||
await create_endpoint(authed_client, name, ver, 'POST', '/s')
|
||||
await subscribe_self(authed_client, name, ver)
|
||||
from utils.database import api_collection
|
||||
api_collection.update_one({'api_name': name, 'api_version': ver}, {'$set': {'api_allowed_retry_count': 1}})
|
||||
|
||||
api_collection.update_one(
|
||||
{'api_name': name, 'api_version': ver}, {'$set': {'api_allowed_retry_count': 1}}
|
||||
)
|
||||
await authed_client.delete('/api/caches')
|
||||
|
||||
class Resp:
|
||||
@@ -50,16 +69,24 @@ async def test_soap_retries_then_success(monkeypatch, authed_client):
|
||||
self.status_code = status
|
||||
self.headers = {'Content-Type': 'application/xml'}
|
||||
self.text = '<ok/>'
|
||||
|
||||
def json(self):
|
||||
return {'ok': True}
|
||||
|
||||
seq = [Resp(503), Resp(200)]
|
||||
|
||||
class HC:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def post(self, url, json=None, params=None, headers=None, content=None):
|
||||
return seq.pop(0)
|
||||
|
||||
monkeypatch.setattr(gs.httpx, 'AsyncClient', HC)
|
||||
r = await authed_client.post(f'/api/soap/{name}/{ver}/s', headers={'Content-Type': 'application/xml'}, content='<a/>')
|
||||
r = await authed_client.post(
|
||||
f'/api/soap/{name}/{ver}/s', headers={'Content-Type': 'application/xml'}, content='<a/>'
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -1,68 +1,94 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
_RUN_LIVE = os.getenv('DOORMAN_RUN_LIVE', '0') in ('1', 'true', 'True')
|
||||
if not _RUN_LIVE:
|
||||
pytestmark = pytest.mark.skip(reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable')
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason='Requires live backend service; set DOORMAN_RUN_LIVE=1 to enable'
|
||||
)
|
||||
|
||||
|
||||
def test_throttle_queue_limit_exceeded_429_live(client):
|
||||
from config import ADMIN_EMAIL
|
||||
name, ver = 'throtq', 'v1'
|
||||
client.post('/platform/api', json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'api_description': 'live throttle',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://up.example'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
})
|
||||
client.post('/platform/endpoint', json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/t',
|
||||
'endpoint_description': 't'
|
||||
})
|
||||
client.post('/platform/subscription/subscribe', json={'username': 'admin', 'api_name': name, 'api_version': ver})
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'api_description': 'live throttle',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://up.example'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/t',
|
||||
'endpoint_description': 't',
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'username': 'admin', 'api_name': name, 'api_version': ver},
|
||||
)
|
||||
client.put('/platform/user/admin', json={'throttle_queue_limit': 1})
|
||||
client.delete('/api/caches')
|
||||
r1 = client.get(f'/api/rest/{name}/{ver}/t')
|
||||
client.get(f'/api/rest/{name}/{ver}/t')
|
||||
r2 = client.get(f'/api/rest/{name}/{ver}/t')
|
||||
assert r2.status_code == 429
|
||||
|
||||
|
||||
def test_throttle_dynamic_wait_live(client):
|
||||
name, ver = 'throtw', 'v1'
|
||||
client.post('/platform/api', json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'api_description': 'live throttle wait',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://up.example'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
})
|
||||
client.post('/platform/endpoint', json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/w',
|
||||
'endpoint_description': 'w'
|
||||
})
|
||||
client.post('/platform/subscription/subscribe', json={'username': 'admin', 'api_name': name, 'api_version': ver})
|
||||
client.put('/platform/user/admin', json={
|
||||
'throttle_duration': 1,
|
||||
'throttle_duration_type': 'second',
|
||||
'throttle_queue_limit': 10,
|
||||
'throttle_wait_duration': 0.1,
|
||||
'throttle_wait_duration_type': 'second',
|
||||
'rate_limit_duration': 1000,
|
||||
'rate_limit_duration_type': 'second',
|
||||
})
|
||||
client.post(
|
||||
'/platform/api',
|
||||
json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'api_description': 'live throttle wait',
|
||||
'api_allowed_roles': ['admin'],
|
||||
'api_allowed_groups': ['ALL'],
|
||||
'api_servers': ['http://up.example'],
|
||||
'api_type': 'REST',
|
||||
'api_allowed_retry_count': 0,
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/endpoint',
|
||||
json={
|
||||
'api_name': name,
|
||||
'api_version': ver,
|
||||
'endpoint_method': 'GET',
|
||||
'endpoint_uri': '/w',
|
||||
'endpoint_description': 'w',
|
||||
},
|
||||
)
|
||||
client.post(
|
||||
'/platform/subscription/subscribe',
|
||||
json={'username': 'admin', 'api_name': name, 'api_version': ver},
|
||||
)
|
||||
client.put(
|
||||
'/platform/user/admin',
|
||||
json={
|
||||
'throttle_duration': 1,
|
||||
'throttle_duration_type': 'second',
|
||||
'throttle_queue_limit': 10,
|
||||
'throttle_wait_duration': 0.1,
|
||||
'throttle_wait_duration_type': 'second',
|
||||
'rate_limit_duration': 1000,
|
||||
'rate_limit_duration_type': 'second',
|
||||
},
|
||||
)
|
||||
client.delete('/api/caches')
|
||||
import time
|
||||
|
||||
t0 = time.perf_counter()
|
||||
r1 = client.get(f'/api/rest/{name}/{ver}/w')
|
||||
t1 = time.perf_counter()
|
||||
|
||||
@@ -5,12 +5,13 @@ Automatically records detailed metrics for every request passing through
|
||||
the gateway, including per-endpoint tracking and full performance data.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.types import ASGIApp
|
||||
from typing import Callable
|
||||
|
||||
from utils.enhanced_metrics_util import enhanced_metrics_store
|
||||
|
||||
@@ -20,7 +21,7 @@ logger = logging.getLogger('doorman.analytics')
|
||||
class AnalyticsMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware to capture comprehensive request/response metrics.
|
||||
|
||||
|
||||
Records:
|
||||
- Response time
|
||||
- Status code
|
||||
@@ -29,21 +30,21 @@ class AnalyticsMiddleware(BaseHTTPMiddleware):
|
||||
- Endpoint URI and method
|
||||
- Request/response sizes
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, app: ASGIApp):
|
||||
super().__init__(app)
|
||||
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
"""
|
||||
Process request and record metrics.
|
||||
"""
|
||||
# Start timing
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
# Extract request metadata
|
||||
method = request.method
|
||||
path = str(request.url.path)
|
||||
|
||||
|
||||
# Estimate request size (headers + body)
|
||||
request_size = 0
|
||||
try:
|
||||
@@ -54,16 +55,16 @@ class AnalyticsMiddleware(BaseHTTPMiddleware):
|
||||
request_size += int(request.headers['content-length'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Process request
|
||||
response = await call_next(request)
|
||||
|
||||
|
||||
# Calculate duration
|
||||
duration_ms = (time.time() - start_time) * 1000
|
||||
|
||||
|
||||
# Extract response metadata
|
||||
status_code = response.status_code
|
||||
|
||||
|
||||
# Estimate response size
|
||||
response_size = 0
|
||||
try:
|
||||
@@ -74,18 +75,20 @@ class AnalyticsMiddleware(BaseHTTPMiddleware):
|
||||
response_size += int(response.headers['content-length'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Extract user from request state (set by auth middleware)
|
||||
username = None
|
||||
try:
|
||||
if hasattr(request.state, 'user'):
|
||||
username = request.state.user.get('sub') if isinstance(request.state.user, dict) else None
|
||||
username = (
|
||||
request.state.user.get('sub') if isinstance(request.state.user, dict) else None
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Parse API and endpoint from path
|
||||
api_key, endpoint_uri = self._parse_api_endpoint(path)
|
||||
|
||||
|
||||
# Record metrics
|
||||
try:
|
||||
enhanced_metrics_store.record(
|
||||
@@ -96,17 +99,17 @@ class AnalyticsMiddleware(BaseHTTPMiddleware):
|
||||
endpoint_uri=endpoint_uri,
|
||||
method=method,
|
||||
bytes_in=request_size,
|
||||
bytes_out=response_size
|
||||
bytes_out=response_size,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to record analytics: {str(e)}")
|
||||
|
||||
logger.error(f'Failed to record analytics: {str(e)}')
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _parse_api_endpoint(self, path: str) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
Parse API key and endpoint URI from request path.
|
||||
|
||||
|
||||
Examples:
|
||||
- /api/rest/customer/v1/users -> ("rest:customer", "/customer/v1/users")
|
||||
- /platform/analytics/overview -> (None, "/platform/analytics/overview")
|
||||
@@ -118,34 +121,34 @@ class AnalyticsMiddleware(BaseHTTPMiddleware):
|
||||
parts = path.split('/')
|
||||
if len(parts) >= 5:
|
||||
api_name = parts[3]
|
||||
api_version = parts[4]
|
||||
parts[4]
|
||||
endpoint_uri = '/' + '/'.join(parts[3:])
|
||||
return f"rest:{api_name}", endpoint_uri
|
||||
|
||||
return f'rest:{api_name}', endpoint_uri
|
||||
|
||||
elif path.startswith('/api/graphql/'):
|
||||
# GraphQL API
|
||||
parts = path.split('/')
|
||||
if len(parts) >= 4:
|
||||
api_name = parts[3]
|
||||
return f"graphql:{api_name}", path
|
||||
|
||||
return f'graphql:{api_name}', path
|
||||
|
||||
elif path.startswith('/api/soap/'):
|
||||
# SOAP API
|
||||
parts = path.split('/')
|
||||
if len(parts) >= 4:
|
||||
api_name = parts[3]
|
||||
return f"soap:{api_name}", path
|
||||
|
||||
return f'soap:{api_name}', path
|
||||
|
||||
elif path.startswith('/api/grpc/'):
|
||||
# gRPC API
|
||||
parts = path.split('/')
|
||||
if len(parts) >= 4:
|
||||
api_name = parts[3]
|
||||
return f"grpc:{api_name}", path
|
||||
|
||||
return f'grpc:{api_name}', path
|
||||
|
||||
# Platform endpoints (not API requests)
|
||||
return None, path
|
||||
|
||||
|
||||
except Exception:
|
||||
return None, path
|
||||
|
||||
@@ -153,8 +156,8 @@ class AnalyticsMiddleware(BaseHTTPMiddleware):
|
||||
def setup_analytics_middleware(app):
|
||||
"""
|
||||
Add analytics middleware to FastAPI app.
|
||||
|
||||
|
||||
Should be called during app initialization.
|
||||
"""
|
||||
app.add_middleware(AnalyticsMiddleware)
|
||||
logger.info("Analytics middleware initialized")
|
||||
logger.info('Analytics middleware initialized')
|
||||
|
||||
@@ -6,16 +6,16 @@ Checks rate limits and quotas, adds headers, and returns 429 when exceeded.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, List, Callable
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import Request, Response, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from models.rate_limit_models import RateLimitRule, RuleType, TimeWindow, TierLimits
|
||||
from utils.rate_limiter import get_rate_limiter, RateLimiter
|
||||
from utils.quota_tracker import get_quota_tracker, QuotaTracker, QuotaType
|
||||
from models.rate_limit_models import RateLimitRule, RuleType, TierLimits, TimeWindow
|
||||
from utils.quota_tracker import QuotaTracker, QuotaType, get_quota_tracker
|
||||
from utils.rate_limiter import RateLimiter, get_rate_limiter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware for rate limiting requests
|
||||
|
||||
|
||||
Features:
|
||||
- Applies rate limit rules based on user, API, endpoint, IP
|
||||
- Checks quotas (monthly, daily)
|
||||
@@ -31,18 +31,18 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
- Returns 429 Too Many Requests when limits exceeded
|
||||
- Supports tier-based limits
|
||||
"""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
rate_limiter: Optional[RateLimiter] = None,
|
||||
quota_tracker: Optional[QuotaTracker] = None,
|
||||
get_rules_func: Optional[Callable] = None,
|
||||
get_user_tier_func: Optional[Callable] = None
|
||||
rate_limiter: RateLimiter | None = None,
|
||||
quota_tracker: QuotaTracker | None = None,
|
||||
get_rules_func: Callable | None = None,
|
||||
get_user_tier_func: Callable | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize rate limit middleware
|
||||
|
||||
|
||||
Args:
|
||||
app: FastAPI application
|
||||
rate_limiter: Rate limiter instance
|
||||
@@ -55,134 +55,134 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
self.quota_tracker = quota_tracker or get_quota_tracker()
|
||||
self.get_rules_func = get_rules_func or self._default_get_rules
|
||||
self.get_user_tier_func = get_user_tier_func or self._default_get_user_tier
|
||||
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""
|
||||
Process request through rate limiting
|
||||
|
||||
|
||||
Args:
|
||||
request: Incoming request
|
||||
call_next: Next middleware/handler
|
||||
|
||||
|
||||
Returns:
|
||||
Response (possibly 429 if rate limited)
|
||||
"""
|
||||
# Skip rate limiting for certain paths
|
||||
if self._should_skip(request):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# Extract identifiers
|
||||
user_id = self._get_user_id(request)
|
||||
api_name = self._get_api_name(request)
|
||||
endpoint_uri = str(request.url.path)
|
||||
ip_address = self._get_client_ip(request)
|
||||
|
||||
|
||||
# Get applicable rules
|
||||
rules = await self.get_rules_func(request, user_id, api_name, endpoint_uri, ip_address)
|
||||
|
||||
|
||||
# Check rate limits
|
||||
for rule in rules:
|
||||
identifier = self._get_identifier(rule, user_id, api_name, endpoint_uri, ip_address)
|
||||
|
||||
|
||||
if identifier:
|
||||
result = self.rate_limiter.check_rate_limit(rule, identifier)
|
||||
|
||||
|
||||
if not result.allowed:
|
||||
# Rate limit exceeded
|
||||
return self._create_rate_limit_response(result, rule)
|
||||
|
||||
|
||||
# Check quotas if user identified
|
||||
if user_id:
|
||||
tier_limits = await self.get_user_tier_func(user_id)
|
||||
|
||||
|
||||
if tier_limits:
|
||||
quota_result = await self._check_quotas(user_id, tier_limits)
|
||||
|
||||
|
||||
if not quota_result.allowed:
|
||||
return self._create_quota_exceeded_response(quota_result)
|
||||
|
||||
|
||||
# Process request
|
||||
response = await call_next(request)
|
||||
|
||||
|
||||
# Add rate limit headers
|
||||
if rules:
|
||||
# Use first rule for headers (highest priority)
|
||||
rule = rules[0]
|
||||
identifier = self._get_identifier(rule, user_id, api_name, endpoint_uri, ip_address)
|
||||
|
||||
|
||||
if identifier:
|
||||
usage = self.rate_limiter.get_current_usage(rule, identifier)
|
||||
self._add_rate_limit_headers(response, usage.limit, usage.remaining, usage.reset_at)
|
||||
|
||||
|
||||
# Increment quota (async, don't block response)
|
||||
if user_id:
|
||||
try:
|
||||
self.quota_tracker.increment_quota(user_id, QuotaType.REQUESTS, 1, 'month')
|
||||
except Exception as e:
|
||||
logger.error(f"Error incrementing quota: {e}")
|
||||
|
||||
logger.error(f'Error incrementing quota: {e}')
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _should_skip(self, request: Request) -> bool:
|
||||
"""
|
||||
Check if rate limiting should be skipped for this request
|
||||
|
||||
|
||||
Args:
|
||||
request: Incoming request
|
||||
|
||||
|
||||
Returns:
|
||||
True if should skip
|
||||
"""
|
||||
# Skip health checks, metrics, etc.
|
||||
skip_paths = ['/health', '/metrics', '/docs', '/redoc', '/openapi.json']
|
||||
|
||||
|
||||
return any(request.url.path.startswith(path) for path in skip_paths)
|
||||
|
||||
def _get_user_id(self, request: Request) -> Optional[str]:
|
||||
|
||||
def _get_user_id(self, request: Request) -> str | None:
|
||||
"""
|
||||
Extract user ID from request
|
||||
|
||||
|
||||
Args:
|
||||
request: Incoming request
|
||||
|
||||
|
||||
Returns:
|
||||
User ID or None
|
||||
"""
|
||||
# Try to get from request state (set by auth middleware)
|
||||
if hasattr(request.state, 'user'):
|
||||
return getattr(request.state.user, 'username', None)
|
||||
|
||||
|
||||
# Try to get from headers
|
||||
return request.headers.get('X-User-ID')
|
||||
|
||||
def _get_api_name(self, request: Request) -> Optional[str]:
|
||||
|
||||
def _get_api_name(self, request: Request) -> str | None:
|
||||
"""
|
||||
Extract API name from request
|
||||
|
||||
|
||||
Args:
|
||||
request: Incoming request
|
||||
|
||||
|
||||
Returns:
|
||||
API name or None
|
||||
"""
|
||||
# Try to get from request state (set by routing)
|
||||
if hasattr(request.state, 'api_name'):
|
||||
return request.state.api_name
|
||||
|
||||
|
||||
# Try to extract from path
|
||||
path_parts = request.url.path.strip('/').split('/')
|
||||
if len(path_parts) > 0:
|
||||
return path_parts[0]
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_client_ip(self, request: Request) -> str:
|
||||
"""
|
||||
Extract client IP address from request
|
||||
|
||||
|
||||
Args:
|
||||
request: Incoming request
|
||||
|
||||
|
||||
Returns:
|
||||
IP address
|
||||
"""
|
||||
@@ -191,36 +191,36 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
if forwarded_for:
|
||||
# Take first IP (original client)
|
||||
return forwarded_for.split(',')[0].strip()
|
||||
|
||||
|
||||
# Check X-Real-IP header
|
||||
real_ip = request.headers.get('X-Real-IP')
|
||||
if real_ip:
|
||||
return real_ip
|
||||
|
||||
|
||||
# Fall back to direct connection
|
||||
if request.client:
|
||||
return request.client.host
|
||||
|
||||
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def _get_identifier(
|
||||
self,
|
||||
rule: RateLimitRule,
|
||||
user_id: Optional[str],
|
||||
api_name: Optional[str],
|
||||
user_id: str | None,
|
||||
api_name: str | None,
|
||||
endpoint_uri: str,
|
||||
ip_address: str
|
||||
) -> Optional[str]:
|
||||
ip_address: str,
|
||||
) -> str | None:
|
||||
"""
|
||||
Get identifier for rate limit rule
|
||||
|
||||
|
||||
Args:
|
||||
rule: Rate limit rule
|
||||
user_id: User ID
|
||||
api_name: API name
|
||||
endpoint_uri: Endpoint URI
|
||||
ip_address: IP address
|
||||
|
||||
|
||||
Returns:
|
||||
Identifier string or None
|
||||
"""
|
||||
@@ -233,188 +233,183 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
elif rule.rule_type == RuleType.PER_IP:
|
||||
return ip_address
|
||||
elif rule.rule_type == RuleType.PER_USER_API:
|
||||
return f"{user_id}:{api_name}" if user_id and api_name else None
|
||||
return f'{user_id}:{api_name}' if user_id and api_name else None
|
||||
elif rule.rule_type == RuleType.PER_USER_ENDPOINT:
|
||||
return f"{user_id}:{endpoint_uri}" if user_id else None
|
||||
return f'{user_id}:{endpoint_uri}' if user_id else None
|
||||
elif rule.rule_type == RuleType.GLOBAL:
|
||||
return 'global'
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _default_get_rules(
|
||||
self,
|
||||
request: Request,
|
||||
user_id: Optional[str],
|
||||
api_name: Optional[str],
|
||||
user_id: str | None,
|
||||
api_name: str | None,
|
||||
endpoint_uri: str,
|
||||
ip_address: str
|
||||
) -> List[RateLimitRule]:
|
||||
ip_address: str,
|
||||
) -> list[RateLimitRule]:
|
||||
"""
|
||||
Default function to get applicable rules
|
||||
|
||||
|
||||
Priority order:
|
||||
1. If user has tier assigned → Use tier limits ONLY
|
||||
2. If user has NO tier → Use per-user rate limit rules
|
||||
3. Fall back to global rules
|
||||
|
||||
|
||||
In production, this should query MongoDB for rules.
|
||||
This is a placeholder that returns default rules.
|
||||
|
||||
|
||||
Args:
|
||||
request: Incoming request
|
||||
user_id: User ID
|
||||
api_name: API name
|
||||
endpoint_uri: Endpoint URI
|
||||
ip_address: IP address
|
||||
|
||||
|
||||
Returns:
|
||||
List of applicable rules
|
||||
"""
|
||||
# TODO: Query MongoDB for rules
|
||||
# For now, return default rules
|
||||
|
||||
|
||||
rules = []
|
||||
|
||||
|
||||
# Check if user has a tier assigned
|
||||
user_tier = None
|
||||
if user_id:
|
||||
user_tier = await self.get_user_tier_func(user_id)
|
||||
|
||||
|
||||
if user_tier:
|
||||
# User has tier → Use tier limits ONLY (priority)
|
||||
# Convert tier limits to rate limit rules
|
||||
if user_tier.requests_per_minute:
|
||||
rules.append(RateLimitRule(
|
||||
rule_id=f'tier_{user_id}',
|
||||
rule_type=RuleType.PER_USER,
|
||||
time_window=TimeWindow.MINUTE,
|
||||
limit=user_tier.requests_per_minute,
|
||||
burst_allowance=user_tier.burst_allowance or 0,
|
||||
priority=100, # Highest priority
|
||||
enabled=True,
|
||||
description=f"Tier-based limit for {user_id}"
|
||||
))
|
||||
rules.append(
|
||||
RateLimitRule(
|
||||
rule_id=f'tier_{user_id}',
|
||||
rule_type=RuleType.PER_USER,
|
||||
time_window=TimeWindow.MINUTE,
|
||||
limit=user_tier.requests_per_minute,
|
||||
burst_allowance=user_tier.burst_allowance or 0,
|
||||
priority=100, # Highest priority
|
||||
enabled=True,
|
||||
description=f'Tier-based limit for {user_id}',
|
||||
)
|
||||
)
|
||||
else:
|
||||
# User has NO tier → Use per-user rate limit rules
|
||||
if user_id:
|
||||
# TODO: Query MongoDB for per-user rules
|
||||
# For now, use default per-user rule
|
||||
rules.append(RateLimitRule(
|
||||
rule_id='default_per_user',
|
||||
rule_type=RuleType.PER_USER,
|
||||
time_window=TimeWindow.MINUTE,
|
||||
limit=100,
|
||||
burst_allowance=20,
|
||||
priority=10,
|
||||
enabled=True,
|
||||
description="Default per-user limit"
|
||||
))
|
||||
|
||||
rules.append(
|
||||
RateLimitRule(
|
||||
rule_id='default_per_user',
|
||||
rule_type=RuleType.PER_USER,
|
||||
time_window=TimeWindow.MINUTE,
|
||||
limit=100,
|
||||
burst_allowance=20,
|
||||
priority=10,
|
||||
enabled=True,
|
||||
description='Default per-user limit',
|
||||
)
|
||||
)
|
||||
|
||||
# Always add global rule as fallback
|
||||
rules.append(RateLimitRule(
|
||||
rule_id='default_global',
|
||||
rule_type=RuleType.GLOBAL,
|
||||
time_window=TimeWindow.MINUTE,
|
||||
limit=1000,
|
||||
priority=0,
|
||||
enabled=True,
|
||||
description="Global rate limit"
|
||||
))
|
||||
|
||||
rules.append(
|
||||
RateLimitRule(
|
||||
rule_id='default_global',
|
||||
rule_type=RuleType.GLOBAL,
|
||||
time_window=TimeWindow.MINUTE,
|
||||
limit=1000,
|
||||
priority=0,
|
||||
enabled=True,
|
||||
description='Global rate limit',
|
||||
)
|
||||
)
|
||||
|
||||
# Sort by priority (highest first)
|
||||
rules.sort(key=lambda r: r.priority, reverse=True)
|
||||
|
||||
|
||||
return rules
|
||||
|
||||
async def _default_get_user_tier(self, user_id: str) -> Optional[TierLimits]:
|
||||
|
||||
async def _default_get_user_tier(self, user_id: str) -> TierLimits | None:
|
||||
"""
|
||||
Get user's tier limits from TierService
|
||||
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
||||
|
||||
Returns:
|
||||
TierLimits or None
|
||||
"""
|
||||
try:
|
||||
from services.tier_service import TierService, get_tier_service
|
||||
from services.tier_service import get_tier_service
|
||||
from utils.database_async import async_database
|
||||
|
||||
|
||||
tier_service = get_tier_service(async_database.db)
|
||||
limits = await tier_service.get_user_limits(user_id)
|
||||
|
||||
|
||||
return limits
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching user tier limits: {e}")
|
||||
logger.error(f'Error fetching user tier limits: {e}')
|
||||
return None
|
||||
|
||||
async def _check_quotas(
|
||||
self,
|
||||
user_id: str,
|
||||
tier_limits: TierLimits
|
||||
) -> 'QuotaCheckResult':
|
||||
|
||||
async def _check_quotas(self, user_id: str, tier_limits: TierLimits) -> 'QuotaCheckResult':
|
||||
"""
|
||||
Check user's quotas
|
||||
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
tier_limits: User's tier limits
|
||||
|
||||
|
||||
Returns:
|
||||
QuotaCheckResult
|
||||
"""
|
||||
# Check monthly quota
|
||||
if tier_limits.monthly_request_quota:
|
||||
result = self.quota_tracker.check_quota(
|
||||
user_id,
|
||||
QuotaType.REQUESTS,
|
||||
tier_limits.monthly_request_quota,
|
||||
'month'
|
||||
user_id, QuotaType.REQUESTS, tier_limits.monthly_request_quota, 'month'
|
||||
)
|
||||
|
||||
|
||||
if not result.allowed:
|
||||
return result
|
||||
|
||||
|
||||
# Check daily quota
|
||||
if tier_limits.daily_request_quota:
|
||||
result = self.quota_tracker.check_quota(
|
||||
user_id,
|
||||
QuotaType.REQUESTS,
|
||||
tier_limits.daily_request_quota,
|
||||
'day'
|
||||
user_id, QuotaType.REQUESTS, tier_limits.daily_request_quota, 'day'
|
||||
)
|
||||
|
||||
|
||||
if not result.allowed:
|
||||
return result
|
||||
|
||||
|
||||
# All quotas OK
|
||||
from utils.quota_tracker import QuotaCheckResult
|
||||
|
||||
return QuotaCheckResult(
|
||||
allowed=True,
|
||||
current_usage=0,
|
||||
limit=tier_limits.monthly_request_quota or 0,
|
||||
remaining=tier_limits.monthly_request_quota or 0,
|
||||
reset_at=self.quota_tracker._get_next_reset('month'),
|
||||
percentage_used=0.0
|
||||
percentage_used=0.0,
|
||||
)
|
||||
|
||||
|
||||
def _create_rate_limit_response(
|
||||
self,
|
||||
result: 'RateLimitResult',
|
||||
rule: RateLimitRule
|
||||
self, result: 'RateLimitResult', rule: RateLimitRule
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Create 429 response for rate limit exceeded
|
||||
|
||||
|
||||
Args:
|
||||
result: Rate limit result
|
||||
rule: Rule that was exceeded
|
||||
|
||||
|
||||
Returns:
|
||||
JSONResponse with 429 status
|
||||
"""
|
||||
info = result.to_info()
|
||||
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
content={
|
||||
@@ -423,21 +418,18 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
'limit': result.limit,
|
||||
'remaining': result.remaining,
|
||||
'reset_at': result.reset_at,
|
||||
'retry_after': result.retry_after
|
||||
'retry_after': result.retry_after,
|
||||
},
|
||||
headers=info.to_headers()
|
||||
headers=info.to_headers(),
|
||||
)
|
||||
|
||||
def _create_quota_exceeded_response(
|
||||
self,
|
||||
result: 'QuotaCheckResult'
|
||||
) -> JSONResponse:
|
||||
|
||||
def _create_quota_exceeded_response(self, result: 'QuotaCheckResult') -> JSONResponse:
|
||||
"""
|
||||
Create 429 response for quota exceeded
|
||||
|
||||
|
||||
Args:
|
||||
result: Quota check result
|
||||
|
||||
|
||||
Returns:
|
||||
JSONResponse with 429 status
|
||||
"""
|
||||
@@ -450,26 +442,22 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
'limit': result.limit,
|
||||
'remaining': result.remaining,
|
||||
'reset_at': result.reset_at.isoformat(),
|
||||
'percentage_used': result.percentage_used
|
||||
'percentage_used': result.percentage_used,
|
||||
},
|
||||
headers={
|
||||
'X-RateLimit-Limit': str(result.limit),
|
||||
'X-RateLimit-Remaining': str(result.remaining),
|
||||
'X-RateLimit-Reset': str(int(result.reset_at.timestamp())),
|
||||
'Retry-After': str(int((result.reset_at - datetime.now()).total_seconds()))
|
||||
}
|
||||
'Retry-After': str(int((result.reset_at - datetime.now()).total_seconds())),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _add_rate_limit_headers(
|
||||
self,
|
||||
response: Response,
|
||||
limit: int,
|
||||
remaining: int,
|
||||
reset_at: int
|
||||
self, response: Response, limit: int, remaining: int, reset_at: int
|
||||
):
|
||||
"""
|
||||
Add rate limit headers to response
|
||||
|
||||
|
||||
Args:
|
||||
response: Response object
|
||||
limit: Rate limit
|
||||
|
||||
@@ -8,14 +8,14 @@ Works alongside existing per-user rate limiting.
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
from models.rate_limit_models import TierLimits
|
||||
from services.tier_service import TierService, get_tier_service
|
||||
from services.tier_service import get_tier_service
|
||||
from utils.database_async import async_database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,19 +24,19 @@ logger = logging.getLogger(__name__)
|
||||
class TierRateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware for tier-based rate limiting and throttling
|
||||
|
||||
|
||||
Features:
|
||||
- Enforces tier-based rate limits (requests per minute/hour/day)
|
||||
- Supports throttling (queuing requests) vs hard rejection
|
||||
- Respects user-specific limit overrides
|
||||
- Adds rate limit headers to responses
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, app: ASGIApp):
|
||||
super().__init__(app)
|
||||
self._request_counts = {} # Simple in-memory counter (use Redis in production)
|
||||
self._request_queue = {} # Queue for throttling
|
||||
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""
|
||||
Process request through tier-based rate limiting
|
||||
@@ -44,244 +44,215 @@ class TierRateLimitMiddleware(BaseHTTPMiddleware):
|
||||
# Skip rate limiting for certain paths
|
||||
if self._should_skip(request):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# Extract user ID
|
||||
user_id = self._get_user_id(request)
|
||||
|
||||
|
||||
if not user_id:
|
||||
# No user ID, skip tier-based limiting
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# Get user's tier limits
|
||||
tier_service = get_tier_service(async_database.db)
|
||||
limits = await tier_service.get_user_limits(user_id)
|
||||
|
||||
|
||||
if not limits:
|
||||
# No tier limits configured, allow request
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# Check rate limits
|
||||
rate_limit_result = await self._check_rate_limits(user_id, limits)
|
||||
|
||||
|
||||
if not rate_limit_result['allowed']:
|
||||
# Check if throttling is enabled
|
||||
if limits.enable_throttling:
|
||||
# Try to queue the request
|
||||
queued = await self._try_queue_request(
|
||||
user_id,
|
||||
limits.max_queue_time_ms
|
||||
)
|
||||
|
||||
queued = await self._try_queue_request(user_id, limits.max_queue_time_ms)
|
||||
|
||||
if not queued:
|
||||
# Queue full or timeout, return 429
|
||||
return self._create_rate_limit_response(
|
||||
rate_limit_result,
|
||||
limits
|
||||
)
|
||||
|
||||
return self._create_rate_limit_response(rate_limit_result, limits)
|
||||
|
||||
# Request was queued and processed, continue
|
||||
else:
|
||||
# Throttling disabled, hard reject
|
||||
return self._create_rate_limit_response(
|
||||
rate_limit_result,
|
||||
limits
|
||||
)
|
||||
|
||||
return self._create_rate_limit_response(rate_limit_result, limits)
|
||||
|
||||
# Increment counters
|
||||
self._increment_counters(user_id, limits)
|
||||
|
||||
|
||||
# Process request
|
||||
response = await call_next(request)
|
||||
|
||||
|
||||
# Add rate limit headers
|
||||
self._add_rate_limit_headers(response, user_id, limits)
|
||||
|
||||
|
||||
return response
|
||||
|
||||
async def _check_rate_limits(
|
||||
self,
|
||||
user_id: str,
|
||||
limits: TierLimits
|
||||
) -> dict:
|
||||
|
||||
async def _check_rate_limits(self, user_id: str, limits: TierLimits) -> dict:
|
||||
"""
|
||||
Check if user has exceeded any rate limits
|
||||
|
||||
|
||||
Returns:
|
||||
dict with 'allowed' (bool) and 'limit_type' (str)
|
||||
"""
|
||||
now = int(time.time())
|
||||
|
||||
|
||||
# Check requests per minute
|
||||
if limits.requests_per_minute and limits.requests_per_minute < 999999:
|
||||
key = f"{user_id}:minute:{now // 60}"
|
||||
key = f'{user_id}:minute:{now // 60}'
|
||||
count = self._request_counts.get(key, 0)
|
||||
|
||||
|
||||
if count >= limits.requests_per_minute:
|
||||
return {
|
||||
'allowed': False,
|
||||
'limit_type': 'minute',
|
||||
'limit': limits.requests_per_minute,
|
||||
'current': count,
|
||||
'reset_at': ((now // 60) + 1) * 60
|
||||
'reset_at': ((now // 60) + 1) * 60,
|
||||
}
|
||||
|
||||
|
||||
# Check requests per hour
|
||||
if limits.requests_per_hour and limits.requests_per_hour < 999999:
|
||||
key = f"{user_id}:hour:{now // 3600}"
|
||||
key = f'{user_id}:hour:{now // 3600}'
|
||||
count = self._request_counts.get(key, 0)
|
||||
|
||||
|
||||
if count >= limits.requests_per_hour:
|
||||
return {
|
||||
'allowed': False,
|
||||
'limit_type': 'hour',
|
||||
'limit': limits.requests_per_hour,
|
||||
'current': count,
|
||||
'reset_at': ((now // 3600) + 1) * 3600
|
||||
'reset_at': ((now // 3600) + 1) * 3600,
|
||||
}
|
||||
|
||||
|
||||
# Check requests per day
|
||||
if limits.requests_per_day and limits.requests_per_day < 999999:
|
||||
key = f"{user_id}:day:{now // 86400}"
|
||||
key = f'{user_id}:day:{now // 86400}'
|
||||
count = self._request_counts.get(key, 0)
|
||||
|
||||
|
||||
if count >= limits.requests_per_day:
|
||||
return {
|
||||
'allowed': False,
|
||||
'limit_type': 'day',
|
||||
'limit': limits.requests_per_day,
|
||||
'current': count,
|
||||
'reset_at': ((now // 86400) + 1) * 86400
|
||||
'reset_at': ((now // 86400) + 1) * 86400,
|
||||
}
|
||||
|
||||
|
||||
return {'allowed': True}
|
||||
|
||||
|
||||
def _increment_counters(self, user_id: str, limits: TierLimits):
|
||||
"""Increment request counters for all time windows"""
|
||||
now = int(time.time())
|
||||
|
||||
|
||||
if limits.requests_per_minute:
|
||||
key = f"{user_id}:minute:{now // 60}"
|
||||
key = f'{user_id}:minute:{now // 60}'
|
||||
self._request_counts[key] = self._request_counts.get(key, 0) + 1
|
||||
|
||||
|
||||
if limits.requests_per_hour:
|
||||
key = f"{user_id}:hour:{now // 3600}"
|
||||
key = f'{user_id}:hour:{now // 3600}'
|
||||
self._request_counts[key] = self._request_counts.get(key, 0) + 1
|
||||
|
||||
|
||||
if limits.requests_per_day:
|
||||
key = f"{user_id}:day:{now // 86400}"
|
||||
key = f'{user_id}:day:{now // 86400}'
|
||||
self._request_counts[key] = self._request_counts.get(key, 0) + 1
|
||||
|
||||
async def _try_queue_request(
|
||||
self,
|
||||
user_id: str,
|
||||
max_wait_ms: int
|
||||
) -> bool:
|
||||
|
||||
async def _try_queue_request(self, user_id: str, max_wait_ms: int) -> bool:
|
||||
"""
|
||||
Try to queue request with throttling
|
||||
|
||||
|
||||
Returns:
|
||||
True if request was processed, False if timeout/rejected
|
||||
"""
|
||||
queue_key = f"{user_id}:queue"
|
||||
queue_key = f'{user_id}:queue'
|
||||
start_time = time.time() * 1000 # milliseconds
|
||||
|
||||
|
||||
# Initialize queue if needed
|
||||
if queue_key not in self._request_queue:
|
||||
self._request_queue[queue_key] = asyncio.Queue(maxsize=100)
|
||||
|
||||
|
||||
queue = self._request_queue[queue_key]
|
||||
|
||||
|
||||
try:
|
||||
# Add to queue with timeout
|
||||
await asyncio.wait_for(
|
||||
queue.put(1),
|
||||
timeout=max_wait_ms / 1000.0
|
||||
)
|
||||
|
||||
await asyncio.wait_for(queue.put(1), timeout=max_wait_ms / 1000.0)
|
||||
|
||||
# Wait for rate limit to reset
|
||||
while True:
|
||||
elapsed = (time.time() * 1000) - start_time
|
||||
|
||||
|
||||
if elapsed >= max_wait_ms:
|
||||
# Timeout exceeded
|
||||
await queue.get() # Remove from queue
|
||||
return False
|
||||
|
||||
|
||||
# Check if we can proceed
|
||||
# In a real implementation, check actual rate limit status
|
||||
await asyncio.sleep(0.1) # Small delay
|
||||
|
||||
|
||||
# For now, assume we can proceed after a short wait
|
||||
if elapsed >= 100: # 100ms min throttle delay
|
||||
await queue.get() # Remove from queue
|
||||
return True
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
|
||||
except TimeoutError:
|
||||
return False
|
||||
|
||||
def _create_rate_limit_response(
|
||||
self,
|
||||
result: dict,
|
||||
limits: TierLimits
|
||||
) -> JSONResponse:
|
||||
|
||||
def _create_rate_limit_response(self, result: dict, limits: TierLimits) -> JSONResponse:
|
||||
"""Create 429 Too Many Requests response"""
|
||||
retry_after = result.get('reset_at', 0) - int(time.time())
|
||||
|
||||
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={
|
||||
'error': 'Rate limit exceeded',
|
||||
'error_code': 'RATE_LIMIT_EXCEEDED',
|
||||
'message': f"Rate limit exceeded: {result.get('current', 0)}/{result.get('limit', 0)} requests per {result.get('limit_type', 'period')}",
|
||||
'message': f'Rate limit exceeded: {result.get("current", 0)}/{result.get("limit", 0)} requests per {result.get("limit_type", "period")}',
|
||||
'limit_type': result.get('limit_type'),
|
||||
'limit': result.get('limit'),
|
||||
'current': result.get('current'),
|
||||
'reset_at': result.get('reset_at'),
|
||||
'retry_after': max(0, retry_after),
|
||||
'throttling_enabled': limits.enable_throttling
|
||||
'throttling_enabled': limits.enable_throttling,
|
||||
},
|
||||
headers={
|
||||
'Retry-After': str(max(0, retry_after)),
|
||||
'X-RateLimit-Limit': str(result.get('limit', 0)),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
'X-RateLimit-Reset': str(result.get('reset_at', 0))
|
||||
}
|
||||
'X-RateLimit-Reset': str(result.get('reset_at', 0)),
|
||||
},
|
||||
)
|
||||
|
||||
def _add_rate_limit_headers(
|
||||
self,
|
||||
response: Response,
|
||||
user_id: str,
|
||||
limits: TierLimits
|
||||
):
|
||||
|
||||
def _add_rate_limit_headers(self, response: Response, user_id: str, limits: TierLimits):
|
||||
"""Add rate limit headers to response"""
|
||||
now = int(time.time())
|
||||
|
||||
|
||||
# Add headers for minute limit (most relevant)
|
||||
if limits.requests_per_minute:
|
||||
key = f"{user_id}:minute:{now // 60}"
|
||||
key = f'{user_id}:minute:{now // 60}'
|
||||
current = self._request_counts.get(key, 0)
|
||||
remaining = max(0, limits.requests_per_minute - current)
|
||||
reset_at = ((now // 60) + 1) * 60
|
||||
|
||||
|
||||
response.headers['X-RateLimit-Limit'] = str(limits.requests_per_minute)
|
||||
response.headers['X-RateLimit-Remaining'] = str(remaining)
|
||||
response.headers['X-RateLimit-Reset'] = str(reset_at)
|
||||
|
||||
|
||||
def _should_skip(self, request: Request) -> bool:
|
||||
"""Check if rate limiting should be skipped"""
|
||||
skip_paths = [
|
||||
'/health',
|
||||
'/metrics',
|
||||
'/docs',
|
||||
'/redoc',
|
||||
'/health',
|
||||
'/metrics',
|
||||
'/docs',
|
||||
'/redoc',
|
||||
'/openapi.json',
|
||||
'/platform/authorization' # Skip auth endpoints
|
||||
'/platform/authorization', # Skip auth endpoints
|
||||
]
|
||||
|
||||
|
||||
return any(request.url.path.startswith(path) for path in skip_paths)
|
||||
|
||||
def _get_user_id(self, request: Request) -> Optional[str]:
|
||||
|
||||
def _get_user_id(self, request: Request) -> str | None:
|
||||
"""Extract user ID from request"""
|
||||
# Try to get from request state (set by auth middleware)
|
||||
if hasattr(request.state, 'user'):
|
||||
@@ -290,9 +261,9 @@ class TierRateLimitMiddleware(BaseHTTPMiddleware):
|
||||
return user.username
|
||||
elif isinstance(user, dict):
|
||||
return user.get('username') or user.get('sub')
|
||||
|
||||
|
||||
# Try to get from JWT payload in state
|
||||
if hasattr(request.state, 'jwt_payload'):
|
||||
return request.state.jwt_payload.get('sub')
|
||||
|
||||
|
||||
return None
|
||||
|
||||
@@ -6,38 +6,41 @@ analytics capabilities while maintaining backward compatibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Deque
|
||||
from collections import deque, defaultdict
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class AggregationLevel(str, Enum):
|
||||
"""Time-based aggregation levels for metrics."""
|
||||
MINUTE = "minute"
|
||||
FIVE_MINUTE = "5minute"
|
||||
HOUR = "hour"
|
||||
DAY = "day"
|
||||
|
||||
MINUTE = 'minute'
|
||||
FIVE_MINUTE = '5minute'
|
||||
HOUR = 'hour'
|
||||
DAY = 'day'
|
||||
|
||||
|
||||
class MetricType(str, Enum):
|
||||
"""Types of metrics tracked."""
|
||||
REQUEST_COUNT = "request_count"
|
||||
ERROR_RATE = "error_rate"
|
||||
RESPONSE_TIME = "response_time"
|
||||
BANDWIDTH = "bandwidth"
|
||||
STATUS_CODE = "status_code"
|
||||
LATENCY_PERCENTILE = "latency_percentile"
|
||||
|
||||
REQUEST_COUNT = 'request_count'
|
||||
ERROR_RATE = 'error_rate'
|
||||
RESPONSE_TIME = 'response_time'
|
||||
BANDWIDTH = 'bandwidth'
|
||||
STATUS_CODE = 'status_code'
|
||||
LATENCY_PERCENTILE = 'latency_percentile'
|
||||
|
||||
|
||||
@dataclass
|
||||
class PercentileMetrics:
|
||||
"""
|
||||
Latency percentile calculations.
|
||||
|
||||
|
||||
Stores multiple percentiles for comprehensive performance analysis.
|
||||
Uses a reservoir sampling approach to maintain a representative sample.
|
||||
"""
|
||||
|
||||
p50: float = 0.0 # Median
|
||||
p75: float = 0.0 # 75th percentile
|
||||
p90: float = 0.0 # 90th percentile
|
||||
@@ -45,20 +48,20 @@ class PercentileMetrics:
|
||||
p99: float = 0.0 # 99th percentile
|
||||
min: float = 0.0 # Minimum latency
|
||||
max: float = 0.0 # Maximum latency
|
||||
|
||||
|
||||
@staticmethod
|
||||
def calculate(latencies: List[float]) -> 'PercentileMetrics':
|
||||
def calculate(latencies: list[float]) -> PercentileMetrics:
|
||||
"""Calculate percentiles from a list of latencies."""
|
||||
if not latencies:
|
||||
return PercentileMetrics()
|
||||
|
||||
|
||||
sorted_latencies = sorted(latencies)
|
||||
n = len(sorted_latencies)
|
||||
|
||||
|
||||
def percentile(p: float) -> float:
|
||||
k = max(0, int(p * n) - 1)
|
||||
return float(sorted_latencies[k])
|
||||
|
||||
|
||||
return PercentileMetrics(
|
||||
p50=percentile(0.50),
|
||||
p75=percentile(0.75),
|
||||
@@ -66,10 +69,10 @@ class PercentileMetrics:
|
||||
p95=percentile(0.95),
|
||||
p99=percentile(0.99),
|
||||
min=float(sorted_latencies[0]),
|
||||
max=float(sorted_latencies[-1])
|
||||
max=float(sorted_latencies[-1]),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'p50': self.p50,
|
||||
'p75': self.p75,
|
||||
@@ -77,7 +80,7 @@ class PercentileMetrics:
|
||||
'p95': self.p95,
|
||||
'p99': self.p99,
|
||||
'min': self.min,
|
||||
'max': self.max
|
||||
'max': self.max,
|
||||
}
|
||||
|
||||
|
||||
@@ -85,36 +88,37 @@ class PercentileMetrics:
|
||||
class EndpointMetrics:
|
||||
"""
|
||||
Per-endpoint performance metrics.
|
||||
|
||||
|
||||
Tracks detailed metrics for individual API endpoints to identify
|
||||
performance bottlenecks at a granular level.
|
||||
"""
|
||||
|
||||
endpoint_uri: str
|
||||
method: str
|
||||
count: int = 0
|
||||
error_count: int = 0
|
||||
total_ms: float = 0.0
|
||||
latencies: Deque[float] = field(default_factory=deque)
|
||||
status_counts: Dict[int, int] = field(default_factory=dict)
|
||||
|
||||
latencies: deque[float] = field(default_factory=deque)
|
||||
status_counts: dict[int, int] = field(default_factory=dict)
|
||||
|
||||
def add(self, ms: float, status: int, max_samples: int = 500) -> None:
|
||||
"""Record a request for this endpoint."""
|
||||
self.count += 1
|
||||
if status >= 400:
|
||||
self.error_count += 1
|
||||
self.total_ms += ms
|
||||
|
||||
|
||||
self.status_counts[status] = self.status_counts.get(status, 0) + 1
|
||||
|
||||
|
||||
self.latencies.append(ms)
|
||||
while len(self.latencies) > max_samples:
|
||||
self.latencies.popleft()
|
||||
|
||||
|
||||
def get_percentiles(self) -> PercentileMetrics:
|
||||
"""Calculate percentiles for this endpoint."""
|
||||
return PercentileMetrics.calculate(list(self.latencies))
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
percentiles = self.get_percentiles()
|
||||
return {
|
||||
'endpoint_uri': self.endpoint_uri,
|
||||
@@ -124,7 +128,7 @@ class EndpointMetrics:
|
||||
'error_rate': (self.error_count / self.count) if self.count > 0 else 0.0,
|
||||
'avg_ms': (self.total_ms / self.count) if self.count > 0 else 0.0,
|
||||
'percentiles': percentiles.to_dict(),
|
||||
'status_counts': dict(self.status_counts)
|
||||
'status_counts': dict(self.status_counts),
|
||||
}
|
||||
|
||||
|
||||
@@ -132,13 +136,14 @@ class EndpointMetrics:
|
||||
class EnhancedMinuteBucket:
|
||||
"""
|
||||
Enhanced version of MinuteBucket with additional analytics.
|
||||
|
||||
|
||||
Extends the existing MinuteBucket from metrics_util.py with:
|
||||
- Per-endpoint tracking
|
||||
- Full percentile calculations (p50, p75, p90, p95, p99)
|
||||
- Unique user tracking
|
||||
- Request/response size tracking
|
||||
"""
|
||||
|
||||
start_ts: int
|
||||
count: int = 0
|
||||
error_count: int = 0
|
||||
@@ -147,35 +152,35 @@ class EnhancedMinuteBucket:
|
||||
bytes_out: int = 0
|
||||
upstream_timeouts: int = 0
|
||||
retries: int = 0
|
||||
|
||||
|
||||
# Existing tracking (compatible with metrics_util.py)
|
||||
status_counts: Dict[int, int] = field(default_factory=dict)
|
||||
api_counts: Dict[str, int] = field(default_factory=dict)
|
||||
api_error_counts: Dict[str, int] = field(default_factory=dict)
|
||||
user_counts: Dict[str, int] = field(default_factory=dict)
|
||||
latencies: Deque[float] = field(default_factory=deque)
|
||||
|
||||
status_counts: dict[int, int] = field(default_factory=dict)
|
||||
api_counts: dict[str, int] = field(default_factory=dict)
|
||||
api_error_counts: dict[str, int] = field(default_factory=dict)
|
||||
user_counts: dict[str, int] = field(default_factory=dict)
|
||||
latencies: deque[float] = field(default_factory=deque)
|
||||
|
||||
# NEW: Enhanced tracking
|
||||
endpoint_metrics: Dict[str, EndpointMetrics] = field(default_factory=dict)
|
||||
endpoint_metrics: dict[str, EndpointMetrics] = field(default_factory=dict)
|
||||
unique_users: set = field(default_factory=set)
|
||||
request_sizes: Deque[int] = field(default_factory=deque)
|
||||
response_sizes: Deque[int] = field(default_factory=deque)
|
||||
|
||||
request_sizes: deque[int] = field(default_factory=deque)
|
||||
response_sizes: deque[int] = field(default_factory=deque)
|
||||
|
||||
def add_request(
|
||||
self,
|
||||
ms: float,
|
||||
status: int,
|
||||
username: Optional[str],
|
||||
api_key: Optional[str],
|
||||
endpoint_uri: Optional[str] = None,
|
||||
method: Optional[str] = None,
|
||||
username: str | None,
|
||||
api_key: str | None,
|
||||
endpoint_uri: str | None = None,
|
||||
method: str | None = None,
|
||||
bytes_in: int = 0,
|
||||
bytes_out: int = 0,
|
||||
max_samples: int = 500
|
||||
max_samples: int = 500,
|
||||
) -> None:
|
||||
"""
|
||||
Record a request with enhanced tracking.
|
||||
|
||||
|
||||
Compatible with existing metrics_util.py while adding new capabilities.
|
||||
"""
|
||||
# Existing tracking (backward compatible)
|
||||
@@ -185,62 +190,61 @@ class EnhancedMinuteBucket:
|
||||
self.total_ms += ms
|
||||
self.bytes_in += bytes_in
|
||||
self.bytes_out += bytes_out
|
||||
|
||||
|
||||
self.status_counts[status] = self.status_counts.get(status, 0) + 1
|
||||
|
||||
|
||||
if api_key:
|
||||
self.api_counts[api_key] = self.api_counts.get(api_key, 0) + 1
|
||||
if status >= 400:
|
||||
self.api_error_counts[api_key] = self.api_error_counts.get(api_key, 0) + 1
|
||||
|
||||
|
||||
if username:
|
||||
self.user_counts[username] = self.user_counts.get(username, 0) + 1
|
||||
self.unique_users.add(username)
|
||||
|
||||
|
||||
self.latencies.append(ms)
|
||||
while len(self.latencies) > max_samples:
|
||||
self.latencies.popleft()
|
||||
|
||||
|
||||
# NEW: Per-endpoint tracking
|
||||
if endpoint_uri and method:
|
||||
endpoint_key = f"{method}:{endpoint_uri}"
|
||||
endpoint_key = f'{method}:{endpoint_uri}'
|
||||
if endpoint_key not in self.endpoint_metrics:
|
||||
self.endpoint_metrics[endpoint_key] = EndpointMetrics(
|
||||
endpoint_uri=endpoint_uri,
|
||||
method=method
|
||||
endpoint_uri=endpoint_uri, method=method
|
||||
)
|
||||
self.endpoint_metrics[endpoint_key].add(ms, status, max_samples)
|
||||
|
||||
|
||||
# NEW: Request/response size tracking
|
||||
if bytes_in > 0:
|
||||
self.request_sizes.append(bytes_in)
|
||||
while len(self.request_sizes) > max_samples:
|
||||
self.request_sizes.popleft()
|
||||
|
||||
|
||||
if bytes_out > 0:
|
||||
self.response_sizes.append(bytes_out)
|
||||
while len(self.response_sizes) > max_samples:
|
||||
self.response_sizes.popleft()
|
||||
|
||||
|
||||
def get_percentiles(self) -> PercentileMetrics:
|
||||
"""Calculate full percentiles for this bucket."""
|
||||
return PercentileMetrics.calculate(list(self.latencies))
|
||||
|
||||
|
||||
def get_unique_user_count(self) -> int:
|
||||
"""Get count of unique users in this bucket."""
|
||||
return len(self.unique_users)
|
||||
|
||||
def get_top_endpoints(self, limit: int = 10) -> List[Dict]:
|
||||
|
||||
def get_top_endpoints(self, limit: int = 10) -> list[dict]:
|
||||
"""Get top N slowest/most-used endpoints."""
|
||||
endpoints = [ep.to_dict() for ep in self.endpoint_metrics.values()]
|
||||
# Sort by count (most used)
|
||||
endpoints.sort(key=lambda x: x['count'], reverse=True)
|
||||
return endpoints[:limit]
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize to dictionary (backward compatible + enhanced)."""
|
||||
percentiles = self.get_percentiles()
|
||||
|
||||
|
||||
return {
|
||||
# Existing fields (backward compatible)
|
||||
'start_ts': self.start_ts,
|
||||
@@ -255,13 +259,16 @@ class EnhancedMinuteBucket:
|
||||
'api_counts': dict(self.api_counts),
|
||||
'api_error_counts': dict(self.api_error_counts),
|
||||
'user_counts': dict(self.user_counts),
|
||||
|
||||
# NEW: Enhanced fields
|
||||
'percentiles': percentiles.to_dict(),
|
||||
'unique_users': self.get_unique_user_count(),
|
||||
'endpoint_metrics': {k: v.to_dict() for k, v in self.endpoint_metrics.items()},
|
||||
'avg_request_size': sum(self.request_sizes) / len(self.request_sizes) if self.request_sizes else 0,
|
||||
'avg_response_size': sum(self.response_sizes) / len(self.response_sizes) if self.response_sizes else 0,
|
||||
'avg_request_size': sum(self.request_sizes) / len(self.request_sizes)
|
||||
if self.request_sizes
|
||||
else 0,
|
||||
'avg_response_size': sum(self.response_sizes) / len(self.response_sizes)
|
||||
if self.response_sizes
|
||||
else 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -269,10 +276,11 @@ class EnhancedMinuteBucket:
|
||||
class AggregatedMetrics:
|
||||
"""
|
||||
Multi-level aggregated metrics (5-minute, hourly, daily).
|
||||
|
||||
|
||||
Used for efficient querying of historical data without
|
||||
scanning all minute-level buckets.
|
||||
"""
|
||||
|
||||
start_ts: int
|
||||
end_ts: int
|
||||
level: AggregationLevel
|
||||
@@ -282,12 +290,12 @@ class AggregatedMetrics:
|
||||
bytes_in: int = 0
|
||||
bytes_out: int = 0
|
||||
unique_users: int = 0
|
||||
|
||||
status_counts: Dict[int, int] = field(default_factory=dict)
|
||||
api_counts: Dict[str, int] = field(default_factory=dict)
|
||||
percentiles: Optional[PercentileMetrics] = None
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
|
||||
status_counts: dict[int, int] = field(default_factory=dict)
|
||||
api_counts: dict[str, int] = field(default_factory=dict)
|
||||
percentiles: PercentileMetrics | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'start_ts': self.start_ts,
|
||||
'end_ts': self.end_ts,
|
||||
@@ -301,7 +309,7 @@ class AggregatedMetrics:
|
||||
'unique_users': self.unique_users,
|
||||
'status_counts': dict(self.status_counts),
|
||||
'api_counts': dict(self.api_counts),
|
||||
'percentiles': self.percentiles.to_dict() if self.percentiles else None
|
||||
'percentiles': self.percentiles.to_dict() if self.percentiles else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -309,9 +317,10 @@ class AggregatedMetrics:
|
||||
class AnalyticsSnapshot:
|
||||
"""
|
||||
Complete analytics snapshot for a time range.
|
||||
|
||||
|
||||
Used as the response format for analytics API endpoints.
|
||||
"""
|
||||
|
||||
start_ts: int
|
||||
end_ts: int
|
||||
total_requests: int
|
||||
@@ -322,19 +331,19 @@ class AnalyticsSnapshot:
|
||||
total_bytes_in: int
|
||||
total_bytes_out: int
|
||||
unique_users: int
|
||||
|
||||
|
||||
# Time-series data
|
||||
series: List[Dict]
|
||||
|
||||
series: list[dict]
|
||||
|
||||
# Top N lists
|
||||
top_apis: List[tuple]
|
||||
top_users: List[tuple]
|
||||
top_endpoints: List[Dict]
|
||||
|
||||
top_apis: list[tuple]
|
||||
top_users: list[tuple]
|
||||
top_endpoints: list[dict]
|
||||
|
||||
# Status code distribution
|
||||
status_distribution: Dict[str, int]
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
status_distribution: dict[str, int]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'start_ts': self.start_ts,
|
||||
'end_ts': self.end_ts,
|
||||
@@ -346,11 +355,11 @@ class AnalyticsSnapshot:
|
||||
'percentiles': self.percentiles.to_dict(),
|
||||
'total_bytes_in': self.total_bytes_in,
|
||||
'total_bytes_out': self.total_bytes_out,
|
||||
'unique_users': self.unique_users
|
||||
'unique_users': self.unique_users,
|
||||
},
|
||||
'series': self.series,
|
||||
'top_apis': [{'api': api, 'count': count} for api, count in self.top_apis],
|
||||
'top_users': [{'user': user, 'count': count} for user, count in self.top_users],
|
||||
'top_endpoints': self.top_endpoints,
|
||||
'status_distribution': self.status_distribution
|
||||
'status_distribution': self.status_distribution,
|
||||
}
|
||||
|
||||
@@ -5,24 +5,61 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class ApiModelResponse(BaseModel):
|
||||
|
||||
api_name: Optional[str] = Field(None, min_length=1, max_length=25, description='Name of the API', example='customer')
|
||||
api_version: Optional[str] = Field(None, min_length=1, max_length=8, description='Version of the API', example='v1')
|
||||
api_description: Optional[str] = Field(None, min_length=1, max_length=127, description='Description of the API', example='New customer onboarding API')
|
||||
api_allowed_roles: Optional[List[str]] = Field(None, description='Allowed user roles for the API', example=['admin', 'user'])
|
||||
api_allowed_groups: Optional[List[str]] = Field(None, description='Allowed user groups for the API' , example=['admin', 'client-1-group'])
|
||||
api_servers: Optional[List[str]] = Field(None, description='List of backend servers for the API', example=['http://localhost:8080', 'http://localhost:8081'])
|
||||
api_type: Optional[str] = Field(None, description="Type of the API. Valid values: 'REST'", example='REST')
|
||||
api_authorization_field_swap: Optional[str] = Field(None, description='Header to swap for backend authorization header', example='backend-auth-header')
|
||||
api_allowed_headers: Optional[List[str]] = Field(None, description='Allowed headers for the API', example=['Content-Type', 'Authorization'])
|
||||
api_allowed_retry_count: Optional[int] = Field(None, description='Number of allowed retries for the API', example=0)
|
||||
api_credits_enabled: Optional[bool] = Field(False, description='Enable credit-based authentication for the API', example=True)
|
||||
api_credit_group: Optional[str] = Field(None, description='API credit group for the API credits', example='ai-group-1')
|
||||
api_id: Optional[str] = Field(None, description='Unique identifier for the API, auto-generated', example='c3eda315-545a-4fef-a831-7e45e2f68987')
|
||||
api_path: Optional[str] = Field(None, description='Unqiue path for the API, auto-generated', example='/customer/v1')
|
||||
api_name: str | None = Field(
|
||||
None, min_length=1, max_length=25, description='Name of the API', example='customer'
|
||||
)
|
||||
api_version: str | None = Field(
|
||||
None, min_length=1, max_length=8, description='Version of the API', example='v1'
|
||||
)
|
||||
api_description: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=127,
|
||||
description='Description of the API',
|
||||
example='New customer onboarding API',
|
||||
)
|
||||
api_allowed_roles: list[str] | None = Field(
|
||||
None, description='Allowed user roles for the API', example=['admin', 'user']
|
||||
)
|
||||
api_allowed_groups: list[str] | None = Field(
|
||||
None, description='Allowed user groups for the API', example=['admin', 'client-1-group']
|
||||
)
|
||||
api_servers: list[str] | None = Field(
|
||||
None,
|
||||
description='List of backend servers for the API',
|
||||
example=['http://localhost:8080', 'http://localhost:8081'],
|
||||
)
|
||||
api_type: str | None = Field(
|
||||
None, description="Type of the API. Valid values: 'REST'", example='REST'
|
||||
)
|
||||
api_authorization_field_swap: str | None = Field(
|
||||
None,
|
||||
description='Header to swap for backend authorization header',
|
||||
example='backend-auth-header',
|
||||
)
|
||||
api_allowed_headers: list[str] | None = Field(
|
||||
None, description='Allowed headers for the API', example=['Content-Type', 'Authorization']
|
||||
)
|
||||
api_allowed_retry_count: int | None = Field(
|
||||
None, description='Number of allowed retries for the API', example=0
|
||||
)
|
||||
api_credits_enabled: bool | None = Field(
|
||||
False, description='Enable credit-based authentication for the API', example=True
|
||||
)
|
||||
api_credit_group: str | None = Field(
|
||||
None, description='API credit group for the API credits', example='ai-group-1'
|
||||
)
|
||||
api_id: str | None = Field(
|
||||
None,
|
||||
description='Unique identifier for the API, auto-generated',
|
||||
example='c3eda315-545a-4fef-a831-7e45e2f68987',
|
||||
)
|
||||
api_path: str | None = Field(
|
||||
None, description='Unqiue path for the API, auto-generated', example='/customer/v1'
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -5,46 +5,127 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class CreateApiModel(BaseModel):
|
||||
|
||||
api_name: str = Field(..., min_length=1, max_length=64, description='Name of the API', example='customer')
|
||||
api_version: str = Field(..., min_length=1, max_length=8, description='Version of the API', example='v1')
|
||||
api_description: Optional[str] = Field(None, max_length=127, description='Description of the API', example='New customer onboarding API')
|
||||
api_allowed_roles: List[str] = Field(default_factory=list, description='Allowed user roles for the API', example=['admin', 'user'])
|
||||
api_allowed_groups: List[str] = Field(default_factory=list, description='Allowed user groups for the API' , example=['admin', 'client-1-group'])
|
||||
api_servers: List[str] = Field(default_factory=list, description='List of backend servers for the API', example=['http://localhost:8080', 'http://localhost:8081'])
|
||||
api_name: str = Field(
|
||||
..., min_length=1, max_length=64, description='Name of the API', example='customer'
|
||||
)
|
||||
api_version: str = Field(
|
||||
..., min_length=1, max_length=8, description='Version of the API', example='v1'
|
||||
)
|
||||
api_description: str | None = Field(
|
||||
None,
|
||||
max_length=127,
|
||||
description='Description of the API',
|
||||
example='New customer onboarding API',
|
||||
)
|
||||
api_allowed_roles: list[str] = Field(
|
||||
default_factory=list,
|
||||
description='Allowed user roles for the API',
|
||||
example=['admin', 'user'],
|
||||
)
|
||||
api_allowed_groups: list[str] = Field(
|
||||
default_factory=list,
|
||||
description='Allowed user groups for the API',
|
||||
example=['admin', 'client-1-group'],
|
||||
)
|
||||
api_servers: list[str] = Field(
|
||||
default_factory=list,
|
||||
description='List of backend servers for the API',
|
||||
example=['http://localhost:8080', 'http://localhost:8081'],
|
||||
)
|
||||
api_type: str = Field(None, description="Type of the API. Valid values: 'REST'", example='REST')
|
||||
api_allowed_retry_count: int = Field(0, description='Number of allowed retries for the API', example=0)
|
||||
api_grpc_package: Optional[str] = Field(None, description='Optional gRPC Python package to use for this API (e.g., "my.pkg"). When set, overrides request package and default.', example='my.pkg')
|
||||
api_grpc_allowed_packages: Optional[List[str]] = Field(None, description='Allow-list of gRPC package/module base names (no dots). If set, requests must match one of these.', example=['customer_v1'])
|
||||
api_grpc_allowed_services: Optional[List[str]] = Field(None, description='Allow-list of gRPC service names (e.g., Greeter). If set, only these services are permitted.', example=['Greeter'])
|
||||
api_grpc_allowed_methods: Optional[List[str]] = Field(None, description='Allow-list of gRPC methods as Service.Method strings. If set, only these methods are permitted.', example=['Greeter.SayHello'])
|
||||
api_allowed_retry_count: int = Field(
|
||||
0, description='Number of allowed retries for the API', example=0
|
||||
)
|
||||
api_grpc_package: str | None = Field(
|
||||
None,
|
||||
description='Optional gRPC Python package to use for this API (e.g., "my.pkg"). When set, overrides request package and default.',
|
||||
example='my.pkg',
|
||||
)
|
||||
api_grpc_allowed_packages: list[str] | None = Field(
|
||||
None,
|
||||
description='Allow-list of gRPC package/module base names (no dots). If set, requests must match one of these.',
|
||||
example=['customer_v1'],
|
||||
)
|
||||
api_grpc_allowed_services: list[str] | None = Field(
|
||||
None,
|
||||
description='Allow-list of gRPC service names (e.g., Greeter). If set, only these services are permitted.',
|
||||
example=['Greeter'],
|
||||
)
|
||||
api_grpc_allowed_methods: list[str] | None = Field(
|
||||
None,
|
||||
description='Allow-list of gRPC methods as Service.Method strings. If set, only these methods are permitted.',
|
||||
example=['Greeter.SayHello'],
|
||||
)
|
||||
|
||||
api_authorization_field_swap: Optional[str] = Field(None, description='Header to swap for backend authorization header', example='backend-auth-header')
|
||||
api_allowed_headers: Optional[List[str]] = Field(None, description='Allowed headers for the API', example=['Content-Type', 'Authorization'])
|
||||
api_credits_enabled: Optional[bool] = Field(False, description='Enable credit-based authentication for the API', example=True)
|
||||
api_credit_group: Optional[str] = Field(None, description='API credit group for the API credits', example='ai-group-1')
|
||||
active: Optional[bool] = Field(True, description='Whether the API is active (enabled)', example=True)
|
||||
api_authorization_field_swap: str | None = Field(
|
||||
None,
|
||||
description='Header to swap for backend authorization header',
|
||||
example='backend-auth-header',
|
||||
)
|
||||
api_allowed_headers: list[str] | None = Field(
|
||||
None, description='Allowed headers for the API', example=['Content-Type', 'Authorization']
|
||||
)
|
||||
api_credits_enabled: bool | None = Field(
|
||||
False, description='Enable credit-based authentication for the API', example=True
|
||||
)
|
||||
api_credit_group: str | None = Field(
|
||||
None, description='API credit group for the API credits', example='ai-group-1'
|
||||
)
|
||||
active: bool | None = Field(
|
||||
True, description='Whether the API is active (enabled)', example=True
|
||||
)
|
||||
|
||||
api_cors_allow_origins: Optional[List[str]] = Field(None, description="Allowed origins for CORS (e.g., ['http://localhost:3000']). Use ['*'] to allow all.")
|
||||
api_cors_allow_methods: Optional[List[str]] = Field(None, description="Allowed methods for CORS preflight (e.g., ['GET','POST','PUT','DELETE','OPTIONS'])")
|
||||
api_cors_allow_headers: Optional[List[str]] = Field(None, description="Allowed request headers for CORS preflight (e.g., ['Content-Type','Authorization'])")
|
||||
api_cors_allow_credentials: Optional[bool] = Field(False, description='Whether to include Access-Control-Allow-Credentials=true in responses')
|
||||
api_cors_expose_headers: Optional[List[str]] = Field(None, description='Response headers to expose to the browser via Access-Control-Expose-Headers')
|
||||
api_cors_allow_origins: list[str] | None = Field(
|
||||
None,
|
||||
description="Allowed origins for CORS (e.g., ['http://localhost:3000']). Use ['*'] to allow all.",
|
||||
)
|
||||
api_cors_allow_methods: list[str] | None = Field(
|
||||
None,
|
||||
description="Allowed methods for CORS preflight (e.g., ['GET','POST','PUT','DELETE','OPTIONS'])",
|
||||
)
|
||||
api_cors_allow_headers: list[str] | None = Field(
|
||||
None,
|
||||
description="Allowed request headers for CORS preflight (e.g., ['Content-Type','Authorization'])",
|
||||
)
|
||||
api_cors_allow_credentials: bool | None = Field(
|
||||
False, description='Whether to include Access-Control-Allow-Credentials=true in responses'
|
||||
)
|
||||
api_cors_expose_headers: list[str] | None = Field(
|
||||
None,
|
||||
description='Response headers to expose to the browser via Access-Control-Expose-Headers',
|
||||
)
|
||||
|
||||
api_public: Optional[bool] = Field(False, description='If true, this API can be called without authentication or subscription')
|
||||
api_public: bool | None = Field(
|
||||
False, description='If true, this API can be called without authentication or subscription'
|
||||
)
|
||||
|
||||
api_auth_required: Optional[bool] = Field(True, description='If true (default), JWT auth is required for this API when not public. If false, requests may be unauthenticated but must meet other checks as configured.')
|
||||
api_auth_required: bool | None = Field(
|
||||
True,
|
||||
description='If true (default), JWT auth is required for this API when not public. If false, requests may be unauthenticated but must meet other checks as configured.',
|
||||
)
|
||||
|
||||
api_id: Optional[str] = Field(None, description='Unique identifier for the API, auto-generated', example=None)
|
||||
api_path: Optional[str] = Field(None, description='Unique path for the API, auto-generated', example=None)
|
||||
api_id: str | None = Field(
|
||||
None, description='Unique identifier for the API, auto-generated', example=None
|
||||
)
|
||||
api_path: str | None = Field(
|
||||
None, description='Unique path for the API, auto-generated', example=None
|
||||
)
|
||||
|
||||
api_ip_mode: Optional[str] = Field('allow_all', description="IP policy mode: 'allow_all' or 'whitelist'")
|
||||
api_ip_whitelist: Optional[List[str]] = Field(None, description='Allowed IPs/CIDRs when api_ip_mode=whitelist')
|
||||
api_ip_blacklist: Optional[List[str]] = Field(None, description='IPs/CIDRs denied regardless of mode')
|
||||
api_trust_x_forwarded_for: Optional[bool] = Field(None, description='Override: trust X-Forwarded-For for this API')
|
||||
api_ip_mode: str | None = Field(
|
||||
'allow_all', description="IP policy mode: 'allow_all' or 'whitelist'"
|
||||
)
|
||||
api_ip_whitelist: list[str] | None = Field(
|
||||
None, description='Allowed IPs/CIDRs when api_ip_mode=whitelist'
|
||||
)
|
||||
api_ip_blacklist: list[str] | None = Field(
|
||||
None, description='IPs/CIDRs denied regardless of mode'
|
||||
)
|
||||
api_trust_x_forwarded_for: bool | None = Field(
|
||||
None, description='Override: trust X-Forwarded-For for this API'
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -5,19 +5,40 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class CreateEndpointModel(BaseModel):
|
||||
api_name: str = Field(
|
||||
..., min_length=1, max_length=50, description='Name of the API', example='customer'
|
||||
)
|
||||
api_version: str = Field(
|
||||
..., min_length=1, max_length=10, description='Version of the API', example='v1'
|
||||
)
|
||||
endpoint_method: str = Field(
|
||||
..., min_length=1, max_length=10, description='HTTP method for the endpoint', example='GET'
|
||||
)
|
||||
endpoint_uri: str = Field(
|
||||
..., min_length=1, max_length=255, description='URI for the endpoint', example='/customer'
|
||||
)
|
||||
endpoint_description: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description='Description of the endpoint',
|
||||
example='Get customer details',
|
||||
)
|
||||
endpoint_servers: list[str] | None = Field(
|
||||
None,
|
||||
description='Optional list of backend servers for this endpoint (overrides API servers)',
|
||||
example=['http://localhost:8082', 'http://localhost:8083'],
|
||||
)
|
||||
|
||||
api_name: str = Field(..., min_length=1, max_length=50, description='Name of the API', example='customer')
|
||||
api_version: str = Field(..., min_length=1, max_length=10, description='Version of the API', example='v1')
|
||||
endpoint_method: str = Field(..., min_length=1, max_length=10, description='HTTP method for the endpoint', example='GET')
|
||||
endpoint_uri: str = Field(..., min_length=1, max_length=255, description='URI for the endpoint', example='/customer')
|
||||
endpoint_description: str = Field(..., min_length=1, max_length=255, description='Description of the endpoint', example='Get customer details')
|
||||
endpoint_servers: Optional[List[str]] = Field(None, description='Optional list of backend servers for this endpoint (overrides API servers)', example=['http://localhost:8082', 'http://localhost:8083'])
|
||||
|
||||
api_id: Optional[str] = Field(None, description='Unique identifier for the API, auto-generated', example=None)
|
||||
endpoint_id: Optional[str] = Field(None, description='Unique identifier for the endpoint, auto-generated', example=None)
|
||||
api_id: str | None = Field(
|
||||
None, description='Unique identifier for the API, auto-generated', example=None
|
||||
)
|
||||
endpoint_id: str | None = Field(
|
||||
None, description='Unique identifier for the endpoint, auto-generated', example=None
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -8,11 +8,19 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from models.validation_schema_model import ValidationSchema
|
||||
|
||||
class CreateEndpointValidationModel(BaseModel):
|
||||
|
||||
endpoint_id: str = Field(..., description='Unique identifier for the endpoint, auto-generated', example='1299f720-e619-4628-b584-48a6570026cf')
|
||||
validation_enabled: bool = Field(..., description='Whether the validation is enabled', example=True)
|
||||
validation_schema: ValidationSchema = Field(..., description='The schema to validate the endpoint against', example={})
|
||||
class CreateEndpointValidationModel(BaseModel):
|
||||
endpoint_id: str = Field(
|
||||
...,
|
||||
description='Unique identifier for the endpoint, auto-generated',
|
||||
example='1299f720-e619-4628-b584-48a6570026cf',
|
||||
)
|
||||
validation_enabled: bool = Field(
|
||||
..., description='Whether the validation is enabled', example=True
|
||||
)
|
||||
validation_schema: ValidationSchema = Field(
|
||||
..., description='The schema to validate the endpoint against', example={}
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -5,14 +5,21 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class CreateGroupModel(BaseModel):
|
||||
group_name: str = Field(
|
||||
..., min_length=1, max_length=50, description='Name of the group', example='client-1-group'
|
||||
)
|
||||
|
||||
group_name: str = Field(..., min_length=1, max_length=50, description='Name of the group', example='client-1-group')
|
||||
|
||||
group_description: Optional[str] = Field(None, max_length=255, description='Description of the group', example='Group for client 1')
|
||||
api_access: Optional[List[str]] = Field(default_factory=list, description='List of APIs the group can access', example=['customer/v1'])
|
||||
group_description: str | None = Field(
|
||||
None, max_length=255, description='Description of the group', example='Group for client 1'
|
||||
)
|
||||
api_access: list[str] | None = Field(
|
||||
default_factory=list,
|
||||
description='List of APIs the group can access',
|
||||
example=['customer/v1'],
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -5,26 +5,46 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class CreateRoleModel(BaseModel):
|
||||
|
||||
role_name: str = Field(..., min_length=1, max_length=50, description='Name of the role', example='admin')
|
||||
role_description: Optional[str] = Field(None, max_length=255, description='Description of the role', example='Administrator role with full access')
|
||||
role_name: str = Field(
|
||||
..., min_length=1, max_length=50, description='Name of the role', example='admin'
|
||||
)
|
||||
role_description: str | None = Field(
|
||||
None,
|
||||
max_length=255,
|
||||
description='Description of the role',
|
||||
example='Administrator role with full access',
|
||||
)
|
||||
manage_users: bool = Field(False, description='Permission to manage users', example=True)
|
||||
manage_apis: bool = Field(False, description='Permission to manage APIs', example=True)
|
||||
manage_endpoints: bool = Field(False, description='Permission to manage endpoints', example=True)
|
||||
manage_endpoints: bool = Field(
|
||||
False, description='Permission to manage endpoints', example=True
|
||||
)
|
||||
manage_groups: bool = Field(False, description='Permission to manage groups', example=True)
|
||||
manage_roles: bool = Field(False, description='Permission to manage roles', example=True)
|
||||
manage_routings: bool = Field(False, description='Permission to manage routings', example=True)
|
||||
manage_gateway: bool = Field(False, description='Permission to manage gateway', example=True)
|
||||
manage_subscriptions: bool = Field(False, description='Permission to manage subscriptions', example=True)
|
||||
manage_security: bool = Field(False, description='Permission to manage security settings', example=True)
|
||||
manage_tiers: bool = Field(False, description='Permission to manage pricing tiers', example=True)
|
||||
manage_rate_limits: bool = Field(False, description='Permission to manage rate limiting rules', example=True)
|
||||
manage_subscriptions: bool = Field(
|
||||
False, description='Permission to manage subscriptions', example=True
|
||||
)
|
||||
manage_security: bool = Field(
|
||||
False, description='Permission to manage security settings', example=True
|
||||
)
|
||||
manage_tiers: bool = Field(
|
||||
False, description='Permission to manage pricing tiers', example=True
|
||||
)
|
||||
manage_rate_limits: bool = Field(
|
||||
False, description='Permission to manage rate limiting rules', example=True
|
||||
)
|
||||
manage_credits: bool = Field(False, description='Permission to manage credits', example=True)
|
||||
manage_auth: bool = Field(False, description='Permission to manage auth (revoke tokens/disable users)', example=True)
|
||||
view_analytics: bool = Field(False, description='Permission to view analytics dashboard', example=True)
|
||||
manage_auth: bool = Field(
|
||||
False, description='Permission to manage auth (revoke tokens/disable users)', example=True
|
||||
)
|
||||
view_analytics: bool = Field(
|
||||
False, description='Permission to view analytics dashboard', example=True
|
||||
)
|
||||
view_logs: bool = Field(False, description='Permission to view logs', example=True)
|
||||
export_logs: bool = Field(False, description='Permission to export logs', example=True)
|
||||
|
||||
|
||||
@@ -5,16 +5,40 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class CreateRoutingModel(BaseModel):
|
||||
routing_name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=50,
|
||||
description='Name of the routing',
|
||||
example='customer-routing',
|
||||
)
|
||||
routing_servers: list[str] = Field(
|
||||
...,
|
||||
min_items=1,
|
||||
description='List of backend servers for the routing',
|
||||
example=['http://localhost:8080', 'http://localhost:8081'],
|
||||
)
|
||||
routing_description: str = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description='Description of the routing',
|
||||
example='Routing for customer API',
|
||||
)
|
||||
|
||||
routing_name: str = Field(..., min_length=1, max_length=50, description='Name of the routing', example='customer-routing')
|
||||
routing_servers : list[str] = Field(..., min_items=1, description='List of backend servers for the routing', example=['http://localhost:8080', 'http://localhost:8081'])
|
||||
routing_description: str = Field(None, min_length=1, max_length=255, description='Description of the routing', example='Routing for customer API')
|
||||
|
||||
client_key: Optional[str] = Field(None, min_length=1, max_length=50, description='Client key for the routing', example='client-1')
|
||||
server_index: Optional[int] = Field(0, ge=0, description='Index of the server to route to', example=0)
|
||||
client_key: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=50,
|
||||
description='Client key for the routing',
|
||||
example='client-1',
|
||||
)
|
||||
server_index: int | None = Field(
|
||||
0, ge=0, description='Index of the server to route to', example=0
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -5,31 +5,93 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class CreateUserModel(BaseModel):
|
||||
username: str = Field(
|
||||
..., min_length=3, max_length=50, description='Username of the user', example='john_doe'
|
||||
)
|
||||
email: str = Field(
|
||||
...,
|
||||
min_length=3,
|
||||
max_length=127,
|
||||
description='Email of the user (no strict format validation)',
|
||||
example='john@mail.com',
|
||||
)
|
||||
password: str = Field(
|
||||
...,
|
||||
min_length=16,
|
||||
max_length=50,
|
||||
description='Password of the user',
|
||||
example='SecurePassword@123',
|
||||
)
|
||||
role: str = Field(
|
||||
..., min_length=2, max_length=50, description='Role of the user', example='admin'
|
||||
)
|
||||
groups: list[str] = Field(
|
||||
default_factory=list,
|
||||
description='List of groups the user belongs to',
|
||||
example=['client-1-group'],
|
||||
)
|
||||
|
||||
username: str = Field(..., min_length=3, max_length=50, description='Username of the user', example='john_doe')
|
||||
email: str = Field(..., min_length=3, max_length=127, description='Email of the user (no strict format validation)', example='john@mail.com')
|
||||
password: str = Field(..., min_length=16, max_length=50, description='Password of the user', example='SecurePassword@123')
|
||||
role: str = Field(..., min_length=2, max_length=50, description='Role of the user', example='admin')
|
||||
groups: List[str] = Field(default_factory=list, description='List of groups the user belongs to', example=['client-1-group'])
|
||||
|
||||
rate_limit_duration: Optional[int] = Field(None, ge=0, description='Rate limit for the user', example=100)
|
||||
rate_limit_duration_type: Optional[str] = Field(None, min_length=1, max_length=7, description='Duration for the rate limit', example='hour')
|
||||
rate_limit_enabled: Optional[bool] = Field(None, description='Whether rate limiting is enabled for this user', example=True)
|
||||
throttle_duration: Optional[int] = Field(None, ge=0, description='Throttle limit for the user', example=10)
|
||||
throttle_duration_type: Optional[str] = Field(None, min_length=1, max_length=7, description='Duration for the throttle limit', example='second')
|
||||
throttle_wait_duration: Optional[int] = Field(None, ge=0, description='Wait time for the throttle limit', example=5)
|
||||
throttle_wait_duration_type: Optional[str] = Field(None, min_length=1, max_length=7, description='Wait duration for the throttle limit', example='seconds')
|
||||
throttle_queue_limit: Optional[int] = Field(None, ge=0, description='Throttle queue limit for the user', example=10)
|
||||
throttle_enabled: Optional[bool] = Field(None, description='Whether throttling is enabled for this user', example=True)
|
||||
custom_attributes: Optional[dict] = Field(None, description='Custom attributes for the user', example={'custom_key': 'custom_value'})
|
||||
bandwidth_limit_bytes: Optional[int] = Field(None, ge=0, description='Maximum bandwidth allowed within the window (bytes)', example=1073741824)
|
||||
bandwidth_limit_window: Optional[str] = Field('day', min_length=1, max_length=10, description='Bandwidth window unit (second/minute/hour/day/month)', example='day')
|
||||
bandwidth_limit_enabled: Optional[bool] = Field(None, description='Whether bandwidth limit enforcement is enabled for this user', example=True)
|
||||
active: Optional[bool] = Field(True, description='Active status of the user', example=True)
|
||||
ui_access: Optional[bool] = Field(False, description='UI access for the user', example=False)
|
||||
rate_limit_duration: int | None = Field(
|
||||
None, ge=0, description='Rate limit for the user', example=100
|
||||
)
|
||||
rate_limit_duration_type: str | None = Field(
|
||||
None, min_length=1, max_length=7, description='Duration for the rate limit', example='hour'
|
||||
)
|
||||
rate_limit_enabled: bool | None = Field(
|
||||
None, description='Whether rate limiting is enabled for this user', example=True
|
||||
)
|
||||
throttle_duration: int | None = Field(
|
||||
None, ge=0, description='Throttle limit for the user', example=10
|
||||
)
|
||||
throttle_duration_type: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=7,
|
||||
description='Duration for the throttle limit',
|
||||
example='second',
|
||||
)
|
||||
throttle_wait_duration: int | None = Field(
|
||||
None, ge=0, description='Wait time for the throttle limit', example=5
|
||||
)
|
||||
throttle_wait_duration_type: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=7,
|
||||
description='Wait duration for the throttle limit',
|
||||
example='seconds',
|
||||
)
|
||||
throttle_queue_limit: int | None = Field(
|
||||
None, ge=0, description='Throttle queue limit for the user', example=10
|
||||
)
|
||||
throttle_enabled: bool | None = Field(
|
||||
None, description='Whether throttling is enabled for this user', example=True
|
||||
)
|
||||
custom_attributes: dict | None = Field(
|
||||
None, description='Custom attributes for the user', example={'custom_key': 'custom_value'}
|
||||
)
|
||||
bandwidth_limit_bytes: int | None = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description='Maximum bandwidth allowed within the window (bytes)',
|
||||
example=1073741824,
|
||||
)
|
||||
bandwidth_limit_window: str | None = Field(
|
||||
'day',
|
||||
min_length=1,
|
||||
max_length=10,
|
||||
description='Bandwidth window unit (second/minute/hour/day/month)',
|
||||
example='day',
|
||||
)
|
||||
bandwidth_limit_enabled: bool | None = Field(
|
||||
None,
|
||||
description='Whether bandwidth limit enforcement is enabled for this user',
|
||||
example=True,
|
||||
)
|
||||
active: bool | None = Field(True, description='Active status of the user', example=True)
|
||||
ui_access: bool | None = Field(False, description='UI access for the user', example=False)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -5,32 +5,31 @@ See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class CreateVaultEntryModel(BaseModel):
|
||||
"""Model for creating a new vault entry."""
|
||||
|
||||
|
||||
key_name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description='Unique name for the vault key',
|
||||
example='api_key_production'
|
||||
example='api_key_production',
|
||||
)
|
||||
|
||||
|
||||
value: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
...,
|
||||
min_length=1,
|
||||
description='The secret value to encrypt and store',
|
||||
example='sk_live_abc123xyz789'
|
||||
example='sk_live_abc123xyz789',
|
||||
)
|
||||
|
||||
description: Optional[str] = Field(
|
||||
|
||||
description: str | None = Field(
|
||||
None,
|
||||
max_length=500,
|
||||
description='Optional description of what this key is used for',
|
||||
example='Production API key for payment gateway'
|
||||
example='Production API key for payment gateway',
|
||||
)
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -4,30 +4,58 @@ Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreditTierModel(BaseModel):
|
||||
tier_name: str = Field(..., min_length=1, max_length=50, description='Name of the credit tier', example='basic')
|
||||
tier_name: str = Field(
|
||||
..., min_length=1, max_length=50, description='Name of the credit tier', example='basic'
|
||||
)
|
||||
credits: int = Field(..., description='Number of credits per reset', example=50)
|
||||
input_limit: int = Field(..., description='Input limit for paid credits (text or context)', example=150)
|
||||
output_limit: int = Field(..., description='Output limit for paid credits (text or context)', example=150)
|
||||
reset_frequency: str = Field(..., description='Frequency of paid credit reset', example='monthly')
|
||||
input_limit: int = Field(
|
||||
..., description='Input limit for paid credits (text or context)', example=150
|
||||
)
|
||||
output_limit: int = Field(
|
||||
..., description='Output limit for paid credits (text or context)', example=150
|
||||
)
|
||||
reset_frequency: str = Field(
|
||||
..., description='Frequency of paid credit reset', example='monthly'
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
class CreditModel(BaseModel):
|
||||
api_credit_group: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=50,
|
||||
description='API group for the credits',
|
||||
example='ai-group-1',
|
||||
)
|
||||
api_key: str = Field(
|
||||
..., description='API key for the credit tier', example='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
|
||||
)
|
||||
api_key_header: str = Field(
|
||||
..., description='Header the API key should be sent in', example='x-api-key'
|
||||
)
|
||||
credit_tiers: list[CreditTierModel] = Field(
|
||||
..., min_items=1, description='Credit tiers information'
|
||||
)
|
||||
|
||||
api_credit_group: str = Field(..., min_length=1, max_length=50, description='API group for the credits', example='ai-group-1')
|
||||
api_key: str = Field(..., description='API key for the credit tier', example='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')
|
||||
api_key_header: str = Field(..., description='Header the API key should be sent in', example='x-api-key')
|
||||
credit_tiers: List[CreditTierModel] = Field(..., min_items=1, description='Credit tiers information')
|
||||
|
||||
api_key_new: Optional[str] = Field(None, description='New API key during rotation period', example='yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy')
|
||||
api_key_rotation_expires: Optional[datetime] = Field(None, description='Expiration time for old API key during rotation', example='2025-01-15T10:00:00Z')
|
||||
api_key_new: str | None = Field(
|
||||
None,
|
||||
description='New API key during rotation period',
|
||||
example='yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy',
|
||||
)
|
||||
api_key_rotation_expires: datetime | None = Field(
|
||||
None,
|
||||
description='Expiration time for old API key during rotation',
|
||||
example='2025-01-15T10:00:00Z',
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class ResponseMessage(BaseModel):
|
||||
|
||||
message: str = Field(None, description='The response message', example='API Deleted Successfully')
|
||||
class ResponseMessage(BaseModel):
|
||||
message: str = Field(
|
||||
None, description='The response message', example='API Deleted Successfully'
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -5,18 +5,47 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class EndpointModelResponse(BaseModel):
|
||||
|
||||
api_name: Optional[str] = Field(None, min_length=1, max_length=50, description='Name of the API', example='customer')
|
||||
api_version: Optional[str] = Field(None, min_length=1, max_length=10, description='Version of the API', example='v1')
|
||||
endpoint_method: Optional[str] = Field(None, min_length=1, max_length=10, description='HTTP method for the endpoint', example='GET')
|
||||
endpoint_uri: Optional[str] = Field(None, min_length=1, max_length=255, description='URI for the endpoint', example='/customer')
|
||||
endpoint_description: Optional[str] = Field(None, min_length=1, max_length=255, description='Description of the endpoint', example='Get customer details')
|
||||
endpoint_servers: Optional[List[str]] = Field(None, description='Optional list of backend servers for this endpoint (overrides API servers)', example=['http://localhost:8082', 'http://localhost:8083'])
|
||||
api_id: Optional[str] = Field(None, min_length=1, max_length=255, description='Unique identifier for the API, auto-generated', example=None)
|
||||
endpoint_id: Optional[str] = Field(None, min_length=1, max_length=255, description='Unique identifier for the endpoint, auto-generated', example=None)
|
||||
api_name: str | None = Field(
|
||||
None, min_length=1, max_length=50, description='Name of the API', example='customer'
|
||||
)
|
||||
api_version: str | None = Field(
|
||||
None, min_length=1, max_length=10, description='Version of the API', example='v1'
|
||||
)
|
||||
endpoint_method: str | None = Field(
|
||||
None, min_length=1, max_length=10, description='HTTP method for the endpoint', example='GET'
|
||||
)
|
||||
endpoint_uri: str | None = Field(
|
||||
None, min_length=1, max_length=255, description='URI for the endpoint', example='/customer'
|
||||
)
|
||||
endpoint_description: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description='Description of the endpoint',
|
||||
example='Get customer details',
|
||||
)
|
||||
endpoint_servers: list[str] | None = Field(
|
||||
None,
|
||||
description='Optional list of backend servers for this endpoint (overrides API servers)',
|
||||
example=['http://localhost:8082', 'http://localhost:8083'],
|
||||
)
|
||||
api_id: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description='Unique identifier for the API, auto-generated',
|
||||
example=None,
|
||||
)
|
||||
endpoint_id: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description='Unique identifier for the endpoint, auto-generated',
|
||||
example=None,
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -8,11 +8,19 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from models.validation_schema_model import ValidationSchema
|
||||
|
||||
class EndpointValidationModelResponse(BaseModel):
|
||||
|
||||
endpoint_id: str = Field(..., description='Unique identifier for the endpoint, auto-generated', example='1299f720-e619-4628-b584-48a6570026cf')
|
||||
validation_enabled: bool = Field(..., description='Whether the validation is enabled', example=True)
|
||||
validation_schema: ValidationSchema = Field(..., description='The schema to validate the endpoint against', example={})
|
||||
class EndpointValidationModelResponse(BaseModel):
|
||||
endpoint_id: str = Field(
|
||||
...,
|
||||
description='Unique identifier for the endpoint, auto-generated',
|
||||
example='1299f720-e619-4628-b584-48a6570026cf',
|
||||
)
|
||||
validation_enabled: bool = Field(
|
||||
..., description='Whether the validation is enabled', example=True
|
||||
)
|
||||
validation_schema: ValidationSchema = Field(
|
||||
..., description='The schema to validate the endpoint against', example={}
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -4,17 +4,31 @@ Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from typing import List, Union, Optional, Dict, Any
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class FieldValidation(BaseModel):
|
||||
required: bool = Field(..., description='Whether the field is required')
|
||||
type: str = Field(..., description='Expected data type (string, number, boolean, array, object)')
|
||||
min: Optional[Union[int, float]] = Field(None, description='Minimum value for numbers or minimum length for strings/arrays')
|
||||
max: Optional[Union[int, float]] = Field(None, description='Maximum value for numbers or maximum length for strings/arrays')
|
||||
pattern: Optional[str] = Field(None, description='Regex pattern for string validation')
|
||||
enum: Optional[List[Any]] = Field(None, description='List of allowed values')
|
||||
format: Optional[str] = Field(None, description='Format validation (email, url, date, datetime, uuid, etc.)')
|
||||
custom_validator: Optional[str] = Field(None, description='Custom validation function name')
|
||||
nested_schema: Optional[Dict[str, 'FieldValidation']] = Field(None, description='Validation schema for nested objects')
|
||||
array_items: Optional['FieldValidation'] = Field(None, description='Validation schema for array items')
|
||||
type: str = Field(
|
||||
..., description='Expected data type (string, number, boolean, array, object)'
|
||||
)
|
||||
min: int | float | None = Field(
|
||||
None, description='Minimum value for numbers or minimum length for strings/arrays'
|
||||
)
|
||||
max: int | float | None = Field(
|
||||
None, description='Maximum value for numbers or maximum length for strings/arrays'
|
||||
)
|
||||
pattern: str | None = Field(None, description='Regex pattern for string validation')
|
||||
enum: list[Any] | None = Field(None, description='List of allowed values')
|
||||
format: str | None = Field(
|
||||
None, description='Format validation (email, url, date, datetime, uuid, etc.)'
|
||||
)
|
||||
custom_validator: str | None = Field(None, description='Custom validation function name')
|
||||
nested_schema: dict[str, 'FieldValidation'] | None = Field(
|
||||
None, description='Validation schema for nested objects'
|
||||
)
|
||||
array_items: Optional['FieldValidation'] = Field(
|
||||
None, description='Validation schema for array items'
|
||||
)
|
||||
|
||||
@@ -5,13 +5,22 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class GroupModelResponse(BaseModel):
|
||||
|
||||
group_name: Optional[str] = Field(None, min_length=1, max_length=50, description='Name of the group', example='client-1-group')
|
||||
group_description: Optional[str] = Field(None, min_length=1, max_length=255, description='Description of the group', example='Group for client 1')
|
||||
api_access: Optional[List[str]] = Field(None, description='List of APIs the group can access', example=['customer/v1'])
|
||||
group_name: str | None = Field(
|
||||
None, min_length=1, max_length=50, description='Name of the group', example='client-1-group'
|
||||
)
|
||||
group_description: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description='Description of the group',
|
||||
example='Group for client 1',
|
||||
)
|
||||
api_access: list[str] | None = Field(
|
||||
None, description='List of APIs the group can access', example=['customer/v1']
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -9,59 +9,64 @@ This module defines the data structures for the rate limiting system including:
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, List, Any
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
# ============================================================================
|
||||
# ENUMS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class RuleType(Enum):
|
||||
"""Types of rate limit rules"""
|
||||
PER_USER = "per_user"
|
||||
PER_API = "per_api"
|
||||
PER_ENDPOINT = "per_endpoint"
|
||||
PER_IP = "per_ip"
|
||||
PER_USER_API = "per_user_api" # Combined: specific user on specific API
|
||||
PER_USER_ENDPOINT = "per_user_endpoint" # Combined: specific user on specific endpoint
|
||||
GLOBAL = "global" # Global rate limit for all requests
|
||||
|
||||
PER_USER = 'per_user'
|
||||
PER_API = 'per_api'
|
||||
PER_ENDPOINT = 'per_endpoint'
|
||||
PER_IP = 'per_ip'
|
||||
PER_USER_API = 'per_user_api' # Combined: specific user on specific API
|
||||
PER_USER_ENDPOINT = 'per_user_endpoint' # Combined: specific user on specific endpoint
|
||||
GLOBAL = 'global' # Global rate limit for all requests
|
||||
|
||||
|
||||
class TimeWindow(Enum):
|
||||
"""Time windows for rate limiting"""
|
||||
SECOND = "second"
|
||||
MINUTE = "minute"
|
||||
HOUR = "hour"
|
||||
DAY = "day"
|
||||
MONTH = "month"
|
||||
|
||||
SECOND = 'second'
|
||||
MINUTE = 'minute'
|
||||
HOUR = 'hour'
|
||||
DAY = 'day'
|
||||
MONTH = 'month'
|
||||
|
||||
|
||||
class TierName(Enum):
|
||||
"""Predefined tier names"""
|
||||
FREE = "free"
|
||||
PRO = "pro"
|
||||
ENTERPRISE = "enterprise"
|
||||
CUSTOM = "custom"
|
||||
|
||||
FREE = 'free'
|
||||
PRO = 'pro'
|
||||
ENTERPRISE = 'enterprise'
|
||||
CUSTOM = 'custom'
|
||||
|
||||
|
||||
class QuotaType(Enum):
|
||||
"""Types of quotas"""
|
||||
REQUESTS = "requests"
|
||||
BANDWIDTH = "bandwidth"
|
||||
COMPUTE_TIME = "compute_time"
|
||||
|
||||
REQUESTS = 'requests'
|
||||
BANDWIDTH = 'bandwidth'
|
||||
COMPUTE_TIME = 'compute_time'
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RATE LIMIT RULE MODELS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitRule:
|
||||
"""
|
||||
Defines a rate limiting rule
|
||||
|
||||
|
||||
Examples:
|
||||
# Per-user rule: 100 requests per minute
|
||||
RateLimitRule(
|
||||
@@ -71,7 +76,7 @@ class RateLimitRule:
|
||||
limit=100,
|
||||
burst_allowance=20
|
||||
)
|
||||
|
||||
|
||||
# Per-API rule: 1000 requests per hour
|
||||
RateLimitRule(
|
||||
rule_id="rule_002",
|
||||
@@ -81,24 +86,25 @@ class RateLimitRule:
|
||||
limit=1000
|
||||
)
|
||||
"""
|
||||
|
||||
rule_id: str
|
||||
rule_type: RuleType
|
||||
time_window: TimeWindow
|
||||
limit: int # Maximum requests allowed in time window
|
||||
|
||||
|
||||
# Optional fields
|
||||
target_identifier: Optional[str] = None # User ID, API name, endpoint URI, or IP
|
||||
target_identifier: str | None = None # User ID, API name, endpoint URI, or IP
|
||||
burst_allowance: int = 0 # Additional requests allowed for bursts
|
||||
priority: int = 0 # Higher priority rules are checked first
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
# Metadata
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
created_by: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
created_by: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for storage"""
|
||||
return {
|
||||
'rule_id': self.rule_id,
|
||||
@@ -112,11 +118,11 @@ class RateLimitRule:
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'created_by': self.created_by,
|
||||
'description': self.description
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'RateLimitRule':
|
||||
def from_dict(cls, data: dict[str, Any]) -> 'RateLimitRule':
|
||||
"""Create from dictionary"""
|
||||
return cls(
|
||||
rule_id=data['rule_id'],
|
||||
@@ -127,10 +133,14 @@ class RateLimitRule:
|
||||
burst_allowance=data.get('burst_allowance', 0),
|
||||
priority=data.get('priority', 0),
|
||||
enabled=data.get('enabled', True),
|
||||
created_at=datetime.fromisoformat(data['created_at']) if data.get('created_at') else None,
|
||||
updated_at=datetime.fromisoformat(data['updated_at']) if data.get('updated_at') else None,
|
||||
created_at=datetime.fromisoformat(data['created_at'])
|
||||
if data.get('created_at')
|
||||
else None,
|
||||
updated_at=datetime.fromisoformat(data['updated_at'])
|
||||
if data.get('updated_at')
|
||||
else None,
|
||||
created_by=data.get('created_by'),
|
||||
description=data.get('description')
|
||||
description=data.get('description'),
|
||||
)
|
||||
|
||||
|
||||
@@ -138,31 +148,33 @@ class RateLimitRule:
|
||||
# TIER/PLAN MODELS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class TierLimits:
|
||||
"""Rate limits and quotas for a specific tier"""
|
||||
|
||||
# Rate limits (requests per time window)
|
||||
requests_per_second: Optional[int] = None
|
||||
requests_per_minute: Optional[int] = None
|
||||
requests_per_hour: Optional[int] = None
|
||||
requests_per_day: Optional[int] = None
|
||||
requests_per_month: Optional[int] = None
|
||||
|
||||
requests_per_second: int | None = None
|
||||
requests_per_minute: int | None = None
|
||||
requests_per_hour: int | None = None
|
||||
requests_per_day: int | None = None
|
||||
requests_per_month: int | None = None
|
||||
|
||||
# Burst allowances
|
||||
burst_per_second: int = 0
|
||||
burst_per_minute: int = 0
|
||||
burst_per_hour: int = 0
|
||||
|
||||
|
||||
# Quotas
|
||||
monthly_request_quota: Optional[int] = None
|
||||
daily_request_quota: Optional[int] = None
|
||||
monthly_bandwidth_quota: Optional[int] = None # In bytes
|
||||
|
||||
monthly_request_quota: int | None = None
|
||||
daily_request_quota: int | None = None
|
||||
monthly_bandwidth_quota: int | None = None # In bytes
|
||||
|
||||
# Throttling configuration
|
||||
enable_throttling: bool = False # If true, queue/delay requests; if false, hard reject (429)
|
||||
max_queue_time_ms: int = 5000 # Maximum time to queue a request before rejecting (milliseconds)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
'requests_per_second': self.requests_per_second,
|
||||
@@ -177,11 +189,11 @@ class TierLimits:
|
||||
'daily_request_quota': self.daily_request_quota,
|
||||
'monthly_bandwidth_quota': self.monthly_bandwidth_quota,
|
||||
'enable_throttling': self.enable_throttling,
|
||||
'max_queue_time_ms': self.max_queue_time_ms
|
||||
'max_queue_time_ms': self.max_queue_time_ms,
|
||||
}
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'TierLimits':
|
||||
def from_dict(cls, data: dict[str, Any]) -> 'TierLimits':
|
||||
"""Create from dictionary"""
|
||||
return cls(**data)
|
||||
|
||||
@@ -190,7 +202,7 @@ class TierLimits:
|
||||
class Tier:
|
||||
"""
|
||||
Defines a tier/plan with associated rate limits and quotas
|
||||
|
||||
|
||||
Examples:
|
||||
# Free tier
|
||||
Tier(
|
||||
@@ -203,7 +215,7 @@ class Tier:
|
||||
daily_request_quota=10000
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Pro tier
|
||||
Tier(
|
||||
tier_id="tier_pro",
|
||||
@@ -218,24 +230,25 @@ class Tier:
|
||||
price_monthly=49.99
|
||||
)
|
||||
"""
|
||||
|
||||
tier_id: str
|
||||
name: TierName
|
||||
display_name: str
|
||||
limits: TierLimits
|
||||
|
||||
|
||||
# Optional fields
|
||||
description: Optional[str] = None
|
||||
price_monthly: Optional[float] = None
|
||||
price_yearly: Optional[float] = None
|
||||
features: List[str] = field(default_factory=list)
|
||||
description: str | None = None
|
||||
price_monthly: float | None = None
|
||||
price_yearly: float | None = None
|
||||
features: list[str] = field(default_factory=list)
|
||||
is_default: bool = False
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
# Metadata
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for storage"""
|
||||
return {
|
||||
'tier_id': self.tier_id,
|
||||
@@ -249,11 +262,11 @@ class Tier:
|
||||
'is_default': self.is_default,
|
||||
'enabled': self.enabled,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Tier':
|
||||
def from_dict(cls, data: dict[str, Any]) -> 'Tier':
|
||||
"""Create from dictionary"""
|
||||
return cls(
|
||||
tier_id=data['tier_id'],
|
||||
@@ -266,30 +279,35 @@ class Tier:
|
||||
features=data.get('features', []),
|
||||
is_default=data.get('is_default', False),
|
||||
enabled=data.get('enabled', True),
|
||||
created_at=datetime.fromisoformat(data['created_at']) if data.get('created_at') else None,
|
||||
updated_at=datetime.fromisoformat(data['updated_at']) if data.get('updated_at') else None
|
||||
created_at=datetime.fromisoformat(data['created_at'])
|
||||
if data.get('created_at')
|
||||
else None,
|
||||
updated_at=datetime.fromisoformat(data['updated_at'])
|
||||
if data.get('updated_at')
|
||||
else None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserTierAssignment:
|
||||
"""Assigns a user to a tier with optional overrides"""
|
||||
|
||||
user_id: str
|
||||
tier_id: str
|
||||
|
||||
|
||||
# Optional overrides (override tier defaults for this specific user)
|
||||
override_limits: Optional[TierLimits] = None
|
||||
|
||||
override_limits: TierLimits | None = None
|
||||
|
||||
# Scheduling
|
||||
effective_from: Optional[datetime] = None
|
||||
effective_until: Optional[datetime] = None
|
||||
|
||||
effective_from: datetime | None = None
|
||||
effective_until: datetime | None = None
|
||||
|
||||
# Metadata
|
||||
assigned_at: Optional[datetime] = None
|
||||
assigned_by: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
assigned_at: datetime | None = None
|
||||
assigned_by: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
'user_id': self.user_id,
|
||||
@@ -299,7 +317,7 @@ class UserTierAssignment:
|
||||
'effective_until': self.effective_until.isoformat() if self.effective_until else None,
|
||||
'assigned_at': self.assigned_at.isoformat() if self.assigned_at else None,
|
||||
'assigned_by': self.assigned_by,
|
||||
'notes': self.notes
|
||||
'notes': self.notes,
|
||||
}
|
||||
|
||||
|
||||
@@ -307,41 +325,43 @@ class UserTierAssignment:
|
||||
# QUOTA TRACKING MODELS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuotaUsage:
|
||||
"""
|
||||
Tracks current quota usage for a user/API/endpoint
|
||||
|
||||
|
||||
This is stored in Redis for real-time tracking
|
||||
"""
|
||||
|
||||
key: str # Redis key (e.g., "quota:user:john_doe:month:2025-12")
|
||||
quota_type: QuotaType
|
||||
current_usage: int
|
||||
limit: int
|
||||
reset_at: datetime # When the quota resets
|
||||
|
||||
|
||||
# Optional fields
|
||||
burst_usage: int = 0 # Burst tokens used
|
||||
burst_limit: int = 0
|
||||
|
||||
|
||||
@property
|
||||
def remaining(self) -> int:
|
||||
"""Calculate remaining quota"""
|
||||
return max(0, self.limit - self.current_usage)
|
||||
|
||||
|
||||
@property
|
||||
def percentage_used(self) -> float:
|
||||
"""Calculate percentage of quota used"""
|
||||
if self.limit == 0:
|
||||
return 0.0
|
||||
return (self.current_usage / self.limit) * 100
|
||||
|
||||
|
||||
@property
|
||||
def is_exhausted(self) -> bool:
|
||||
"""Check if quota is exhausted"""
|
||||
return self.current_usage >= self.limit
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
'key': self.key,
|
||||
@@ -353,7 +373,7 @@ class QuotaUsage:
|
||||
'reset_at': self.reset_at.isoformat(),
|
||||
'burst_usage': self.burst_usage,
|
||||
'burst_limit': self.burst_limit,
|
||||
'is_exhausted': self.is_exhausted
|
||||
'is_exhausted': self.is_exhausted,
|
||||
}
|
||||
|
||||
|
||||
@@ -361,9 +381,10 @@ class QuotaUsage:
|
||||
class RateLimitCounter:
|
||||
"""
|
||||
Real-time counter for rate limiting (stored in Redis)
|
||||
|
||||
|
||||
Uses sliding window counter algorithm
|
||||
"""
|
||||
|
||||
key: str # Redis key (e.g., "ratelimit:user:john_doe:minute:1701504000")
|
||||
window_start: int # Unix timestamp
|
||||
window_size: int # Window size in seconds
|
||||
@@ -371,23 +392,23 @@ class RateLimitCounter:
|
||||
limit: int # Maximum allowed requests
|
||||
burst_count: int = 0 # Burst tokens used
|
||||
burst_limit: int = 0
|
||||
|
||||
|
||||
@property
|
||||
def remaining(self) -> int:
|
||||
"""Calculate remaining requests"""
|
||||
return max(0, self.limit - self.count)
|
||||
|
||||
|
||||
@property
|
||||
def is_limited(self) -> bool:
|
||||
"""Check if rate limit is exceeded"""
|
||||
return self.count >= self.limit
|
||||
|
||||
|
||||
@property
|
||||
def reset_at(self) -> int:
|
||||
"""Calculate when the window resets (Unix timestamp)"""
|
||||
return self.window_start + self.window_size
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
'key': self.key,
|
||||
@@ -399,7 +420,7 @@ class RateLimitCounter:
|
||||
'reset_at': self.reset_at,
|
||||
'burst_count': self.burst_count,
|
||||
'burst_limit': self.burst_limit,
|
||||
'is_limited': self.is_limited
|
||||
'is_limited': self.is_limited,
|
||||
}
|
||||
|
||||
|
||||
@@ -407,28 +428,30 @@ class RateLimitCounter:
|
||||
# HISTORICAL TRACKING MODELS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class UsageHistoryRecord:
|
||||
"""
|
||||
Historical usage record for analytics
|
||||
|
||||
|
||||
Stored in time-series database or MongoDB
|
||||
"""
|
||||
|
||||
timestamp: datetime
|
||||
user_id: Optional[str] = None
|
||||
api_name: Optional[str] = None
|
||||
endpoint_uri: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
|
||||
user_id: str | None = None
|
||||
api_name: str | None = None
|
||||
endpoint_uri: str | None = None
|
||||
ip_address: str | None = None
|
||||
|
||||
# Metrics
|
||||
request_count: int = 0
|
||||
blocked_count: int = 0 # Requests blocked by rate limit
|
||||
burst_used: int = 0
|
||||
|
||||
|
||||
# Aggregation period
|
||||
period: str = "minute" # minute, hour, day
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
period: str = 'minute' # minute, hour, day
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
'timestamp': self.timestamp.isoformat(),
|
||||
@@ -439,7 +462,7 @@ class UsageHistoryRecord:
|
||||
'request_count': self.request_count,
|
||||
'blocked_count': self.blocked_count,
|
||||
'burst_used': self.burst_used,
|
||||
'period': self.period
|
||||
'period': self.period,
|
||||
}
|
||||
|
||||
|
||||
@@ -447,42 +470,44 @@ class UsageHistoryRecord:
|
||||
# RESPONSE MODELS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitInfo:
|
||||
"""
|
||||
Information about current rate limit status
|
||||
|
||||
|
||||
Returned in API responses and headers
|
||||
"""
|
||||
|
||||
limit: int
|
||||
remaining: int
|
||||
reset_at: int # Unix timestamp
|
||||
retry_after: Optional[int] = None # Seconds until retry (when limited)
|
||||
|
||||
retry_after: int | None = None # Seconds until retry (when limited)
|
||||
|
||||
# Additional info
|
||||
burst_limit: int = 0
|
||||
burst_remaining: int = 0
|
||||
tier: Optional[str] = None
|
||||
|
||||
def to_headers(self) -> Dict[str, str]:
|
||||
tier: str | None = None
|
||||
|
||||
def to_headers(self) -> dict[str, str]:
|
||||
"""Convert to HTTP headers"""
|
||||
headers = {
|
||||
'X-RateLimit-Limit': str(self.limit),
|
||||
'X-RateLimit-Remaining': str(self.remaining),
|
||||
'X-RateLimit-Reset': str(self.reset_at)
|
||||
'X-RateLimit-Reset': str(self.reset_at),
|
||||
}
|
||||
|
||||
|
||||
if self.retry_after is not None:
|
||||
headers['X-RateLimit-Retry-After'] = str(self.retry_after)
|
||||
headers['Retry-After'] = str(self.retry_after)
|
||||
|
||||
|
||||
if self.burst_limit > 0:
|
||||
headers['X-RateLimit-Burst-Limit'] = str(self.burst_limit)
|
||||
headers['X-RateLimit-Burst-Remaining'] = str(self.burst_remaining)
|
||||
|
||||
|
||||
return headers
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
'limit': self.limit,
|
||||
@@ -491,7 +516,7 @@ class RateLimitInfo:
|
||||
'retry_after': self.retry_after,
|
||||
'burst_limit': self.burst_limit,
|
||||
'burst_remaining': self.burst_remaining,
|
||||
'tier': self.tier
|
||||
'tier': self.tier,
|
||||
}
|
||||
|
||||
|
||||
@@ -499,6 +524,7 @@ class RateLimitInfo:
|
||||
# HELPER FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_time_window_seconds(window: TimeWindow) -> int:
|
||||
"""Convert time window enum to seconds"""
|
||||
mapping = {
|
||||
@@ -506,39 +532,32 @@ def get_time_window_seconds(window: TimeWindow) -> int:
|
||||
TimeWindow.MINUTE: 60,
|
||||
TimeWindow.HOUR: 3600,
|
||||
TimeWindow.DAY: 86400,
|
||||
TimeWindow.MONTH: 2592000 # 30 days
|
||||
TimeWindow.MONTH: 2592000, # 30 days
|
||||
}
|
||||
return mapping[window]
|
||||
|
||||
|
||||
def generate_redis_key(
|
||||
rule_type: RuleType,
|
||||
identifier: str,
|
||||
window: TimeWindow,
|
||||
window_start: int
|
||||
rule_type: RuleType, identifier: str, window: TimeWindow, window_start: int
|
||||
) -> str:
|
||||
"""
|
||||
Generate Redis key for rate limit counter
|
||||
|
||||
|
||||
Examples:
|
||||
generate_redis_key(RuleType.PER_USER, "john_doe", TimeWindow.MINUTE, 1701504000)
|
||||
# Returns: "ratelimit:user:john_doe:minute:1701504000"
|
||||
"""
|
||||
type_prefix = rule_type.value.replace('per_', '')
|
||||
window_name = window.value
|
||||
return f"ratelimit:{type_prefix}:{identifier}:{window_name}:{window_start}"
|
||||
return f'ratelimit:{type_prefix}:{identifier}:{window_name}:{window_start}'
|
||||
|
||||
|
||||
def generate_quota_key(
|
||||
user_id: str,
|
||||
quota_type: QuotaType,
|
||||
period: str
|
||||
) -> str:
|
||||
def generate_quota_key(user_id: str, quota_type: QuotaType, period: str) -> str:
|
||||
"""
|
||||
Generate Redis key for quota tracking
|
||||
|
||||
|
||||
Examples:
|
||||
generate_quota_key("john_doe", QuotaType.REQUESTS, "2025-12")
|
||||
# Returns: "quota:user:john_doe:requests:month:2025-12"
|
||||
"""
|
||||
return f"quota:user:{user_id}:{quota_type.value}:month:{period}"
|
||||
return f'quota:user:{user_id}:{quota_type.value}:month:{period}'
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
class RequestModel(BaseModel):
|
||||
method: str
|
||||
path: str
|
||||
headers: Dict[str, str]
|
||||
query_params: Dict[str, str]
|
||||
identity: Optional[str] = None
|
||||
body: Optional[str] = None
|
||||
headers: dict[str, str]
|
||||
query_params: dict[str, str]
|
||||
identity: str | None = None
|
||||
body: str | None = None
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Union
|
||||
|
||||
|
||||
class ResponseModel(BaseModel):
|
||||
status_code: int = Field(None)
|
||||
|
||||
response_headers: Optional[dict] = Field(None)
|
||||
response_headers: dict | None = Field(None)
|
||||
|
||||
response: Optional[Union[dict, list, str]] = Field(None)
|
||||
message: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
response: dict | list | str | None = Field(None)
|
||||
message: str | None = Field(None, min_length=1, max_length=255)
|
||||
|
||||
error_code: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
error_message: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
error_code: str | None = Field(None, min_length=1, max_length=255)
|
||||
error_message: str | None = Field(None, min_length=1, max_length=255)
|
||||
|
||||
@@ -5,28 +5,57 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class RoleModelResponse(BaseModel):
|
||||
|
||||
role_name: Optional[str] = Field(None, min_length=1, max_length=50, description='Name of the role', example='admin')
|
||||
role_description: Optional[str] = Field(None, min_length=1, max_length=255, description='Description of the role', example='Administrator role with full access')
|
||||
manage_users: Optional[bool] = Field(None, description='Permission to manage users', example=True)
|
||||
manage_apis: Optional[bool] = Field(None, description='Permission to manage APIs', example=True)
|
||||
manage_endpoints: Optional[bool] = Field(None, description='Permission to manage endpoints', example=True)
|
||||
manage_groups: Optional[bool] = Field(None, description='Permission to manage groups', example=True)
|
||||
manage_roles: Optional[bool] = Field(None, description='Permission to manage roles', example=True)
|
||||
manage_routings: Optional[bool] = Field(None, description='Permission to manage routings', example=True)
|
||||
manage_gateway: Optional[bool] = Field(None, description='Permission to manage gateway', example=True)
|
||||
manage_subscriptions: Optional[bool] = Field(None, description='Permission to manage subscriptions', example=True)
|
||||
manage_security: Optional[bool] = Field(None, description='Permission to manage security settings', example=True)
|
||||
manage_tiers: Optional[bool] = Field(None, description='Permission to manage pricing tiers', example=True)
|
||||
manage_rate_limits: Optional[bool] = Field(None, description='Permission to manage rate limiting rules', example=True)
|
||||
manage_credits: Optional[bool] = Field(None, description='Permission to manage API credits', example=True)
|
||||
manage_auth: Optional[bool] = Field(None, description='Permission to manage auth (revoke tokens/disable users)', example=True)
|
||||
view_analytics: Optional[bool] = Field(None, description='Permission to view analytics dashboard', example=True)
|
||||
view_logs: Optional[bool] = Field(None, description='Permission to view logs', example=True)
|
||||
export_logs: Optional[bool] = Field(None, description='Permission to export logs', example=True)
|
||||
role_name: str | None = Field(
|
||||
None, min_length=1, max_length=50, description='Name of the role', example='admin'
|
||||
)
|
||||
role_description: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description='Description of the role',
|
||||
example='Administrator role with full access',
|
||||
)
|
||||
manage_users: bool | None = Field(None, description='Permission to manage users', example=True)
|
||||
manage_apis: bool | None = Field(None, description='Permission to manage APIs', example=True)
|
||||
manage_endpoints: bool | None = Field(
|
||||
None, description='Permission to manage endpoints', example=True
|
||||
)
|
||||
manage_groups: bool | None = Field(
|
||||
None, description='Permission to manage groups', example=True
|
||||
)
|
||||
manage_roles: bool | None = Field(None, description='Permission to manage roles', example=True)
|
||||
manage_routings: bool | None = Field(
|
||||
None, description='Permission to manage routings', example=True
|
||||
)
|
||||
manage_gateway: bool | None = Field(
|
||||
None, description='Permission to manage gateway', example=True
|
||||
)
|
||||
manage_subscriptions: bool | None = Field(
|
||||
None, description='Permission to manage subscriptions', example=True
|
||||
)
|
||||
manage_security: bool | None = Field(
|
||||
None, description='Permission to manage security settings', example=True
|
||||
)
|
||||
manage_tiers: bool | None = Field(
|
||||
None, description='Permission to manage pricing tiers', example=True
|
||||
)
|
||||
manage_rate_limits: bool | None = Field(
|
||||
None, description='Permission to manage rate limiting rules', example=True
|
||||
)
|
||||
manage_credits: bool | None = Field(
|
||||
None, description='Permission to manage API credits', example=True
|
||||
)
|
||||
manage_auth: bool | None = Field(
|
||||
None, description='Permission to manage auth (revoke tokens/disable users)', example=True
|
||||
)
|
||||
view_analytics: bool | None = Field(
|
||||
None, description='Permission to view analytics dashboard', example=True
|
||||
)
|
||||
view_logs: bool | None = Field(None, description='Permission to view logs', example=True)
|
||||
export_logs: bool | None = Field(None, description='Permission to export logs', example=True)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -5,16 +5,40 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class RoutingModelResponse(BaseModel):
|
||||
routing_name: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=50,
|
||||
description='Name of the routing',
|
||||
example='customer-routing',
|
||||
)
|
||||
routing_servers: list[str] | None = Field(
|
||||
None,
|
||||
min_items=1,
|
||||
description='List of backend servers for the routing',
|
||||
example=['http://localhost:8080', 'http://localhost:8081'],
|
||||
)
|
||||
routing_description: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description='Description of the routing',
|
||||
example='Routing for customer API',
|
||||
)
|
||||
|
||||
routing_name: Optional[str] = Field(None, min_length=1, max_length=50, description='Name of the routing', example='customer-routing')
|
||||
routing_servers : Optional[list[str]] = Field(None, min_items=1, description='List of backend servers for the routing', example=['http://localhost:8080', 'http://localhost:8081'])
|
||||
routing_description: Optional[str] = Field(None, min_length=1, max_length=255, description='Description of the routing', example='Routing for customer API')
|
||||
|
||||
client_key: Optional[str] = Field(None, min_length=1, max_length=50, description='Client key for the routing', example='client-1')
|
||||
server_index: Optional[int] = Field(None, exclude=True, ge=0, description='Index of the server to route to', example=0)
|
||||
client_key: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=50,
|
||||
description='Client key for the routing',
|
||||
example='client-1',
|
||||
)
|
||||
server_index: int | None = Field(
|
||||
None, exclude=True, ge=0, description='Index of the server to route to', example=0
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class SecuritySettingsModel(BaseModel):
|
||||
enable_auto_save: Optional[bool] = Field(default=None)
|
||||
auto_save_frequency_seconds: Optional[int] = Field(default=None, ge=60, description='How often to auto-save memory dump (seconds)')
|
||||
dump_path: Optional[str] = Field(default=None, description='Path to write encrypted memory dumps')
|
||||
ip_whitelist: Optional[List[str]] = Field(default=None, description='List of allowed IPs/CIDRs. If non-empty, only these are allowed.')
|
||||
ip_blacklist: Optional[List[str]] = Field(default=None, description='List of blocked IPs/CIDRs')
|
||||
trust_x_forwarded_for: Optional[bool] = Field(default=None, description='If true, use X-Forwarded-For header for client IP')
|
||||
xff_trusted_proxies: Optional[List[str]] = Field(default=None, description='IPs/CIDRs of proxies allowed to set client IP headers (XFF/X-Real-IP). Empty means trust all when enabled.')
|
||||
allow_localhost_bypass: Optional[bool] = Field(default=None, description='Allow direct localhost (::1/127.0.0.1) to bypass IP allow/deny lists when no forwarding headers are present')
|
||||
enable_auto_save: bool | None = Field(default=None)
|
||||
auto_save_frequency_seconds: int | None = Field(
|
||||
default=None, ge=60, description='How often to auto-save memory dump (seconds)'
|
||||
)
|
||||
dump_path: str | None = Field(default=None, description='Path to write encrypted memory dumps')
|
||||
ip_whitelist: list[str] | None = Field(
|
||||
default=None, description='List of allowed IPs/CIDRs. If non-empty, only these are allowed.'
|
||||
)
|
||||
ip_blacklist: list[str] | None = Field(default=None, description='List of blocked IPs/CIDRs')
|
||||
trust_x_forwarded_for: bool | None = Field(
|
||||
default=None, description='If true, use X-Forwarded-For header for client IP'
|
||||
)
|
||||
xff_trusted_proxies: list[str] | None = Field(
|
||||
default=None,
|
||||
description='IPs/CIDRs of proxies allowed to set client IP headers (XFF/X-Real-IP). Empty means trust all when enabled.',
|
||||
)
|
||||
allow_localhost_bypass: bool | None = Field(
|
||||
default=None,
|
||||
description='Allow direct localhost (::1/127.0.0.1) to bypass IP allow/deny lists when no forwarding headers are present',
|
||||
)
|
||||
|
||||
@@ -6,11 +6,29 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class SubscribeModel(BaseModel):
|
||||
|
||||
username: str = Field(..., min_length=3, max_length=50, description='Username of the subscriber', example='client-1')
|
||||
api_name: str = Field(..., min_length=3, max_length=50, description='Name of the API to subscribe to', example='customer')
|
||||
api_version: str = Field(..., min_length=1, max_length=5, description='Version of the API to subscribe to', example='v1')
|
||||
class SubscribeModel(BaseModel):
|
||||
username: str = Field(
|
||||
...,
|
||||
min_length=3,
|
||||
max_length=50,
|
||||
description='Username of the subscriber',
|
||||
example='client-1',
|
||||
)
|
||||
api_name: str = Field(
|
||||
...,
|
||||
min_length=3,
|
||||
max_length=50,
|
||||
description='Name of the API to subscribe to',
|
||||
example='customer',
|
||||
)
|
||||
api_version: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=5,
|
||||
description='Version of the API to subscribe to',
|
||||
example='v1',
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -5,44 +5,120 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class UpdateApiModel(BaseModel):
|
||||
api_name: str | None = Field(
|
||||
None, min_length=1, max_length=25, description='Name of the API', example='customer'
|
||||
)
|
||||
api_version: str | None = Field(
|
||||
None, min_length=1, max_length=8, description='Version of the API', example='v1'
|
||||
)
|
||||
api_description: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=127,
|
||||
description='Description of the API',
|
||||
example='New customer onboarding API',
|
||||
)
|
||||
api_allowed_roles: list[str] | None = Field(
|
||||
None, description='Allowed user roles for the API', example=['admin', 'user']
|
||||
)
|
||||
api_allowed_groups: list[str] | None = Field(
|
||||
None, description='Allowed user groups for the API', example=['admin', 'client-1-group']
|
||||
)
|
||||
api_servers: list[str] | None = Field(
|
||||
None,
|
||||
description='List of backend servers for the API',
|
||||
example=['http://localhost:8080', 'http://localhost:8081'],
|
||||
)
|
||||
api_type: str | None = Field(
|
||||
None, description="Type of the API. Valid values: 'REST'", example='REST'
|
||||
)
|
||||
api_authorization_field_swap: str | None = Field(
|
||||
None,
|
||||
description='Header to swap for backend authorization header',
|
||||
example='backend-auth-header',
|
||||
)
|
||||
api_allowed_headers: list[str] | None = Field(
|
||||
None, description='Allowed headers for the API', example=['Content-Type', 'Authorization']
|
||||
)
|
||||
api_allowed_retry_count: int | None = Field(
|
||||
None, description='Number of allowed retries for the API', example=0
|
||||
)
|
||||
api_grpc_package: str | None = Field(
|
||||
None,
|
||||
description='Optional gRPC Python package to use for this API (e.g., "my.pkg"). When set, overrides request package and default.',
|
||||
example='my.pkg',
|
||||
)
|
||||
api_grpc_allowed_packages: list[str] | None = Field(
|
||||
None,
|
||||
description='Allow-list of gRPC package/module base names (no dots). If set, requests must match one of these.',
|
||||
example=['customer_v1'],
|
||||
)
|
||||
api_grpc_allowed_services: list[str] | None = Field(
|
||||
None,
|
||||
description='Allow-list of gRPC service names (e.g., Greeter). If set, only these services are permitted.',
|
||||
example=['Greeter'],
|
||||
)
|
||||
api_grpc_allowed_methods: list[str] | None = Field(
|
||||
None,
|
||||
description='Allow-list of gRPC methods as Service.Method strings. If set, only these methods are permitted.',
|
||||
example=['Greeter.SayHello'],
|
||||
)
|
||||
api_credits_enabled: bool | None = Field(
|
||||
False, description='Enable credit-based authentication for the API', example=True
|
||||
)
|
||||
api_credit_group: str | None = Field(
|
||||
None, description='API credit group for the API credits', example='ai-group-1'
|
||||
)
|
||||
active: bool | None = Field(None, description='Whether the API is active (enabled)')
|
||||
api_id: str | None = Field(
|
||||
None, description='Unique identifier for the API, auto-generated', example=None
|
||||
)
|
||||
api_path: str | None = Field(
|
||||
None, description='Unqiue path for the API, auto-generated', example=None
|
||||
)
|
||||
|
||||
api_name: Optional[str] = Field(None, min_length=1, max_length=25, description='Name of the API', example='customer')
|
||||
api_version: Optional[str] = Field(None, min_length=1, max_length=8, description='Version of the API', example='v1')
|
||||
api_description: Optional[str] = Field(None, min_length=1, max_length=127, description='Description of the API', example='New customer onboarding API')
|
||||
api_allowed_roles: Optional[List[str]] = Field(None, description='Allowed user roles for the API', example=['admin', 'user'])
|
||||
api_allowed_groups: Optional[List[str]] = Field(None, description='Allowed user groups for the API' , example=['admin', 'client-1-group'])
|
||||
api_servers: Optional[List[str]] = Field(None, description='List of backend servers for the API', example=['http://localhost:8080', 'http://localhost:8081'])
|
||||
api_type: Optional[str] = Field(None, description="Type of the API. Valid values: 'REST'", example='REST')
|
||||
api_authorization_field_swap: Optional[str] = Field(None, description='Header to swap for backend authorization header', example='backend-auth-header')
|
||||
api_allowed_headers: Optional[List[str]] = Field(None, description='Allowed headers for the API', example=['Content-Type', 'Authorization'])
|
||||
api_allowed_retry_count: Optional[int] = Field(None, description='Number of allowed retries for the API', example=0)
|
||||
api_grpc_package: Optional[str] = Field(None, description='Optional gRPC Python package to use for this API (e.g., "my.pkg"). When set, overrides request package and default.', example='my.pkg')
|
||||
api_grpc_allowed_packages: Optional[List[str]] = Field(None, description='Allow-list of gRPC package/module base names (no dots). If set, requests must match one of these.', example=['customer_v1'])
|
||||
api_grpc_allowed_services: Optional[List[str]] = Field(None, description='Allow-list of gRPC service names (e.g., Greeter). If set, only these services are permitted.', example=['Greeter'])
|
||||
api_grpc_allowed_methods: Optional[List[str]] = Field(None, description='Allow-list of gRPC methods as Service.Method strings. If set, only these methods are permitted.', example=['Greeter.SayHello'])
|
||||
api_credits_enabled: Optional[bool] = Field(False, description='Enable credit-based authentication for the API', example=True)
|
||||
api_credit_group: Optional[str] = Field(None, description='API credit group for the API credits', example='ai-group-1')
|
||||
active: Optional[bool] = Field(None, description='Whether the API is active (enabled)')
|
||||
api_id: Optional[str] = Field(None, description='Unique identifier for the API, auto-generated', example=None)
|
||||
api_path: Optional[str] = Field(None, description='Unqiue path for the API, auto-generated', example=None)
|
||||
api_cors_allow_origins: list[str] | None = Field(
|
||||
None,
|
||||
description="Allowed origins for CORS (e.g., ['http://localhost:3000']). Use ['*'] to allow all.",
|
||||
)
|
||||
api_cors_allow_methods: list[str] | None = Field(
|
||||
None,
|
||||
description="Allowed methods for CORS preflight (e.g., ['GET','POST','PUT','DELETE','OPTIONS'])",
|
||||
)
|
||||
api_cors_allow_headers: list[str] | None = Field(
|
||||
None,
|
||||
description="Allowed request headers for CORS preflight (e.g., ['Content-Type','Authorization'])",
|
||||
)
|
||||
api_cors_allow_credentials: bool | None = Field(
|
||||
None, description='Whether to include Access-Control-Allow-Credentials=true in responses'
|
||||
)
|
||||
api_cors_expose_headers: list[str] | None = Field(
|
||||
None,
|
||||
description='Response headers to expose to the browser via Access-Control-Expose-Headers',
|
||||
)
|
||||
|
||||
api_cors_allow_origins: Optional[List[str]] = Field(None, description="Allowed origins for CORS (e.g., ['http://localhost:3000']). Use ['*'] to allow all.")
|
||||
api_cors_allow_methods: Optional[List[str]] = Field(None, description="Allowed methods for CORS preflight (e.g., ['GET','POST','PUT','DELETE','OPTIONS'])")
|
||||
api_cors_allow_headers: Optional[List[str]] = Field(None, description="Allowed request headers for CORS preflight (e.g., ['Content-Type','Authorization'])")
|
||||
api_cors_allow_credentials: Optional[bool] = Field(None, description='Whether to include Access-Control-Allow-Credentials=true in responses')
|
||||
api_cors_expose_headers: Optional[List[str]] = Field(None, description='Response headers to expose to the browser via Access-Control-Expose-Headers')
|
||||
api_public: bool | None = Field(
|
||||
None, description='If true, this API can be called without authentication or subscription'
|
||||
)
|
||||
|
||||
api_public: Optional[bool] = Field(None, description='If true, this API can be called without authentication or subscription')
|
||||
api_auth_required: bool | None = Field(
|
||||
None,
|
||||
description='If true (default), JWT auth is required for this API when not public. If false, requests may be unauthenticated but must meet other checks as configured.',
|
||||
)
|
||||
|
||||
api_auth_required: Optional[bool] = Field(None, description='If true (default), JWT auth is required for this API when not public. If false, requests may be unauthenticated but must meet other checks as configured.')
|
||||
|
||||
api_ip_mode: Optional[str] = Field(None, description="IP policy mode: 'allow_all' or 'whitelist'")
|
||||
api_ip_whitelist: Optional[List[str]] = Field(None, description='Allowed IPs/CIDRs when api_ip_mode=whitelist')
|
||||
api_ip_blacklist: Optional[List[str]] = Field(None, description='IPs/CIDRs denied regardless of mode')
|
||||
api_trust_x_forwarded_for: Optional[bool] = Field(None, description='Override: trust X-Forwarded-For for this API')
|
||||
api_ip_mode: str | None = Field(None, description="IP policy mode: 'allow_all' or 'whitelist'")
|
||||
api_ip_whitelist: list[str] | None = Field(
|
||||
None, description='Allowed IPs/CIDRs when api_ip_mode=whitelist'
|
||||
)
|
||||
api_ip_blacklist: list[str] | None = Field(
|
||||
None, description='IPs/CIDRs denied regardless of mode'
|
||||
)
|
||||
api_trust_x_forwarded_for: bool | None = Field(
|
||||
None, description='Override: trust X-Forwarded-For for this API'
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -5,18 +5,47 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class UpdateEndpointModel(BaseModel):
|
||||
|
||||
api_name: Optional[str] = Field(None, min_length=1, max_length=50, description='Name of the API', example='customer')
|
||||
api_version: Optional[str] = Field(None, min_length=1, max_length=10, description='Version of the API', example='v1')
|
||||
endpoint_method: Optional[str] = Field(None, min_length=1, max_length=10, description='HTTP method for the endpoint', example='GET')
|
||||
endpoint_uri: Optional[str] = Field(None, min_length=1, max_length=255, description='URI for the endpoint', example='/customer')
|
||||
endpoint_description: Optional[str] = Field(None, min_length=1, max_length=255, description='Description of the endpoint', example='Get customer details')
|
||||
endpoint_servers: Optional[List[str]] = Field(None, description='Optional list of backend servers for this endpoint (overrides API servers)', example=['http://localhost:8082', 'http://localhost:8083'])
|
||||
api_id: Optional[str] = Field(None, min_length=1, max_length=255, description='Unique identifier for the API, auto-generated', example=None)
|
||||
endpoint_id: Optional[str] = Field(None, min_length=1, max_length=255, description='Unique identifier for the endpoint, auto-generated', example=None)
|
||||
api_name: str | None = Field(
|
||||
None, min_length=1, max_length=50, description='Name of the API', example='customer'
|
||||
)
|
||||
api_version: str | None = Field(
|
||||
None, min_length=1, max_length=10, description='Version of the API', example='v1'
|
||||
)
|
||||
endpoint_method: str | None = Field(
|
||||
None, min_length=1, max_length=10, description='HTTP method for the endpoint', example='GET'
|
||||
)
|
||||
endpoint_uri: str | None = Field(
|
||||
None, min_length=1, max_length=255, description='URI for the endpoint', example='/customer'
|
||||
)
|
||||
endpoint_description: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description='Description of the endpoint',
|
||||
example='Get customer details',
|
||||
)
|
||||
endpoint_servers: list[str] | None = Field(
|
||||
None,
|
||||
description='Optional list of backend servers for this endpoint (overrides API servers)',
|
||||
example=['http://localhost:8082', 'http://localhost:8083'],
|
||||
)
|
||||
api_id: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description='Unique identifier for the API, auto-generated',
|
||||
example=None,
|
||||
)
|
||||
endpoint_id: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description='Unique identifier for the endpoint, auto-generated',
|
||||
example=None,
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -8,10 +8,14 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from models.validation_schema_model import ValidationSchema
|
||||
|
||||
class UpdateEndpointValidationModel(BaseModel):
|
||||
|
||||
validation_enabled: bool = Field(..., description='Whether the validation is enabled', example=True)
|
||||
validation_schema: ValidationSchema = Field(..., description='The schema to validate the endpoint against', example={})
|
||||
class UpdateEndpointValidationModel(BaseModel):
|
||||
validation_enabled: bool = Field(
|
||||
..., description='Whether the validation is enabled', example=True
|
||||
)
|
||||
validation_schema: ValidationSchema = Field(
|
||||
..., description='The schema to validate the endpoint against', example={}
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -5,13 +5,22 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class UpdateGroupModel(BaseModel):
|
||||
|
||||
group_name: Optional[str] = Field(None, min_length=1, max_length=50, description='Name of the group', example='client-1-group')
|
||||
group_description: Optional[str] = Field(None, min_length=1, max_length=255, description='Description of the group', example='Group for client 1')
|
||||
api_access: Optional[List[str]] = Field(None, description='List of APIs the group can access', example=['customer/v1'])
|
||||
group_name: str | None = Field(
|
||||
None, min_length=1, max_length=50, description='Name of the group', example='client-1-group'
|
||||
)
|
||||
group_description: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description='Description of the group',
|
||||
example='Group for client 1',
|
||||
)
|
||||
api_access: list[str] | None = Field(
|
||||
None, description='List of APIs the group can access', example=['customer/v1']
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -6,9 +6,15 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class UpdatePasswordModel(BaseModel):
|
||||
|
||||
new_password: str = Field(..., min_length=6, max_length=36, description='New password of the user', example='NewPassword456!')
|
||||
class UpdatePasswordModel(BaseModel):
|
||||
new_password: str = Field(
|
||||
...,
|
||||
min_length=6,
|
||||
max_length=36,
|
||||
description='New password of the user',
|
||||
example='NewPassword456!',
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -5,28 +5,57 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class UpdateRoleModel(BaseModel):
|
||||
|
||||
role_name: Optional[str] = Field(None, min_length=1, max_length=50, description='Name of the role', example='admin')
|
||||
role_description: Optional[str] = Field(None, min_length=1, max_length=255, description='Description of the role', example='Administrator role with full access')
|
||||
manage_users: Optional[bool] = Field(None, description='Permission to manage users', example=True)
|
||||
manage_apis: Optional[bool] = Field(None, description='Permission to manage APIs', example=True)
|
||||
manage_endpoints: Optional[bool] = Field(None, description='Permission to manage endpoints', example=True)
|
||||
manage_groups: Optional[bool] = Field(None, description='Permission to manage groups', example=True)
|
||||
manage_roles: Optional[bool] = Field(None, description='Permission to manage roles', example=True)
|
||||
manage_routings: Optional[bool] = Field(None, description='Permission to manage routings', example=True)
|
||||
manage_gateway: Optional[bool] = Field(None, description='Permission to manage gateway', example=True)
|
||||
manage_subscriptions: Optional[bool] = Field(None, description='Permission to manage subscriptions', example=True)
|
||||
manage_security: Optional[bool] = Field(None, description='Permission to manage security settings', example=True)
|
||||
manage_tiers: Optional[bool] = Field(None, description='Permission to manage pricing tiers', example=True)
|
||||
manage_rate_limits: Optional[bool] = Field(None, description='Permission to manage rate limiting rules', example=True)
|
||||
manage_credits: Optional[bool] = Field(None, description='Permission to manage credits', example=True)
|
||||
manage_auth: Optional[bool] = Field(None, description='Permission to manage auth (revoke tokens/disable users)', example=True)
|
||||
view_analytics: Optional[bool] = Field(None, description='Permission to view analytics dashboard', example=True)
|
||||
view_logs: Optional[bool] = Field(None, description='Permission to view logs', example=True)
|
||||
export_logs: Optional[bool] = Field(None, description='Permission to export logs', example=True)
|
||||
role_name: str | None = Field(
|
||||
None, min_length=1, max_length=50, description='Name of the role', example='admin'
|
||||
)
|
||||
role_description: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description='Description of the role',
|
||||
example='Administrator role with full access',
|
||||
)
|
||||
manage_users: bool | None = Field(None, description='Permission to manage users', example=True)
|
||||
manage_apis: bool | None = Field(None, description='Permission to manage APIs', example=True)
|
||||
manage_endpoints: bool | None = Field(
|
||||
None, description='Permission to manage endpoints', example=True
|
||||
)
|
||||
manage_groups: bool | None = Field(
|
||||
None, description='Permission to manage groups', example=True
|
||||
)
|
||||
manage_roles: bool | None = Field(None, description='Permission to manage roles', example=True)
|
||||
manage_routings: bool | None = Field(
|
||||
None, description='Permission to manage routings', example=True
|
||||
)
|
||||
manage_gateway: bool | None = Field(
|
||||
None, description='Permission to manage gateway', example=True
|
||||
)
|
||||
manage_subscriptions: bool | None = Field(
|
||||
None, description='Permission to manage subscriptions', example=True
|
||||
)
|
||||
manage_security: bool | None = Field(
|
||||
None, description='Permission to manage security settings', example=True
|
||||
)
|
||||
manage_tiers: bool | None = Field(
|
||||
None, description='Permission to manage pricing tiers', example=True
|
||||
)
|
||||
manage_rate_limits: bool | None = Field(
|
||||
None, description='Permission to manage rate limiting rules', example=True
|
||||
)
|
||||
manage_credits: bool | None = Field(
|
||||
None, description='Permission to manage credits', example=True
|
||||
)
|
||||
manage_auth: bool | None = Field(
|
||||
None, description='Permission to manage auth (revoke tokens/disable users)', example=True
|
||||
)
|
||||
view_analytics: bool | None = Field(
|
||||
None, description='Permission to view analytics dashboard', example=True
|
||||
)
|
||||
view_logs: bool | None = Field(None, description='Permission to view logs', example=True)
|
||||
export_logs: bool | None = Field(None, description='Permission to export logs', example=True)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -5,16 +5,40 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class UpdateRoutingModel(BaseModel):
|
||||
routing_name: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=50,
|
||||
description='Name of the routing',
|
||||
example='customer-routing',
|
||||
)
|
||||
routing_servers: list[str] | None = Field(
|
||||
None,
|
||||
min_items=1,
|
||||
description='List of backend servers for the routing',
|
||||
example=['http://localhost:8080', 'http://localhost:8081'],
|
||||
)
|
||||
routing_description: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description='Description of the routing',
|
||||
example='Routing for customer API',
|
||||
)
|
||||
|
||||
routing_name: Optional[str] = Field(None, min_length=1, max_length=50, description='Name of the routing', example='customer-routing')
|
||||
routing_servers : Optional[list[str]] = Field(None, min_items=1, description='List of backend servers for the routing', example=['http://localhost:8080', 'http://localhost:8081'])
|
||||
routing_description: Optional[str] = Field(None, min_length=1, max_length=255, description='Description of the routing', example='Routing for customer API')
|
||||
|
||||
client_key: Optional[str] = Field(None, min_length=1, max_length=50, description='Client key for the routing', example='client-1')
|
||||
server_index: Optional[int] = Field(None, exclude=True, ge=0, description='Index of the server to route to', example=0)
|
||||
client_key: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=50,
|
||||
description='Client key for the routing',
|
||||
example='client-1',
|
||||
)
|
||||
server_index: int | None = Field(
|
||||
None, exclude=True, ge=0, description='Index of the server to route to', example=0
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -5,29 +5,90 @@ See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class UpdateUserModel(BaseModel):
|
||||
username: str | None = Field(
|
||||
None, min_length=3, max_length=50, description='Username of the user', example='john_doe'
|
||||
)
|
||||
email: str | None = Field(
|
||||
None,
|
||||
min_length=3,
|
||||
max_length=127,
|
||||
description='Email of the user (no strict format validation)',
|
||||
example='john@mail.com',
|
||||
)
|
||||
password: str | None = Field(
|
||||
None,
|
||||
min_length=6,
|
||||
max_length=50,
|
||||
description='Password of the user',
|
||||
example='SecurePassword@123',
|
||||
)
|
||||
role: str | None = Field(
|
||||
None, min_length=2, max_length=50, description='Role of the user', example='admin'
|
||||
)
|
||||
groups: list[str] | None = Field(
|
||||
None, description='List of groups the user belongs to', example=['client-1-group']
|
||||
)
|
||||
rate_limit_duration: int | None = Field(
|
||||
None, ge=0, description='Rate limit for the user', example=100
|
||||
)
|
||||
rate_limit_duration_type: str | None = Field(
|
||||
None, min_length=1, max_length=7, description='Duration for the rate limit', example='hour'
|
||||
)
|
||||
rate_limit_enabled: bool | None = Field(
|
||||
None, description='Whether rate limiting is enabled for this user', example=True
|
||||
)
|
||||
throttle_duration: int | None = Field(
|
||||
None, ge=0, description='Throttle limit for the user', example=10
|
||||
)
|
||||
throttle_duration_type: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=7,
|
||||
description='Duration for the throttle limit',
|
||||
example='second',
|
||||
)
|
||||
throttle_wait_duration: int | None = Field(
|
||||
None, ge=0, description='Wait time for the throttle limit', example=5
|
||||
)
|
||||
throttle_wait_duration_type: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=7,
|
||||
description='Wait duration for the throttle limit',
|
||||
example='seconds',
|
||||
)
|
||||
throttle_queue_limit: int | None = Field(
|
||||
None, ge=0, description='Throttle queue limit for the user', example=10
|
||||
)
|
||||
throttle_enabled: bool | None = Field(
|
||||
None, description='Whether throttling is enabled for this user', example=True
|
||||
)
|
||||
custom_attributes: dict | None = Field(
|
||||
None, description='Custom attributes for the user', example={'custom_key': 'custom_value'}
|
||||
)
|
||||
bandwidth_limit_bytes: int | None = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description='Maximum bandwidth allowed within the window (bytes)',
|
||||
example=1073741824,
|
||||
)
|
||||
bandwidth_limit_window: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=10,
|
||||
description='Bandwidth window unit (second/minute/hour/day/month)',
|
||||
example='day',
|
||||
)
|
||||
bandwidth_limit_enabled: bool | None = Field(
|
||||
None,
|
||||
description='Whether bandwidth limit enforcement is enabled for this user',
|
||||
example=True,
|
||||
)
|
||||
active: bool | None = Field(None, description='Active status of the user', example=True)
|
||||
ui_access: bool | None = Field(None, description='UI access for the user', example=False)
|
||||
|
||||
username: Optional[str] = Field(None, min_length=3, max_length=50, description='Username of the user', example='john_doe')
|
||||
email: Optional[str] = Field(None, min_length=3, max_length=127, description='Email of the user (no strict format validation)', example='john@mail.com')
|
||||
password: Optional[str] = Field(None, min_length=6, max_length=50, description='Password of the user', example='SecurePassword@123')
|
||||
role: Optional[str] = Field(None, min_length=2, max_length=50, description='Role of the user', example='admin')
|
||||
groups: Optional[List[str]] = Field(None, description='List of groups the user belongs to', example=['client-1-group'])
|
||||
rate_limit_duration: Optional[int] = Field(None, ge=0, description='Rate limit for the user', example=100)
|
||||
rate_limit_duration_type: Optional[str] = Field(None, min_length=1, max_length=7, description='Duration for the rate limit', example='hour')
|
||||
rate_limit_enabled: Optional[bool] = Field(None, description='Whether rate limiting is enabled for this user', example=True)
|
||||
throttle_duration: Optional[int] = Field(None, ge=0, description='Throttle limit for the user', example=10)
|
||||
throttle_duration_type: Optional[str] = Field(None, min_length=1, max_length=7, description='Duration for the throttle limit', example='second')
|
||||
throttle_wait_duration: Optional[int] = Field(None, ge=0, description='Wait time for the throttle limit', example=5)
|
||||
throttle_wait_duration_type: Optional[str] = Field(None, min_length=1, max_length=7, description='Wait duration for the throttle limit', example='seconds')
|
||||
throttle_queue_limit: Optional[int] = Field(None, ge=0, description='Throttle queue limit for the user', example=10)
|
||||
throttle_enabled: Optional[bool] = Field(None, description='Whether throttling is enabled for this user', example=True)
|
||||
custom_attributes: Optional[dict] = Field(None, description='Custom attributes for the user', example={'custom_key': 'custom_value'})
|
||||
bandwidth_limit_bytes: Optional[int] = Field(None, ge=0, description='Maximum bandwidth allowed within the window (bytes)', example=1073741824)
|
||||
bandwidth_limit_window: Optional[str] = Field(None, min_length=1, max_length=10, description='Bandwidth window unit (second/minute/hour/day/month)', example='day')
|
||||
bandwidth_limit_enabled: Optional[bool] = Field(None, description='Whether bandwidth limit enforcement is enabled for this user', example=True)
|
||||
active: Optional[bool] = Field(None, description='Active status of the user', example=True)
|
||||
ui_access: Optional[bool] = Field(None, description='UI access for the user', example=False)
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -5,17 +5,16 @@ See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class UpdateVaultEntryModel(BaseModel):
|
||||
"""Model for updating a vault entry. Only description can be updated, not the value."""
|
||||
|
||||
description: Optional[str] = Field(
|
||||
|
||||
description: str | None = Field(
|
||||
None,
|
||||
max_length=500,
|
||||
description='Updated description of what this key is used for',
|
||||
example='Production API key for payment gateway - updated'
|
||||
example='Production API key for payment gateway - updated',
|
||||
)
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -4,23 +4,39 @@ Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UserCreditInformationModel(BaseModel):
|
||||
tier_name: str = Field(..., min_length=1, max_length=50, description='Name of the credit tier', example='basic')
|
||||
tier_name: str = Field(
|
||||
..., min_length=1, max_length=50, description='Name of the credit tier', example='basic'
|
||||
)
|
||||
available_credits: int = Field(..., description='Number of available credits', example=50)
|
||||
|
||||
reset_date: Optional[str] = Field(None, description='Date when paid credits are reset', example='2023-10-01')
|
||||
user_api_key: Optional[str] = Field(None, description='User specific API key for the credit tier', example='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')
|
||||
reset_date: str | None = Field(
|
||||
None, description='Date when paid credits are reset', example='2023-10-01'
|
||||
)
|
||||
user_api_key: str | None = Field(
|
||||
None,
|
||||
description='User specific API key for the credit tier',
|
||||
example='xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
class UserCreditModel(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=50, description='Username of credits owner', example='client-1')
|
||||
users_credits: Dict[str, UserCreditInformationModel] = Field(..., description='Credits information. Key is the credit group name')
|
||||
username: str = Field(
|
||||
...,
|
||||
min_length=3,
|
||||
max_length=50,
|
||||
description='Username of credits owner',
|
||||
example='client-1',
|
||||
)
|
||||
users_credits: dict[str, UserCreditInformationModel] = Field(
|
||||
..., description='Credits information. Key is the credit group name'
|
||||
)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
@@ -4,33 +4,96 @@ Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class UserModelResponse(BaseModel):
|
||||
|
||||
username: Optional[str] = Field(None, min_length=3, max_length=50, description='Username of the user', example='john_doe')
|
||||
email: Optional[EmailStr] = Field(None, description='Email of the user', example='john@mail.com')
|
||||
password: Optional[str] = Field(None, min_length=6, max_length=50, description='Password of the user', example='SecurePassword@123')
|
||||
role: Optional[str] = Field(None, min_length=2, max_length=50, description='Role of the user', example='admin')
|
||||
groups: Optional[List[str]] = Field(None, description='List of groups the user belongs to', example=['client-1-group'])
|
||||
rate_limit_duration: Optional[int] = Field(None, ge=0, description='Rate limit for the user', example=100)
|
||||
rate_limit_duration_type: Optional[str] = Field(None, min_length=1, max_length=7, description='Duration for the rate limit', example='hour')
|
||||
rate_limit_enabled: Optional[bool] = Field(None, description='Whether rate limiting is enabled for this user', example=True)
|
||||
throttle_duration: Optional[int] = Field(None, ge=0, description='Throttle limit for the user', example=10)
|
||||
throttle_duration_type: Optional[str] = Field(None, min_length=1, max_length=7, description='Duration for the throttle limit', example='second')
|
||||
throttle_wait_duration: Optional[int] = Field(None, ge=0, description='Wait time for the throttle limit', example=5)
|
||||
throttle_wait_duration_type: Optional[str] = Field(None, min_length=1, max_length=7, description='Wait duration for the throttle limit', example='seconds')
|
||||
throttle_queue_limit: Optional[int] = Field(None, ge=0, description='Throttle queue limit for the user', example=10)
|
||||
throttle_enabled: Optional[bool] = Field(None, description='Whether throttling is enabled for this user', example=True)
|
||||
custom_attributes: Optional[dict] = Field(None, description='Custom attributes for the user', example={'custom_key': 'custom_value'})
|
||||
bandwidth_limit_bytes: Optional[int] = Field(None, ge=0, description='Maximum bandwidth allowed within the window (bytes)', example=1073741824)
|
||||
bandwidth_limit_window: Optional[str] = Field(None, min_length=1, max_length=10, description='Bandwidth window unit (second/minute/hour/day/month)', example='day')
|
||||
bandwidth_usage_bytes: Optional[int] = Field(None, ge=0, description='Current bandwidth usage in the active window (bytes)', example=123456)
|
||||
bandwidth_resets_at: Optional[int] = Field(None, description='UTC epoch seconds when the current bandwidth window resets', example=1727481600)
|
||||
bandwidth_limit_enabled: Optional[bool] = Field(None, description='Whether bandwidth limit enforcement is enabled for this user', example=True)
|
||||
active: Optional[bool] = Field(None, description='Active status of the user', example=True)
|
||||
ui_access: Optional[bool] = Field(None, description='UI access for the user', example=False)
|
||||
username: str | None = Field(
|
||||
None, min_length=3, max_length=50, description='Username of the user', example='john_doe'
|
||||
)
|
||||
email: EmailStr | None = Field(None, description='Email of the user', example='john@mail.com')
|
||||
password: str | None = Field(
|
||||
None,
|
||||
min_length=6,
|
||||
max_length=50,
|
||||
description='Password of the user',
|
||||
example='SecurePassword@123',
|
||||
)
|
||||
role: str | None = Field(
|
||||
None, min_length=2, max_length=50, description='Role of the user', example='admin'
|
||||
)
|
||||
groups: list[str] | None = Field(
|
||||
None, description='List of groups the user belongs to', example=['client-1-group']
|
||||
)
|
||||
rate_limit_duration: int | None = Field(
|
||||
None, ge=0, description='Rate limit for the user', example=100
|
||||
)
|
||||
rate_limit_duration_type: str | None = Field(
|
||||
None, min_length=1, max_length=7, description='Duration for the rate limit', example='hour'
|
||||
)
|
||||
rate_limit_enabled: bool | None = Field(
|
||||
None, description='Whether rate limiting is enabled for this user', example=True
|
||||
)
|
||||
throttle_duration: int | None = Field(
|
||||
None, ge=0, description='Throttle limit for the user', example=10
|
||||
)
|
||||
throttle_duration_type: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=7,
|
||||
description='Duration for the throttle limit',
|
||||
example='second',
|
||||
)
|
||||
throttle_wait_duration: int | None = Field(
|
||||
None, ge=0, description='Wait time for the throttle limit', example=5
|
||||
)
|
||||
throttle_wait_duration_type: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=7,
|
||||
description='Wait duration for the throttle limit',
|
||||
example='seconds',
|
||||
)
|
||||
throttle_queue_limit: int | None = Field(
|
||||
None, ge=0, description='Throttle queue limit for the user', example=10
|
||||
)
|
||||
throttle_enabled: bool | None = Field(
|
||||
None, description='Whether throttling is enabled for this user', example=True
|
||||
)
|
||||
custom_attributes: dict | None = Field(
|
||||
None, description='Custom attributes for the user', example={'custom_key': 'custom_value'}
|
||||
)
|
||||
bandwidth_limit_bytes: int | None = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description='Maximum bandwidth allowed within the window (bytes)',
|
||||
example=1073741824,
|
||||
)
|
||||
bandwidth_limit_window: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=10,
|
||||
description='Bandwidth window unit (second/minute/hour/day/month)',
|
||||
example='day',
|
||||
)
|
||||
bandwidth_usage_bytes: int | None = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description='Current bandwidth usage in the active window (bytes)',
|
||||
example=123456,
|
||||
)
|
||||
bandwidth_resets_at: int | None = Field(
|
||||
None,
|
||||
description='UTC epoch seconds when the current bandwidth window resets',
|
||||
example=1727481600,
|
||||
)
|
||||
bandwidth_limit_enabled: bool | None = Field(
|
||||
None,
|
||||
description='Whether bandwidth limit enforcement is enabled for this user',
|
||||
example=True,
|
||||
)
|
||||
active: bool | None = Field(None, description='Active status of the user', example=True)
|
||||
ui_access: bool | None = Field(None, description='UI access for the user', example=False)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -4,11 +4,11 @@ Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
"""
|
||||
|
||||
from typing import Dict
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from models.field_validation_model import FieldValidation
|
||||
|
||||
|
||||
class ValidationSchema(BaseModel):
|
||||
"""Validation schema for endpoint request/response validation.
|
||||
|
||||
@@ -57,21 +57,12 @@ class ValidationSchema(BaseModel):
|
||||
}
|
||||
}
|
||||
"""
|
||||
validation_schema: Dict[str, FieldValidation] = Field(
|
||||
|
||||
validation_schema: dict[str, FieldValidation] = Field(
|
||||
...,
|
||||
description='The schema to validate the endpoint against',
|
||||
example={
|
||||
'user.name': {
|
||||
'required': True,
|
||||
'type': 'string',
|
||||
'min': 2,
|
||||
'max': 50
|
||||
},
|
||||
'user.age': {
|
||||
'required': True,
|
||||
'type': 'number',
|
||||
'min': 0,
|
||||
'max': 120
|
||||
}
|
||||
}
|
||||
)
|
||||
'user.name': {'required': True, 'type': 'string', 'min': 2, 'max': 50},
|
||||
'user.age': {'required': True, 'type': 'number', 'min': 0, 'max': 120},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -5,40 +5,29 @@ See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class VaultEntryModelResponse(BaseModel):
|
||||
"""Response model for vault entry. Value is never returned."""
|
||||
|
||||
key_name: str = Field(
|
||||
...,
|
||||
description='Name of the vault key',
|
||||
example='api_key_production'
|
||||
)
|
||||
|
||||
username: str = Field(
|
||||
...,
|
||||
description='Username of the vault entry owner',
|
||||
example='john_doe'
|
||||
)
|
||||
|
||||
description: Optional[str] = Field(
|
||||
|
||||
key_name: str = Field(..., description='Name of the vault key', example='api_key_production')
|
||||
|
||||
username: str = Field(..., description='Username of the vault entry owner', example='john_doe')
|
||||
|
||||
description: str | None = Field(
|
||||
None,
|
||||
description='Description of what this key is used for',
|
||||
example='Production API key for payment gateway'
|
||||
example='Production API key for payment gateway',
|
||||
)
|
||||
|
||||
created_at: Optional[str] = Field(
|
||||
None,
|
||||
description='Timestamp when the entry was created',
|
||||
example='2024-11-22T10:15:30Z'
|
||||
|
||||
created_at: str | None = Field(
|
||||
None, description='Timestamp when the entry was created', example='2024-11-22T10:15:30Z'
|
||||
)
|
||||
|
||||
updated_at: Optional[str] = Field(
|
||||
|
||||
updated_at: str | None = Field(
|
||||
None,
|
||||
description='Timestamp when the entry was last updated',
|
||||
example='2024-11-22T10:15:30Z'
|
||||
example='2024-11-22T10:15:30Z',
|
||||
)
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -9,20 +9,17 @@ Provides comprehensive analytics endpoints for:
|
||||
- Endpoint performance analysis
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Query, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
import uuid
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
|
||||
from models.response_model import ResponseModel
|
||||
from utils.response_util import respond_rest, process_response
|
||||
from utils.auth_util import auth_required
|
||||
from utils.role_util import platform_role_required_bool
|
||||
from utils.enhanced_metrics_util import enhanced_metrics_store
|
||||
from utils.analytics_aggregator import analytics_aggregator
|
||||
from utils.response_util import respond_rest
|
||||
from utils.role_util import platform_role_required_bool
|
||||
|
||||
analytics_router = APIRouter()
|
||||
logger = logging.getLogger('doorman.analytics')
|
||||
@@ -32,7 +29,9 @@ logger = logging.getLogger('doorman.analytics')
|
||||
# ENDPOINT 1: Dashboard Overview
|
||||
# ============================================================================
|
||||
|
||||
@analytics_router.get('/analytics/overview',
|
||||
|
||||
@analytics_router.get(
|
||||
'/analytics/overview',
|
||||
description='Get dashboard overview statistics',
|
||||
response_model=ResponseModel,
|
||||
responses={
|
||||
@@ -50,33 +49,33 @@ logger = logging.getLogger('doorman.analytics')
|
||||
'p75': 180.0,
|
||||
'p90': 250.0,
|
||||
'p95': 300.0,
|
||||
'p99': 450.0
|
||||
'p99': 450.0,
|
||||
},
|
||||
'unique_users': 150,
|
||||
'total_bandwidth': 1073741824,
|
||||
'top_apis': [
|
||||
{'api': 'rest:customer', 'count': 5000},
|
||||
{'api': 'rest:orders', 'count': 3000}
|
||||
{'api': 'rest:orders', 'count': 3000},
|
||||
],
|
||||
'top_users': [
|
||||
{'user': 'john_doe', 'count': 500},
|
||||
{'user': 'jane_smith', 'count': 300}
|
||||
]
|
||||
{'user': 'jane_smith', 'count': 300},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
async def get_analytics_overview(
|
||||
request: Request,
|
||||
start_ts: Optional[int] = Query(None, description='Start timestamp (Unix seconds)'),
|
||||
end_ts: Optional[int] = Query(None, description='End timestamp (Unix seconds)'),
|
||||
range: Optional[str] = Query('24h', description='Time range (1h, 24h, 7d, 30d)')
|
||||
start_ts: int | None = Query(None, description='Start timestamp (Unix seconds)'),
|
||||
end_ts: int | None = Query(None, description='End timestamp (Unix seconds)'),
|
||||
range: str | None = Query('24h', description='Time range (1h, 24h, 7d, 30d)'),
|
||||
):
|
||||
"""
|
||||
Get dashboard overview statistics.
|
||||
|
||||
|
||||
Returns summary metrics including:
|
||||
- Total requests and errors
|
||||
- Error rate
|
||||
@@ -89,23 +88,27 @@ async def get_analytics_overview(
|
||||
"""
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
try:
|
||||
# Authentication and authorization
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
|
||||
logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}')
|
||||
|
||||
logger.info(
|
||||
f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}'
|
||||
)
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
|
||||
|
||||
if not await platform_role_required_bool(username, 'view_analytics'):
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=403,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS001',
|
||||
error_message='You do not have permission to view analytics'
|
||||
))
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=403,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS001',
|
||||
error_message='You do not have permission to view analytics',
|
||||
)
|
||||
)
|
||||
|
||||
# Determine time range
|
||||
if start_ts and end_ts:
|
||||
# Use provided timestamps
|
||||
@@ -113,24 +116,19 @@ async def get_analytics_overview(
|
||||
else:
|
||||
# Use range parameter
|
||||
end_ts = int(time.time())
|
||||
range_map = {
|
||||
'1h': 3600,
|
||||
'24h': 86400,
|
||||
'7d': 604800,
|
||||
'30d': 2592000
|
||||
}
|
||||
range_map = {'1h': 3600, '24h': 86400, '7d': 604800, '30d': 2592000}
|
||||
seconds = range_map.get(range, 86400)
|
||||
start_ts = end_ts - seconds
|
||||
|
||||
|
||||
# Get analytics snapshot
|
||||
snapshot = enhanced_metrics_store.get_snapshot(start_ts, end_ts)
|
||||
|
||||
|
||||
# Build response
|
||||
overview = {
|
||||
'time_range': {
|
||||
'start_ts': start_ts,
|
||||
'end_ts': end_ts,
|
||||
'duration_seconds': end_ts - start_ts
|
||||
'duration_seconds': end_ts - start_ts,
|
||||
},
|
||||
'summary': {
|
||||
'total_requests': snapshot.total_requests,
|
||||
@@ -140,29 +138,31 @@ async def get_analytics_overview(
|
||||
'unique_users': snapshot.unique_users,
|
||||
'total_bandwidth': snapshot.total_bytes_in + snapshot.total_bytes_out,
|
||||
'bandwidth_in': snapshot.total_bytes_in,
|
||||
'bandwidth_out': snapshot.total_bytes_out
|
||||
'bandwidth_out': snapshot.total_bytes_out,
|
||||
},
|
||||
'percentiles': snapshot.percentiles.to_dict(),
|
||||
'top_apis': snapshot.top_apis,
|
||||
'top_users': snapshot.top_users,
|
||||
'status_distribution': snapshot.status_distribution
|
||||
'status_distribution': snapshot.status_distribution,
|
||||
}
|
||||
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=200,
|
||||
response_headers={'request_id': request_id},
|
||||
response=overview
|
||||
))
|
||||
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=200, response_headers={'request_id': request_id}, response=overview
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'{request_id} | Error: {str(e)}', exc_info=True)
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS999',
|
||||
error_message='An unexpected error occurred'
|
||||
))
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS999',
|
||||
error_message='An unexpected error occurred',
|
||||
)
|
||||
)
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
logger.info(f'{request_id} | Total time: {(end_time - start_time) * 1000:.2f}ms')
|
||||
@@ -172,21 +172,28 @@ async def get_analytics_overview(
|
||||
# ENDPOINT 2: Time-Series Data
|
||||
# ============================================================================
|
||||
|
||||
@analytics_router.get('/analytics/timeseries',
|
||||
|
||||
@analytics_router.get(
|
||||
'/analytics/timeseries',
|
||||
description='Get time-series analytics data with filtering',
|
||||
response_model=ResponseModel
|
||||
response_model=ResponseModel,
|
||||
)
|
||||
async def get_analytics_timeseries(
|
||||
request: Request,
|
||||
start_ts: Optional[int] = Query(None, description='Start timestamp (Unix seconds)'),
|
||||
end_ts: Optional[int] = Query(None, description='End timestamp (Unix seconds)'),
|
||||
range: Optional[str] = Query('24h', description='Time range (1h, 24h, 7d, 30d)'),
|
||||
granularity: Optional[str] = Query('auto', description='Data granularity (auto, minute, 5minute, hour, day)'),
|
||||
metric_type: Optional[str] = Query(None, description='Specific metric to return (request_count, error_rate, latency, bandwidth)')
|
||||
start_ts: int | None = Query(None, description='Start timestamp (Unix seconds)'),
|
||||
end_ts: int | None = Query(None, description='End timestamp (Unix seconds)'),
|
||||
range: str | None = Query('24h', description='Time range (1h, 24h, 7d, 30d)'),
|
||||
granularity: str | None = Query(
|
||||
'auto', description='Data granularity (auto, minute, 5minute, hour, day)'
|
||||
),
|
||||
metric_type: str | None = Query(
|
||||
None,
|
||||
description='Specific metric to return (request_count, error_rate, latency, bandwidth)',
|
||||
),
|
||||
):
|
||||
"""
|
||||
Get time-series analytics data.
|
||||
|
||||
|
||||
Returns series of data points over time with:
|
||||
- Timestamp
|
||||
- Request count
|
||||
@@ -198,19 +205,21 @@ async def get_analytics_timeseries(
|
||||
"""
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
|
||||
|
||||
if not await platform_role_required_bool(username, 'view_analytics'):
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=403,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS001',
|
||||
error_message='You do not have permission to view analytics'
|
||||
))
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=403,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS001',
|
||||
error_message='You do not have permission to view analytics',
|
||||
)
|
||||
)
|
||||
|
||||
# Determine time range
|
||||
if start_ts and end_ts:
|
||||
pass
|
||||
@@ -219,10 +228,10 @@ async def get_analytics_timeseries(
|
||||
range_map = {'1h': 3600, '24h': 86400, '7d': 604800, '30d': 2592000}
|
||||
seconds = range_map.get(range, 86400)
|
||||
start_ts = end_ts - seconds
|
||||
|
||||
|
||||
# Get snapshot with time-series data
|
||||
snapshot = enhanced_metrics_store.get_snapshot(start_ts, end_ts, granularity)
|
||||
|
||||
|
||||
# Filter by metric type if specified
|
||||
series = snapshot.series
|
||||
if metric_type:
|
||||
@@ -243,27 +252,31 @@ async def get_analytics_timeseries(
|
||||
filtered_point['bytes_out'] = point['bytes_out']
|
||||
filtered_series.append(filtered_point)
|
||||
series = filtered_series
|
||||
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=200,
|
||||
response_headers={'request_id': request_id},
|
||||
response={
|
||||
'time_range': {'start_ts': start_ts, 'end_ts': end_ts},
|
||||
'granularity': granularity,
|
||||
'series': series,
|
||||
'data_points': len(series)
|
||||
}
|
||||
))
|
||||
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=200,
|
||||
response_headers={'request_id': request_id},
|
||||
response={
|
||||
'time_range': {'start_ts': start_ts, 'end_ts': end_ts},
|
||||
'granularity': granularity,
|
||||
'series': series,
|
||||
'data_points': len(series),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'{request_id} | Error: {str(e)}', exc_info=True)
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS999',
|
||||
error_message='An unexpected error occurred'
|
||||
))
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS999',
|
||||
error_message='An unexpected error occurred',
|
||||
)
|
||||
)
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
logger.info(f'{request_id} | Total time: {(end_time - start_time) * 1000:.2f}ms')
|
||||
@@ -273,20 +286,20 @@ async def get_analytics_timeseries(
|
||||
# ENDPOINT 3: Top APIs
|
||||
# ============================================================================
|
||||
|
||||
@analytics_router.get('/analytics/top-apis',
|
||||
description='Get most used APIs',
|
||||
response_model=ResponseModel
|
||||
|
||||
@analytics_router.get(
|
||||
'/analytics/top-apis', description='Get most used APIs', response_model=ResponseModel
|
||||
)
|
||||
async def get_top_apis(
|
||||
request: Request,
|
||||
start_ts: Optional[int] = Query(None),
|
||||
end_ts: Optional[int] = Query(None),
|
||||
range: Optional[str] = Query('24h'),
|
||||
limit: int = Query(10, ge=1, le=100, description='Number of APIs to return')
|
||||
start_ts: int | None = Query(None),
|
||||
end_ts: int | None = Query(None),
|
||||
range: str | None = Query('24h'),
|
||||
limit: int = Query(10, ge=1, le=100, description='Number of APIs to return'),
|
||||
):
|
||||
"""
|
||||
Get top N most used APIs.
|
||||
|
||||
|
||||
Returns list of APIs sorted by request count with:
|
||||
- API name
|
||||
- Total requests
|
||||
@@ -296,19 +309,21 @@ async def get_top_apis(
|
||||
"""
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
|
||||
|
||||
if not await platform_role_required_bool(username, 'view_analytics'):
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=403,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS001',
|
||||
error_message='You do not have permission to view analytics'
|
||||
))
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=403,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS001',
|
||||
error_message='You do not have permission to view analytics',
|
||||
)
|
||||
)
|
||||
|
||||
# Determine time range
|
||||
if start_ts and end_ts:
|
||||
pass
|
||||
@@ -317,32 +332,36 @@ async def get_top_apis(
|
||||
range_map = {'1h': 3600, '24h': 86400, '7d': 604800, '30d': 2592000}
|
||||
seconds = range_map.get(range, 86400)
|
||||
start_ts = end_ts - seconds
|
||||
|
||||
|
||||
# Get snapshot
|
||||
snapshot = enhanced_metrics_store.get_snapshot(start_ts, end_ts)
|
||||
|
||||
|
||||
# Get top APIs (already sorted by count)
|
||||
top_apis = snapshot.top_apis[:limit]
|
||||
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=200,
|
||||
response_headers={'request_id': request_id},
|
||||
response={
|
||||
'time_range': {'start_ts': start_ts, 'end_ts': end_ts},
|
||||
'top_apis': top_apis,
|
||||
'total_apis': len(snapshot.top_apis)
|
||||
}
|
||||
))
|
||||
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=200,
|
||||
response_headers={'request_id': request_id},
|
||||
response={
|
||||
'time_range': {'start_ts': start_ts, 'end_ts': end_ts},
|
||||
'top_apis': top_apis,
|
||||
'total_apis': len(snapshot.top_apis),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'{request_id} | Error: {str(e)}', exc_info=True)
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS999',
|
||||
error_message='An unexpected error occurred'
|
||||
))
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS999',
|
||||
error_message='An unexpected error occurred',
|
||||
)
|
||||
)
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
logger.info(f'{request_id} | Total time: {(end_time - start_time) * 1000:.2f}ms')
|
||||
@@ -352,37 +371,39 @@ async def get_top_apis(
|
||||
# ENDPOINT 4: Top Users
|
||||
# ============================================================================
|
||||
|
||||
@analytics_router.get('/analytics/top-users',
|
||||
description='Get highest consuming users',
|
||||
response_model=ResponseModel
|
||||
|
||||
@analytics_router.get(
|
||||
'/analytics/top-users', description='Get highest consuming users', response_model=ResponseModel
|
||||
)
|
||||
async def get_top_users(
|
||||
request: Request,
|
||||
start_ts: Optional[int] = Query(None),
|
||||
end_ts: Optional[int] = Query(None),
|
||||
range: Optional[str] = Query('24h'),
|
||||
limit: int = Query(10, ge=1, le=100, description='Number of users to return')
|
||||
start_ts: int | None = Query(None),
|
||||
end_ts: int | None = Query(None),
|
||||
range: str | None = Query('24h'),
|
||||
limit: int = Query(10, ge=1, le=100, description='Number of users to return'),
|
||||
):
|
||||
"""
|
||||
Get top N highest consuming users.
|
||||
|
||||
|
||||
Returns list of users sorted by request count.
|
||||
"""
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
|
||||
|
||||
if not await platform_role_required_bool(username, 'view_analytics'):
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=403,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS001',
|
||||
error_message='You do not have permission to view analytics'
|
||||
))
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=403,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS001',
|
||||
error_message='You do not have permission to view analytics',
|
||||
)
|
||||
)
|
||||
|
||||
# Determine time range
|
||||
if start_ts and end_ts:
|
||||
pass
|
||||
@@ -391,32 +412,36 @@ async def get_top_users(
|
||||
range_map = {'1h': 3600, '24h': 86400, '7d': 604800, '30d': 2592000}
|
||||
seconds = range_map.get(range, 86400)
|
||||
start_ts = end_ts - seconds
|
||||
|
||||
|
||||
# Get snapshot
|
||||
snapshot = enhanced_metrics_store.get_snapshot(start_ts, end_ts)
|
||||
|
||||
|
||||
# Get top users (already sorted by count)
|
||||
top_users = snapshot.top_users[:limit]
|
||||
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=200,
|
||||
response_headers={'request_id': request_id},
|
||||
response={
|
||||
'time_range': {'start_ts': start_ts, 'end_ts': end_ts},
|
||||
'top_users': top_users,
|
||||
'total_users': len(snapshot.top_users)
|
||||
}
|
||||
))
|
||||
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=200,
|
||||
response_headers={'request_id': request_id},
|
||||
response={
|
||||
'time_range': {'start_ts': start_ts, 'end_ts': end_ts},
|
||||
'top_users': top_users,
|
||||
'total_users': len(snapshot.top_users),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'{request_id} | Error: {str(e)}', exc_info=True)
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS999',
|
||||
error_message='An unexpected error occurred'
|
||||
))
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS999',
|
||||
error_message='An unexpected error occurred',
|
||||
)
|
||||
)
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
logger.info(f'{request_id} | Total time: {(end_time - start_time) * 1000:.2f}ms')
|
||||
@@ -426,21 +451,23 @@ async def get_top_users(
|
||||
# ENDPOINT 5: Top Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@analytics_router.get('/analytics/top-endpoints',
|
||||
|
||||
@analytics_router.get(
|
||||
'/analytics/top-endpoints',
|
||||
description='Get slowest/most-used endpoints',
|
||||
response_model=ResponseModel
|
||||
response_model=ResponseModel,
|
||||
)
|
||||
async def get_top_endpoints(
|
||||
request: Request,
|
||||
start_ts: Optional[int] = Query(None),
|
||||
end_ts: Optional[int] = Query(None),
|
||||
range: Optional[str] = Query('24h'),
|
||||
start_ts: int | None = Query(None),
|
||||
end_ts: int | None = Query(None),
|
||||
range: str | None = Query('24h'),
|
||||
sort_by: str = Query('count', description='Sort by: count, avg_ms, error_rate'),
|
||||
limit: int = Query(10, ge=1, le=100)
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
):
|
||||
"""
|
||||
Get top endpoints sorted by usage or performance.
|
||||
|
||||
|
||||
Returns detailed per-endpoint metrics including:
|
||||
- Request count
|
||||
- Error count and rate
|
||||
@@ -449,19 +476,21 @@ async def get_top_endpoints(
|
||||
"""
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
|
||||
|
||||
if not await platform_role_required_bool(username, 'view_analytics'):
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=403,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS001',
|
||||
error_message='You do not have permission to view analytics'
|
||||
))
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=403,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS001',
|
||||
error_message='You do not have permission to view analytics',
|
||||
)
|
||||
)
|
||||
|
||||
# Determine time range
|
||||
if start_ts and end_ts:
|
||||
pass
|
||||
@@ -470,41 +499,45 @@ async def get_top_endpoints(
|
||||
range_map = {'1h': 3600, '24h': 86400, '7d': 604800, '30d': 2592000}
|
||||
seconds = range_map.get(range, 86400)
|
||||
start_ts = end_ts - seconds
|
||||
|
||||
|
||||
# Get snapshot
|
||||
snapshot = enhanced_metrics_store.get_snapshot(start_ts, end_ts)
|
||||
|
||||
|
||||
# Get and sort endpoints
|
||||
endpoints = snapshot.top_endpoints
|
||||
|
||||
|
||||
if sort_by == 'avg_ms':
|
||||
endpoints.sort(key=lambda x: x['avg_ms'], reverse=True)
|
||||
elif sort_by == 'error_rate':
|
||||
endpoints.sort(key=lambda x: x['error_rate'], reverse=True)
|
||||
# Default is already sorted by count
|
||||
|
||||
|
||||
top_endpoints = endpoints[:limit]
|
||||
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=200,
|
||||
response_headers={'request_id': request_id},
|
||||
response={
|
||||
'time_range': {'start_ts': start_ts, 'end_ts': end_ts},
|
||||
'sort_by': sort_by,
|
||||
'top_endpoints': top_endpoints,
|
||||
'total_endpoints': len(endpoints)
|
||||
}
|
||||
))
|
||||
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=200,
|
||||
response_headers={'request_id': request_id},
|
||||
response={
|
||||
'time_range': {'start_ts': start_ts, 'end_ts': end_ts},
|
||||
'sort_by': sort_by,
|
||||
'top_endpoints': top_endpoints,
|
||||
'total_endpoints': len(endpoints),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'{request_id} | Error: {str(e)}', exc_info=True)
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS999',
|
||||
error_message='An unexpected error occurred'
|
||||
))
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS999',
|
||||
error_message='An unexpected error occurred',
|
||||
)
|
||||
)
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
logger.info(f'{request_id} | Total time: {(end_time - start_time) * 1000:.2f}ms')
|
||||
@@ -514,21 +547,23 @@ async def get_top_endpoints(
|
||||
# ENDPOINT 6: Per-API Breakdown
|
||||
# ============================================================================
|
||||
|
||||
@analytics_router.get('/analytics/api/{api_name}/{version}',
|
||||
|
||||
@analytics_router.get(
|
||||
'/analytics/api/{api_name}/{version}',
|
||||
description='Get detailed analytics for a specific API',
|
||||
response_model=ResponseModel
|
||||
response_model=ResponseModel,
|
||||
)
|
||||
async def get_api_analytics(
|
||||
request: Request,
|
||||
api_name: str,
|
||||
version: str,
|
||||
start_ts: Optional[int] = Query(None),
|
||||
end_ts: Optional[int] = Query(None),
|
||||
range: Optional[str] = Query('24h')
|
||||
start_ts: int | None = Query(None),
|
||||
end_ts: int | None = Query(None),
|
||||
range: str | None = Query('24h'),
|
||||
):
|
||||
"""
|
||||
Get detailed analytics for a specific API.
|
||||
|
||||
|
||||
Returns:
|
||||
- Total requests for this API
|
||||
- Error count and rate
|
||||
@@ -538,19 +573,21 @@ async def get_api_analytics(
|
||||
"""
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
|
||||
|
||||
if not await platform_role_required_bool(username, 'view_analytics'):
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=403,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS001',
|
||||
error_message='You do not have permission to view analytics'
|
||||
))
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=403,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS001',
|
||||
error_message='You do not have permission to view analytics',
|
||||
)
|
||||
)
|
||||
|
||||
# Determine time range
|
||||
if start_ts and end_ts:
|
||||
pass
|
||||
@@ -559,55 +596,62 @@ async def get_api_analytics(
|
||||
range_map = {'1h': 3600, '24h': 86400, '7d': 604800, '30d': 2592000}
|
||||
seconds = range_map.get(range, 86400)
|
||||
start_ts = end_ts - seconds
|
||||
|
||||
|
||||
# Get full snapshot
|
||||
snapshot = enhanced_metrics_store.get_snapshot(start_ts, end_ts)
|
||||
|
||||
|
||||
# Filter for this API
|
||||
api_key = f"rest:{api_name}" # Assuming REST API
|
||||
|
||||
api_key = f'rest:{api_name}' # Assuming REST API
|
||||
|
||||
# Find API in top_apis
|
||||
api_data = None
|
||||
for api, count in snapshot.top_apis:
|
||||
if api == api_key:
|
||||
api_data = {'api': api, 'count': count}
|
||||
break
|
||||
|
||||
|
||||
if not api_data:
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=404,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS404',
|
||||
error_message=f'No data found for API: {api_name}/{version}'
|
||||
))
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=404,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS404',
|
||||
error_message=f'No data found for API: {api_name}/{version}',
|
||||
)
|
||||
)
|
||||
|
||||
# Filter endpoints for this API
|
||||
api_endpoints = [
|
||||
ep for ep in snapshot.top_endpoints
|
||||
ep
|
||||
for ep in snapshot.top_endpoints
|
||||
if ep['endpoint_uri'].startswith(f'/{api_name}/{version}')
|
||||
]
|
||||
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=200,
|
||||
response_headers={'request_id': request_id},
|
||||
response={
|
||||
'api_name': api_name,
|
||||
'version': version,
|
||||
'time_range': {'start_ts': start_ts, 'end_ts': end_ts},
|
||||
'summary': api_data,
|
||||
'endpoints': api_endpoints
|
||||
}
|
||||
))
|
||||
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=200,
|
||||
response_headers={'request_id': request_id},
|
||||
response={
|
||||
'api_name': api_name,
|
||||
'version': version,
|
||||
'time_range': {'start_ts': start_ts, 'end_ts': end_ts},
|
||||
'summary': api_data,
|
||||
'endpoints': api_endpoints,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'{request_id} | Error: {str(e)}', exc_info=True)
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS999',
|
||||
error_message='An unexpected error occurred'
|
||||
))
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS999',
|
||||
error_message='An unexpected error occurred',
|
||||
)
|
||||
)
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
logger.info(f'{request_id} | Total time: {(end_time - start_time) * 1000:.2f}ms')
|
||||
@@ -617,20 +661,22 @@ async def get_api_analytics(
|
||||
# ENDPOINT 7: Per-User Breakdown
|
||||
# ============================================================================
|
||||
|
||||
@analytics_router.get('/analytics/user/{username}',
|
||||
|
||||
@analytics_router.get(
|
||||
'/analytics/user/{username}',
|
||||
description='Get detailed analytics for a specific user',
|
||||
response_model=ResponseModel
|
||||
response_model=ResponseModel,
|
||||
)
|
||||
async def get_user_analytics(
|
||||
request: Request,
|
||||
username: str,
|
||||
start_ts: Optional[int] = Query(None),
|
||||
end_ts: Optional[int] = Query(None),
|
||||
range: Optional[str] = Query('24h')
|
||||
start_ts: int | None = Query(None),
|
||||
end_ts: int | None = Query(None),
|
||||
range: str | None = Query('24h'),
|
||||
):
|
||||
"""
|
||||
Get detailed analytics for a specific user.
|
||||
|
||||
|
||||
Returns:
|
||||
- Total requests by this user
|
||||
- APIs accessed
|
||||
@@ -638,19 +684,21 @@ async def get_user_analytics(
|
||||
"""
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
requesting_username = payload.get('sub')
|
||||
|
||||
|
||||
if not await platform_role_required_bool(requesting_username, 'view_analytics'):
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=403,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS001',
|
||||
error_message='You do not have permission to view analytics'
|
||||
))
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=403,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS001',
|
||||
error_message='You do not have permission to view analytics',
|
||||
)
|
||||
)
|
||||
|
||||
# Determine time range
|
||||
if start_ts and end_ts:
|
||||
pass
|
||||
@@ -659,44 +707,50 @@ async def get_user_analytics(
|
||||
range_map = {'1h': 3600, '24h': 86400, '7d': 604800, '30d': 2592000}
|
||||
seconds = range_map.get(range, 86400)
|
||||
start_ts = end_ts - seconds
|
||||
|
||||
|
||||
# Get full snapshot
|
||||
snapshot = enhanced_metrics_store.get_snapshot(start_ts, end_ts)
|
||||
|
||||
|
||||
# Find user in top_users
|
||||
user_data = None
|
||||
for user, count in snapshot.top_users:
|
||||
if user == username:
|
||||
user_data = {'user': user, 'count': count}
|
||||
break
|
||||
|
||||
|
||||
if not user_data:
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=404,
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=404,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS404',
|
||||
error_message=f'No data found for user: {username}',
|
||||
)
|
||||
)
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=200,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS404',
|
||||
error_message=f'No data found for user: {username}'
|
||||
))
|
||||
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=200,
|
||||
response_headers={'request_id': request_id},
|
||||
response={
|
||||
'username': username,
|
||||
'time_range': {'start_ts': start_ts, 'end_ts': end_ts},
|
||||
'summary': user_data
|
||||
}
|
||||
))
|
||||
|
||||
response={
|
||||
'username': username,
|
||||
'time_range': {'start_ts': start_ts, 'end_ts': end_ts},
|
||||
'summary': user_data,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'{request_id} | Error: {str(e)}', exc_info=True)
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS999',
|
||||
error_message='An unexpected error occurred'
|
||||
))
|
||||
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='ANALYTICS999',
|
||||
error_message='An unexpected error occurred',
|
||||
)
|
||||
)
|
||||
|
||||
finally:
|
||||
end_time = time.time()
|
||||
logger.info(f'{request_id} | Total time: {(end_time - start_time) * 1000:.2f}ms')
|
||||
|
||||
@@ -4,22 +4,22 @@ Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
from typing import List
|
||||
import logging
|
||||
import uuid
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
|
||||
from models.response_model import ResponseModel
|
||||
from services.api_service import ApiService
|
||||
from utils.auth_util import auth_required
|
||||
from models.create_api_model import CreateApiModel
|
||||
from models.update_api_model import UpdateApiModel
|
||||
from models.api_model_response import ApiModelResponse
|
||||
from utils.response_util import respond_rest, process_response
|
||||
from utils.constants import ErrorCodes, Messages, Defaults, Roles, Headers
|
||||
from utils.role_util import platform_role_required_bool
|
||||
from models.create_api_model import CreateApiModel
|
||||
from models.response_model import ResponseModel
|
||||
from models.update_api_model import UpdateApiModel
|
||||
from services.api_service import ApiService
|
||||
from utils.audit_util import audit
|
||||
from utils.auth_util import auth_required
|
||||
from utils.constants import ErrorCodes, Headers, Messages, Roles
|
||||
from utils.response_util import process_response, respond_rest
|
||||
from utils.role_util import platform_role_required_bool
|
||||
|
||||
api_router = APIRouter()
|
||||
logger = logging.getLogger('doorman.gateway')
|
||||
@@ -33,58 +33,65 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@api_router.post('',
|
||||
|
||||
@api_router.post(
|
||||
'',
|
||||
description='Add API',
|
||||
response_model=ResponseModel,
|
||||
responses={
|
||||
200: {
|
||||
'description': 'Successful Response',
|
||||
'content': {
|
||||
'application/json': {
|
||||
'example': {
|
||||
'message': 'API created successfully'
|
||||
}
|
||||
}
|
||||
}
|
||||
'content': {'application/json': {'example': {'message': 'API created successfully'}}},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async def create_api(request: Request, api_data: CreateApiModel):
|
||||
async def create_api(request: Request, api_data: CreateApiModel) -> Response:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time() * 1000
|
||||
logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}')
|
||||
logger.info(
|
||||
f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}'
|
||||
)
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
try:
|
||||
if not await platform_role_required_bool(username, Roles.MANAGE_APIS):
|
||||
logger.warning(f'{request_id} | Permission denied for user: {username}')
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=403,
|
||||
response_headers={
|
||||
Headers.REQUEST_ID: request_id
|
||||
},
|
||||
error_code='API007',
|
||||
error_message='You do not have permission to create APIs'
|
||||
))
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=403,
|
||||
response_headers={Headers.REQUEST_ID: request_id},
|
||||
error_code='API007',
|
||||
error_message='You do not have permission to create APIs',
|
||||
)
|
||||
)
|
||||
result = await ApiService.create_api(api_data, request_id)
|
||||
audit(request, actor=username, action='api.create', target=f'{api_data.api_name}/{api_data.api_version}', status=result.get('status_code'), details={'message': result.get('message')}, request_id=request_id)
|
||||
audit(
|
||||
request,
|
||||
actor=username,
|
||||
action='api.create',
|
||||
target=f'{api_data.api_name}/{api_data.api_version}',
|
||||
status=result.get('status_code'),
|
||||
details={'message': result.get('message')},
|
||||
request_id=request_id,
|
||||
)
|
||||
return respond_rest(result)
|
||||
except Exception as e:
|
||||
logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True)
|
||||
return process_response(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={
|
||||
Headers.REQUEST_ID: request_id
|
||||
},
|
||||
error_code=ErrorCodes.UNEXPECTED,
|
||||
error_message=Messages.UNEXPECTED
|
||||
).dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={Headers.REQUEST_ID: request_id},
|
||||
error_code=ErrorCodes.UNEXPECTED,
|
||||
error_message=Messages.UNEXPECTED,
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
finally:
|
||||
end_time = time.time() * 1000
|
||||
logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms')
|
||||
|
||||
|
||||
"""
|
||||
Update API
|
||||
|
||||
@@ -94,57 +101,66 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@api_router.put('/{api_name}/{api_version}',
|
||||
|
||||
@api_router.put(
|
||||
'/{api_name}/{api_version}',
|
||||
description='Update API',
|
||||
response_model=ResponseModel,
|
||||
responses={
|
||||
200: {
|
||||
'description': 'Successful Response',
|
||||
'content': {
|
||||
'application/json': {
|
||||
'example': {
|
||||
'message': 'API updated successfully'
|
||||
}
|
||||
}
|
||||
}
|
||||
'content': {'application/json': {'example': {'message': 'API updated successfully'}}},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async def update_api(api_name: str, api_version: str, request: Request, api_data: UpdateApiModel):
|
||||
async def update_api(
|
||||
api_name: str, api_version: str, request: Request, api_data: UpdateApiModel
|
||||
) -> Response:
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time() * 1000
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}')
|
||||
logger.info(
|
||||
f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}'
|
||||
)
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
if not await platform_role_required_bool(username, Roles.MANAGE_APIS):
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=403,
|
||||
response_headers={
|
||||
Headers.REQUEST_ID: request_id
|
||||
},
|
||||
error_code='API008',
|
||||
error_message='You do not have permission to update APIs'
|
||||
))
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=403,
|
||||
response_headers={Headers.REQUEST_ID: request_id},
|
||||
error_code='API008',
|
||||
error_message='You do not have permission to update APIs',
|
||||
)
|
||||
)
|
||||
result = await ApiService.update_api(api_name, api_version, api_data, request_id)
|
||||
audit(request, actor=username, action='api.update', target=f'{api_name}/{api_version}', status=result.get('status_code'), details={'message': result.get('message')}, request_id=request_id)
|
||||
audit(
|
||||
request,
|
||||
actor=username,
|
||||
action='api.update',
|
||||
target=f'{api_name}/{api_version}',
|
||||
status=result.get('status_code'),
|
||||
details={'message': result.get('message')},
|
||||
request_id=request_id,
|
||||
)
|
||||
return respond_rest(result)
|
||||
except Exception as e:
|
||||
logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True)
|
||||
return process_response(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={
|
||||
Headers.REQUEST_ID: request_id
|
||||
},
|
||||
error_code=ErrorCodes.UNEXPECTED,
|
||||
error_message=Messages.UNEXPECTED
|
||||
).dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={Headers.REQUEST_ID: request_id},
|
||||
error_code=ErrorCodes.UNEXPECTED,
|
||||
error_message=Messages.UNEXPECTED,
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
finally:
|
||||
end_time = time.time() * 1000
|
||||
logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms')
|
||||
|
||||
|
||||
"""
|
||||
Get API
|
||||
|
||||
@@ -154,46 +170,47 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@api_router.get('/{api_name}/{api_version}',
|
||||
|
||||
@api_router.get(
|
||||
'/{api_name}/{api_version}',
|
||||
description='Get API',
|
||||
response_model=ApiModelResponse,
|
||||
responses={
|
||||
200: {
|
||||
'description': 'Successful Response',
|
||||
'content': {
|
||||
'application/json': {
|
||||
'example': {
|
||||
'message': 'API retrieved successfully'
|
||||
}
|
||||
}
|
||||
}
|
||||
'content': {'application/json': {'example': {'message': 'API retrieved successfully'}}},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async def get_api_by_name_version(api_name: str, api_version: str, request: Request):
|
||||
async def get_api_by_name_version(api_name: str, api_version: str, request: Request) -> Response:
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time() * 1000
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}')
|
||||
logger.info(
|
||||
f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}'
|
||||
)
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
return respond_rest(await ApiService.get_api_by_name_version(api_name, api_version, request_id))
|
||||
return respond_rest(
|
||||
await ApiService.get_api_by_name_version(api_name, api_version, request_id)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True)
|
||||
return process_response(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={
|
||||
Headers.REQUEST_ID: request_id
|
||||
},
|
||||
error_code=ErrorCodes.UNEXPECTED,
|
||||
error_message=Messages.UNEXPECTED
|
||||
).dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={Headers.REQUEST_ID: request_id},
|
||||
error_code=ErrorCodes.UNEXPECTED,
|
||||
error_message=Messages.UNEXPECTED,
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
finally:
|
||||
end_time = time.time() * 1000
|
||||
logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms')
|
||||
|
||||
|
||||
"""
|
||||
Delete API
|
||||
|
||||
@@ -203,48 +220,55 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@api_router.delete('/{api_name}/{api_version}',
|
||||
|
||||
@api_router.delete(
|
||||
'/{api_name}/{api_version}',
|
||||
description='Delete API',
|
||||
response_model=ResponseModel,
|
||||
responses={
|
||||
200: {
|
||||
'description': 'Successful Response',
|
||||
'content': {
|
||||
'application/json': {
|
||||
'example': {
|
||||
'message': 'API deleted successfully'
|
||||
}
|
||||
}
|
||||
}
|
||||
'content': {'application/json': {'example': {'message': 'API deleted successfully'}}},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async def delete_api(api_name: str, api_version: str, request: Request):
|
||||
async def delete_api(api_name: str, api_version: str, request: Request) -> Response:
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time() * 1000
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}')
|
||||
logger.info(
|
||||
f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}'
|
||||
)
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
result = await ApiService.delete_api(api_name, api_version, request_id)
|
||||
audit(request, actor=username, action='api.delete', target=f'{api_name}/{api_version}', status=result.get('status_code'), details={'message': result.get('message')}, request_id=request_id)
|
||||
audit(
|
||||
request,
|
||||
actor=username,
|
||||
action='api.delete',
|
||||
target=f'{api_name}/{api_version}',
|
||||
status=result.get('status_code'),
|
||||
details={'message': result.get('message')},
|
||||
request_id=request_id,
|
||||
)
|
||||
return respond_rest(result)
|
||||
except Exception as e:
|
||||
logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True)
|
||||
return process_response(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={
|
||||
'request_id': request_id
|
||||
},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred'
|
||||
).dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred',
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
finally:
|
||||
end_time = time.time() * 1000
|
||||
logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms')
|
||||
|
||||
|
||||
"""
|
||||
Endpoint
|
||||
|
||||
@@ -254,46 +278,47 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@api_router.get('/all',
|
||||
description='Get all APIs',
|
||||
response_model=List[ApiModelResponse]
|
||||
)
|
||||
|
||||
async def get_all_apis(request: Request, page: int = Defaults.PAGE, page_size: int = Defaults.PAGE_SIZE):
|
||||
@api_router.get('/all', description='Get all APIs', response_model=list[ApiModelResponse])
|
||||
async def get_all_apis(page: int, page_size: int, request: Request) -> Response:
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time() * 1000
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}')
|
||||
logger.info(
|
||||
f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}'
|
||||
)
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
return respond_rest(await ApiService.get_apis(page, page_size, request_id))
|
||||
except HTTPException as e:
|
||||
# Surface 401/403 properly for tests that probe unauthorized access
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=e.status_code,
|
||||
response_headers={Headers.REQUEST_ID: request_id},
|
||||
error_code='API_AUTH',
|
||||
error_message=e.detail
|
||||
))
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=e.status_code,
|
||||
response_headers={Headers.REQUEST_ID: request_id},
|
||||
error_code='API_AUTH',
|
||||
error_message=e.detail,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True)
|
||||
return process_response(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={
|
||||
Headers.REQUEST_ID: request_id
|
||||
},
|
||||
error_code=ErrorCodes.UNEXPECTED,
|
||||
error_message=Messages.UNEXPECTED
|
||||
).dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={Headers.REQUEST_ID: request_id},
|
||||
error_code=ErrorCodes.UNEXPECTED,
|
||||
error_message=Messages.UNEXPECTED,
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
finally:
|
||||
end_time = time.time() * 1000
|
||||
logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms')
|
||||
@api_router.get('',
|
||||
description='Get all APIs (base path)',
|
||||
response_model=List[ApiModelResponse]
|
||||
)
|
||||
async def get_all_apis_base(request: Request, page: int = Defaults.PAGE, page_size: int = Defaults.PAGE_SIZE):
|
||||
|
||||
|
||||
@api_router.get('', description='Get all APIs (base path)', response_model=list[ApiModelResponse])
|
||||
async def get_all_apis_base(page: int, page_size: int, request: Request) -> Response:
|
||||
"""Convenience alias for GET /platform/api/all to support tests and clients
|
||||
that expect listing at the base collection path.
|
||||
"""
|
||||
@@ -302,21 +327,28 @@ async def get_all_apis_base(request: Request, page: int = Defaults.PAGE, page_si
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}')
|
||||
logger.info(
|
||||
f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}'
|
||||
)
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
return respond_rest(await ApiService.get_apis(page, page_size, request_id))
|
||||
except HTTPException as e:
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=e.status_code,
|
||||
response_headers={Headers.REQUEST_ID: request_id},
|
||||
error_code='API_AUTH',
|
||||
error_message=e.detail
|
||||
))
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=e.status_code,
|
||||
response_headers={Headers.REQUEST_ID: request_id},
|
||||
error_code='API_AUTH',
|
||||
error_message=e.detail,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True)
|
||||
return process_response(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={Headers.REQUEST_ID: request_id},
|
||||
error_code=ErrorCodes.UNEXPECTED,
|
||||
error_message=Messages.UNEXPECTED
|
||||
).dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={Headers.REQUEST_ID: request_id},
|
||||
error_code=ErrorCodes.UNEXPECTED,
|
||||
error_message=Messages.UNEXPECTED,
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,37 +4,33 @@ Configuration Hot Reload Routes
|
||||
API endpoints for configuration management and hot reload.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import Dict, Any
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from models.response_model import ResponseModel
|
||||
from utils.auth_util import auth_required
|
||||
from utils.hot_reload_config import hot_config
|
||||
from models.response_model import ResponseModel
|
||||
|
||||
logger = logging.getLogger('doorman.gateway')
|
||||
|
||||
config_hot_reload_router = APIRouter(
|
||||
prefix='/config',
|
||||
tags=['Configuration Hot Reload']
|
||||
)
|
||||
config_hot_reload_router = APIRouter(prefix='/config', tags=['Configuration Hot Reload'])
|
||||
|
||||
|
||||
@config_hot_reload_router.get(
|
||||
'/current',
|
||||
summary='Get Current Configuration',
|
||||
description='Retrieve current hot-reloadable configuration values',
|
||||
response_model=Dict[str, Any],
|
||||
response_model=dict[str, Any],
|
||||
)
|
||||
async def get_current_config(
|
||||
payload: dict = Depends(auth_required)
|
||||
):
|
||||
async def get_current_config(payload: dict = Depends(auth_required)):
|
||||
"""Get current configuration (admin only)"""
|
||||
try:
|
||||
accesses = payload.get('accesses', {})
|
||||
if not accesses.get('manage_gateway'):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail='Insufficient permissions: manage_gateway required'
|
||||
status_code=403, detail='Insufficient permissions: manage_gateway required'
|
||||
)
|
||||
|
||||
config = hot_config.dump()
|
||||
@@ -44,101 +40,126 @@ async def get_current_config(
|
||||
data={
|
||||
'config': config,
|
||||
'source': 'Environment variables override config file values',
|
||||
'reload_command': 'kill -HUP $(cat doorman.pid)'
|
||||
'reload_command': 'kill -HUP $(cat doorman.pid)',
|
||||
},
|
||||
error_code=None,
|
||||
error_message=None
|
||||
error_message=None,
|
||||
).dict()
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to retrieve configuration: {e}', exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail='Failed to retrieve configuration'
|
||||
)
|
||||
raise HTTPException(status_code=500, detail='Failed to retrieve configuration')
|
||||
|
||||
|
||||
@config_hot_reload_router.post(
|
||||
'/reload',
|
||||
summary='Trigger Configuration Reload',
|
||||
description='Manually trigger configuration reload (same as SIGHUP)',
|
||||
response_model=Dict[str, Any],
|
||||
response_model=dict[str, Any],
|
||||
)
|
||||
async def trigger_config_reload(
|
||||
payload: dict = Depends(auth_required)
|
||||
):
|
||||
async def trigger_config_reload(payload: dict = Depends(auth_required)):
|
||||
"""Trigger configuration reload (admin only)"""
|
||||
try:
|
||||
accesses = payload.get('accesses', {})
|
||||
if not accesses.get('manage_gateway'):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail='Insufficient permissions: manage_gateway required'
|
||||
status_code=403, detail='Insufficient permissions: manage_gateway required'
|
||||
)
|
||||
|
||||
hot_config.reload()
|
||||
|
||||
return ResponseModel(
|
||||
status_code=200,
|
||||
data={
|
||||
'message': 'Configuration reloaded successfully',
|
||||
'config': hot_config.dump()
|
||||
},
|
||||
data={'message': 'Configuration reloaded successfully', 'config': hot_config.dump()},
|
||||
error_code=None,
|
||||
error_message=None
|
||||
error_message=None,
|
||||
).dict()
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to reload configuration: {e}', exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail='Failed to reload configuration'
|
||||
)
|
||||
raise HTTPException(status_code=500, detail='Failed to reload configuration')
|
||||
|
||||
|
||||
@config_hot_reload_router.get(
|
||||
'/reloadable-keys',
|
||||
summary='List Reloadable Configuration Keys',
|
||||
description='Get list of configuration keys that support hot reload',
|
||||
response_model=Dict[str, Any],
|
||||
response_model=dict[str, Any],
|
||||
)
|
||||
async def get_reloadable_keys(
|
||||
payload: dict = Depends(auth_required)
|
||||
):
|
||||
async def get_reloadable_keys(payload: dict = Depends(auth_required)):
|
||||
"""Get list of reloadable configuration keys"""
|
||||
try:
|
||||
reloadable_keys = [
|
||||
{'key': 'LOG_LEVEL', 'description': 'Log level (DEBUG, INFO, WARNING, ERROR)', 'example': 'INFO'},
|
||||
{
|
||||
'key': 'LOG_LEVEL',
|
||||
'description': 'Log level (DEBUG, INFO, WARNING, ERROR)',
|
||||
'example': 'INFO',
|
||||
},
|
||||
{'key': 'LOG_FORMAT', 'description': 'Log format (json, text)', 'example': 'json'},
|
||||
{'key': 'LOG_FILE', 'description': 'Log file path', 'example': 'logs/doorman.log'},
|
||||
|
||||
{'key': 'GATEWAY_TIMEOUT', 'description': 'Gateway timeout in seconds', 'example': '30'},
|
||||
{'key': 'UPSTREAM_TIMEOUT', 'description': 'Upstream timeout in seconds', 'example': '30'},
|
||||
{'key': 'CONNECTION_TIMEOUT', 'description': 'Connection timeout in seconds', 'example': '10'},
|
||||
|
||||
{
|
||||
'key': 'GATEWAY_TIMEOUT',
|
||||
'description': 'Gateway timeout in seconds',
|
||||
'example': '30',
|
||||
},
|
||||
{
|
||||
'key': 'UPSTREAM_TIMEOUT',
|
||||
'description': 'Upstream timeout in seconds',
|
||||
'example': '30',
|
||||
},
|
||||
{
|
||||
'key': 'CONNECTION_TIMEOUT',
|
||||
'description': 'Connection timeout in seconds',
|
||||
'example': '10',
|
||||
},
|
||||
{'key': 'RATE_LIMIT_ENABLED', 'description': 'Enable rate limiting', 'example': 'true'},
|
||||
{'key': 'RATE_LIMIT_REQUESTS', 'description': 'Requests per window', 'example': '100'},
|
||||
{'key': 'RATE_LIMIT_WINDOW', 'description': 'Window size in seconds', 'example': '60'},
|
||||
|
||||
{'key': 'CACHE_TTL', 'description': 'Cache TTL in seconds', 'example': '300'},
|
||||
{'key': 'CACHE_MAX_SIZE', 'description': 'Maximum cache entries', 'example': '1000'},
|
||||
|
||||
{'key': 'CIRCUIT_BREAKER_ENABLED', 'description': 'Enable circuit breaker', 'example': 'true'},
|
||||
{'key': 'CIRCUIT_BREAKER_THRESHOLD', 'description': 'Failures before opening', 'example': '5'},
|
||||
{'key': 'CIRCUIT_BREAKER_TIMEOUT', 'description': 'Timeout before retry (seconds)', 'example': '60'},
|
||||
|
||||
{
|
||||
'key': 'CIRCUIT_BREAKER_ENABLED',
|
||||
'description': 'Enable circuit breaker',
|
||||
'example': 'true',
|
||||
},
|
||||
{
|
||||
'key': 'CIRCUIT_BREAKER_THRESHOLD',
|
||||
'description': 'Failures before opening',
|
||||
'example': '5',
|
||||
},
|
||||
{
|
||||
'key': 'CIRCUIT_BREAKER_TIMEOUT',
|
||||
'description': 'Timeout before retry (seconds)',
|
||||
'example': '60',
|
||||
},
|
||||
{'key': 'RETRY_ENABLED', 'description': 'Enable retry logic', 'example': 'true'},
|
||||
{'key': 'RETRY_MAX_ATTEMPTS', 'description': 'Maximum retry attempts', 'example': '3'},
|
||||
{'key': 'RETRY_BACKOFF', 'description': 'Backoff multiplier', 'example': '1.0'},
|
||||
|
||||
{'key': 'METRICS_ENABLED', 'description': 'Enable metrics collection', 'example': 'true'},
|
||||
{'key': 'METRICS_INTERVAL', 'description': 'Metrics interval (seconds)', 'example': '60'},
|
||||
|
||||
{'key': 'FEATURE_REQUEST_REPLAY', 'description': 'Enable request replay', 'example': 'false'},
|
||||
{
|
||||
'key': 'METRICS_ENABLED',
|
||||
'description': 'Enable metrics collection',
|
||||
'example': 'true',
|
||||
},
|
||||
{
|
||||
'key': 'METRICS_INTERVAL',
|
||||
'description': 'Metrics interval (seconds)',
|
||||
'example': '60',
|
||||
},
|
||||
{
|
||||
'key': 'FEATURE_REQUEST_REPLAY',
|
||||
'description': 'Enable request replay',
|
||||
'example': 'false',
|
||||
},
|
||||
{'key': 'FEATURE_AB_TESTING', 'description': 'Enable A/B testing', 'example': 'false'},
|
||||
{'key': 'FEATURE_COST_ANALYTICS', 'description': 'Enable cost analytics', 'example': 'false'},
|
||||
{
|
||||
'key': 'FEATURE_COST_ANALYTICS',
|
||||
'description': 'Enable cost analytics',
|
||||
'example': 'false',
|
||||
},
|
||||
]
|
||||
|
||||
return ResponseModel(
|
||||
@@ -150,16 +171,13 @@ async def get_reloadable_keys(
|
||||
'Environment variables always override config file values',
|
||||
'Changes take effect immediately after reload',
|
||||
'Reload via: kill -HUP $(cat doorman.pid)',
|
||||
'Or use: POST /config/reload'
|
||||
]
|
||||
'Or use: POST /config/reload',
|
||||
],
|
||||
},
|
||||
error_code=None,
|
||||
error_message=None
|
||||
error_message=None,
|
||||
).dict()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to retrieve reloadable keys: {e}', exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail='Failed to retrieve reloadable keys'
|
||||
)
|
||||
raise HTTPException(status_code=500, detail='Failed to retrieve reloadable keys')
|
||||
|
||||
@@ -2,37 +2,39 @@
|
||||
Routes to export and import platform configuration (APIs, Endpoints, Roles, Groups, Routings).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from typing import Any, Dict, List, Optional
|
||||
import uuid
|
||||
import time
|
||||
import logging
|
||||
import copy
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from models.response_model import ResponseModel
|
||||
from utils.response_util import process_response
|
||||
from utils.auth_util import auth_required
|
||||
from utils.role_util import platform_role_required_bool
|
||||
from utils.doorman_cache_util import doorman_cache
|
||||
from utils.audit_util import audit
|
||||
from utils.auth_util import auth_required
|
||||
from utils.database import (
|
||||
|
||||
api_collection,
|
||||
endpoint_collection,
|
||||
group_collection,
|
||||
role_collection,
|
||||
routing_collection,
|
||||
)
|
||||
from utils.doorman_cache_util import doorman_cache
|
||||
from utils.response_util import process_response
|
||||
from utils.role_util import platform_role_required_bool
|
||||
|
||||
config_router = APIRouter()
|
||||
logger = logging.getLogger('doorman.gateway')
|
||||
|
||||
def _strip_id(doc: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
def _strip_id(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
d = dict(doc)
|
||||
d.pop('_id', None)
|
||||
return d
|
||||
|
||||
def _export_all() -> Dict[str, Any]:
|
||||
|
||||
def _export_all() -> dict[str, Any]:
|
||||
apis = [_strip_id(a) for a in api_collection.find().to_list(length=None)]
|
||||
endpoints = [_strip_id(e) for e in endpoint_collection.find().to_list(length=None)]
|
||||
roles = [_strip_id(r) for r in role_collection.find().to_list(length=None)]
|
||||
@@ -46,6 +48,7 @@ def _export_all() -> Dict[str, Any]:
|
||||
'routings': routings,
|
||||
}
|
||||
|
||||
|
||||
"""
|
||||
Endpoint
|
||||
|
||||
@@ -55,11 +58,12 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@config_router.get('/config/export/all',
|
||||
|
||||
@config_router.get(
|
||||
'/config/export/all',
|
||||
description='Export all platform configuration (APIs, Endpoints, Roles, Groups, Routings)',
|
||||
response_model=ResponseModel,
|
||||
)
|
||||
|
||||
async def export_all(request: Request):
|
||||
request_id = str(uuid.uuid4())
|
||||
start = time.time() * 1000
|
||||
@@ -67,15 +71,39 @@ async def export_all(request: Request):
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
if not await platform_role_required_bool(username, 'manage_gateway'):
|
||||
return process_response(ResponseModel(status_code=403, error_code='CFG001', error_message='Insufficient permissions').dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=403, error_code='CFG001', error_message='Insufficient permissions'
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
data = _export_all()
|
||||
audit(request, actor=username, action='config.export_all', target='all', status='success', details={'counts': {k: len(v) for k,v in data.items()}}, request_id=request_id)
|
||||
return process_response(ResponseModel(status_code=200, response_headers={'request_id': request_id}, response=data).dict(), 'rest')
|
||||
audit(
|
||||
request,
|
||||
actor=username,
|
||||
action='config.export_all',
|
||||
target='all',
|
||||
status='success',
|
||||
details={'counts': {k: len(v) for k, v in data.items()}},
|
||||
request_id=request_id,
|
||||
)
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=200, response_headers={'request_id': request_id}, response=data
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'{request_id} | export_all error: {e}')
|
||||
return process_response(ResponseModel(status_code=500, error_code='GTW999', error_message='An unexpected error occurred').dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500, error_code='GTW999', error_message='An unexpected error occurred'
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
finally:
|
||||
logger.info(f'{request_id} | export_all took {time.time()*1000 - start:.2f}ms')
|
||||
logger.info(f'{request_id} | export_all took {time.time() * 1000 - start:.2f}ms')
|
||||
|
||||
|
||||
"""
|
||||
Endpoint
|
||||
@@ -86,37 +114,80 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@config_router.get('/config/export/apis',
|
||||
description='Export APIs (optionally a single API with its endpoints)',
|
||||
response_model=ResponseModel)
|
||||
|
||||
async def export_apis(request: Request, api_name: Optional[str] = None, api_version: Optional[str] = None):
|
||||
@config_router.get(
|
||||
'/config/export/apis',
|
||||
description='Export APIs (optionally a single API with its endpoints)',
|
||||
response_model=ResponseModel,
|
||||
)
|
||||
async def export_apis(
|
||||
request: Request, api_name: str | None = None, api_version: str | None = None
|
||||
):
|
||||
request_id = str(uuid.uuid4())
|
||||
start = time.time() * 1000
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
if not await platform_role_required_bool(username, 'manage_apis'):
|
||||
return process_response(ResponseModel(status_code=403, error_code='CFG002', error_message='Insufficient permissions').dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=403, error_code='CFG002', error_message='Insufficient permissions'
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
if api_name and api_version:
|
||||
api = api_collection.find_one({'api_name': api_name, 'api_version': api_version})
|
||||
if not api:
|
||||
return process_response(ResponseModel(status_code=404, error_code='CFG404', error_message='API not found').dict(), 'rest')
|
||||
aid = api.get('api_id')
|
||||
eps = endpoint_collection.find({'api_name': api_name, 'api_version': api_version}).to_list(length=None)
|
||||
audit(request, actor=username, action='config.export_api', target=f'{api_name}/{api_version}', status='success', details={'endpoints': len(eps)}, request_id=request_id)
|
||||
return process_response(ResponseModel(status_code=200, response={
|
||||
'api': _strip_id(api),
|
||||
'endpoints': [_strip_id(e) for e in eps]
|
||||
}).dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=404, error_code='CFG404', error_message='API not found'
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
api.get('api_id')
|
||||
eps = endpoint_collection.find(
|
||||
{'api_name': api_name, 'api_version': api_version}
|
||||
).to_list(length=None)
|
||||
audit(
|
||||
request,
|
||||
actor=username,
|
||||
action='config.export_api',
|
||||
target=f'{api_name}/{api_version}',
|
||||
status='success',
|
||||
details={'endpoints': len(eps)},
|
||||
request_id=request_id,
|
||||
)
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=200,
|
||||
response={'api': _strip_id(api), 'endpoints': [_strip_id(e) for e in eps]},
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
apis = [_strip_id(a) for a in api_collection.find().to_list(length=None)]
|
||||
audit(request, actor=username, action='config.export_apis', target='list', status='success', details={'count': len(apis)}, request_id=request_id)
|
||||
return process_response(ResponseModel(status_code=200, response={'apis': apis}).dict(), 'rest')
|
||||
audit(
|
||||
request,
|
||||
actor=username,
|
||||
action='config.export_apis',
|
||||
target='list',
|
||||
status='success',
|
||||
details={'count': len(apis)},
|
||||
request_id=request_id,
|
||||
)
|
||||
return process_response(
|
||||
ResponseModel(status_code=200, response={'apis': apis}).dict(), 'rest'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'{request_id} | export_apis error: {e}')
|
||||
return process_response(ResponseModel(status_code=500, error_code='GTW999', error_message='An unexpected error occurred').dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500, error_code='GTW999', error_message='An unexpected error occurred'
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
finally:
|
||||
logger.info(f'{request_id} | export_apis took {time.time()*1000 - start:.2f}ms')
|
||||
logger.info(f'{request_id} | export_apis took {time.time() * 1000 - start:.2f}ms')
|
||||
|
||||
|
||||
"""
|
||||
Endpoint
|
||||
@@ -127,27 +198,63 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@config_router.get('/config/export/roles', description='Export Roles', response_model=ResponseModel)
|
||||
|
||||
async def export_roles(request: Request, role_name: Optional[str] = None):
|
||||
@config_router.get('/config/export/roles', description='Export Roles', response_model=ResponseModel)
|
||||
async def export_roles(request: Request, role_name: str | None = None):
|
||||
request_id = str(uuid.uuid4())
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
if not await platform_role_required_bool(username, 'manage_roles'):
|
||||
return process_response(ResponseModel(status_code=403, error_code='CFG003', error_message='Insufficient permissions').dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=403, error_code='CFG003', error_message='Insufficient permissions'
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
if role_name:
|
||||
role = role_collection.find_one({'role_name': role_name})
|
||||
if not role:
|
||||
return process_response(ResponseModel(status_code=404, error_code='CFG404', error_message='Role not found').dict(), 'rest')
|
||||
audit(request, actor=username, action='config.export_role', target=role_name, status='success', details=None, request_id=request_id)
|
||||
return process_response(ResponseModel(status_code=200, response={'role': _strip_id(role)}).dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=404, error_code='CFG404', error_message='Role not found'
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
audit(
|
||||
request,
|
||||
actor=username,
|
||||
action='config.export_role',
|
||||
target=role_name,
|
||||
status='success',
|
||||
details=None,
|
||||
request_id=request_id,
|
||||
)
|
||||
return process_response(
|
||||
ResponseModel(status_code=200, response={'role': _strip_id(role)}).dict(), 'rest'
|
||||
)
|
||||
roles = [_strip_id(r) for r in role_collection.find().to_list(length=None)]
|
||||
audit(request, actor=username, action='config.export_roles', target='list', status='success', details={'count': len(roles)}, request_id=request_id)
|
||||
return process_response(ResponseModel(status_code=200, response={'roles': roles}).dict(), 'rest')
|
||||
audit(
|
||||
request,
|
||||
actor=username,
|
||||
action='config.export_roles',
|
||||
target='list',
|
||||
status='success',
|
||||
details={'count': len(roles)},
|
||||
request_id=request_id,
|
||||
)
|
||||
return process_response(
|
||||
ResponseModel(status_code=200, response={'roles': roles}).dict(), 'rest'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'{request_id} | export_roles error: {e}')
|
||||
return process_response(ResponseModel(status_code=500, error_code='GTW999', error_message='An unexpected error occurred').dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500, error_code='GTW999', error_message='An unexpected error occurred'
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
|
||||
|
||||
"""
|
||||
Endpoint
|
||||
@@ -158,27 +265,65 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@config_router.get('/config/export/groups', description='Export Groups', response_model=ResponseModel)
|
||||
|
||||
async def export_groups(request: Request, group_name: Optional[str] = None):
|
||||
@config_router.get(
|
||||
'/config/export/groups', description='Export Groups', response_model=ResponseModel
|
||||
)
|
||||
async def export_groups(request: Request, group_name: str | None = None):
|
||||
request_id = str(uuid.uuid4())
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
if not await platform_role_required_bool(username, 'manage_groups'):
|
||||
return process_response(ResponseModel(status_code=403, error_code='CFG004', error_message='Insufficient permissions').dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=403, error_code='CFG004', error_message='Insufficient permissions'
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
if group_name:
|
||||
group = group_collection.find_one({'group_name': group_name})
|
||||
if not group:
|
||||
return process_response(ResponseModel(status_code=404, error_code='CFG404', error_message='Group not found').dict(), 'rest')
|
||||
audit(request, actor=username, action='config.export_group', target=group_name, status='success', details=None, request_id=request_id)
|
||||
return process_response(ResponseModel(status_code=200, response={'group': _strip_id(group)}).dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=404, error_code='CFG404', error_message='Group not found'
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
audit(
|
||||
request,
|
||||
actor=username,
|
||||
action='config.export_group',
|
||||
target=group_name,
|
||||
status='success',
|
||||
details=None,
|
||||
request_id=request_id,
|
||||
)
|
||||
return process_response(
|
||||
ResponseModel(status_code=200, response={'group': _strip_id(group)}).dict(), 'rest'
|
||||
)
|
||||
groups = [_strip_id(g) for g in group_collection.find().to_list(length=None)]
|
||||
audit(request, actor=username, action='config.export_groups', target='list', status='success', details={'count': len(groups)}, request_id=request_id)
|
||||
return process_response(ResponseModel(status_code=200, response={'groups': groups}).dict(), 'rest')
|
||||
audit(
|
||||
request,
|
||||
actor=username,
|
||||
action='config.export_groups',
|
||||
target='list',
|
||||
status='success',
|
||||
details={'count': len(groups)},
|
||||
request_id=request_id,
|
||||
)
|
||||
return process_response(
|
||||
ResponseModel(status_code=200, response={'groups': groups}).dict(), 'rest'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'{request_id} | export_groups error: {e}')
|
||||
return process_response(ResponseModel(status_code=500, error_code='GTW999', error_message='An unexpected error occurred').dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500, error_code='GTW999', error_message='An unexpected error occurred'
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
|
||||
|
||||
"""
|
||||
Endpoint
|
||||
@@ -189,27 +334,66 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@config_router.get('/config/export/routings', description='Export Routings', response_model=ResponseModel)
|
||||
|
||||
async def export_routings(request: Request, client_key: Optional[str] = None):
|
||||
@config_router.get(
|
||||
'/config/export/routings', description='Export Routings', response_model=ResponseModel
|
||||
)
|
||||
async def export_routings(request: Request, client_key: str | None = None):
|
||||
request_id = str(uuid.uuid4())
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
if not await platform_role_required_bool(username, 'manage_routings'):
|
||||
return process_response(ResponseModel(status_code=403, error_code='CFG005', error_message='Insufficient permissions').dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=403, error_code='CFG005', error_message='Insufficient permissions'
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
if client_key:
|
||||
routing = routing_collection.find_one({'client_key': client_key})
|
||||
if not routing:
|
||||
return process_response(ResponseModel(status_code=404, error_code='CFG404', error_message='Routing not found').dict(), 'rest')
|
||||
audit(request, actor=username, action='config.export_routing', target=client_key, status='success', details=None, request_id=request_id)
|
||||
return process_response(ResponseModel(status_code=200, response={'routing': _strip_id(routing)}).dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=404, error_code='CFG404', error_message='Routing not found'
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
audit(
|
||||
request,
|
||||
actor=username,
|
||||
action='config.export_routing',
|
||||
target=client_key,
|
||||
status='success',
|
||||
details=None,
|
||||
request_id=request_id,
|
||||
)
|
||||
return process_response(
|
||||
ResponseModel(status_code=200, response={'routing': _strip_id(routing)}).dict(),
|
||||
'rest',
|
||||
)
|
||||
routings = [_strip_id(r) for r in routing_collection.find().to_list(length=None)]
|
||||
audit(request, actor=username, action='config.export_routings', target='list', status='success', details={'count': len(routings)}, request_id=request_id)
|
||||
return process_response(ResponseModel(status_code=200, response={'routings': routings}).dict(), 'rest')
|
||||
audit(
|
||||
request,
|
||||
actor=username,
|
||||
action='config.export_routings',
|
||||
target='list',
|
||||
status='success',
|
||||
details={'count': len(routings)},
|
||||
request_id=request_id,
|
||||
)
|
||||
return process_response(
|
||||
ResponseModel(status_code=200, response={'routings': routings}).dict(), 'rest'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'{request_id} | export_routings error: {e}')
|
||||
return process_response(ResponseModel(status_code=500, error_code='GTW999', error_message='An unexpected error occurred').dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500, error_code='GTW999', error_message='An unexpected error occurred'
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
|
||||
|
||||
"""
|
||||
Endpoint
|
||||
@@ -220,30 +404,47 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@config_router.get('/config/export/endpoints',
|
||||
description='Export endpoints (optionally filter by api_name/api_version)',
|
||||
response_model=ResponseModel)
|
||||
|
||||
async def export_endpoints(request: Request, api_name: Optional[str] = None, api_version: Optional[str] = None):
|
||||
@config_router.get(
|
||||
'/config/export/endpoints',
|
||||
description='Export endpoints (optionally filter by api_name/api_version)',
|
||||
response_model=ResponseModel,
|
||||
)
|
||||
async def export_endpoints(
|
||||
request: Request, api_name: str | None = None, api_version: str | None = None
|
||||
):
|
||||
request_id = str(uuid.uuid4())
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
|
||||
if not await platform_role_required_bool(username, 'manage_endpoints'):
|
||||
return process_response(ResponseModel(status_code=403, error_code='CFG007', error_message='Insufficient permissions').dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=403, error_code='CFG007', error_message='Insufficient permissions'
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
query = {}
|
||||
if api_name:
|
||||
query['api_name'] = api_name
|
||||
if api_version:
|
||||
query['api_version'] = api_version
|
||||
eps = [_strip_id(e) for e in endpoint_collection.find(query).to_list(length=None)]
|
||||
return process_response(ResponseModel(status_code=200, response={'endpoints': eps}).dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(status_code=200, response={'endpoints': eps}).dict(), 'rest'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'{request_id} | export_endpoints error: {e}')
|
||||
return process_response(ResponseModel(status_code=500, error_code='GTW999', error_message='An unexpected error occurred').dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500, error_code='GTW999', error_message='An unexpected error occurred'
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
|
||||
def _upsert_api(doc: Dict[str, Any]) -> None:
|
||||
|
||||
def _upsert_api(doc: dict[str, Any]) -> None:
|
||||
api_name = doc.get('api_name')
|
||||
api_version = doc.get('api_version')
|
||||
if not api_name or not api_version:
|
||||
@@ -258,11 +459,14 @@ def _upsert_api(doc: Dict[str, Any]) -> None:
|
||||
to_set.setdefault('api_id', str(uuid.uuid4()))
|
||||
to_set.setdefault('api_path', f'/{api_name}/{api_version}')
|
||||
if existing:
|
||||
api_collection.update_one({'api_name': api_name, 'api_version': api_version}, {'$set': to_set})
|
||||
api_collection.update_one(
|
||||
{'api_name': api_name, 'api_version': api_version}, {'$set': to_set}
|
||||
)
|
||||
else:
|
||||
api_collection.insert_one(to_set)
|
||||
|
||||
def _upsert_endpoint(doc: Dict[str, Any]) -> None:
|
||||
|
||||
def _upsert_endpoint(doc: dict[str, Any]) -> None:
|
||||
api_name = doc.get('api_name')
|
||||
api_version = doc.get('api_version')
|
||||
method = doc.get('endpoint_method')
|
||||
@@ -275,23 +479,29 @@ def _upsert_endpoint(doc: Dict[str, Any]) -> None:
|
||||
if api_doc:
|
||||
to_set['api_id'] = api_doc.get('api_id')
|
||||
to_set.setdefault('endpoint_id', str(uuid.uuid4()))
|
||||
existing = endpoint_collection.find_one({
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': method,
|
||||
'endpoint_uri': uri,
|
||||
})
|
||||
if existing:
|
||||
endpoint_collection.update_one({
|
||||
existing = endpoint_collection.find_one(
|
||||
{
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': method,
|
||||
'endpoint_uri': uri,
|
||||
}, {'$set': to_set})
|
||||
}
|
||||
)
|
||||
if existing:
|
||||
endpoint_collection.update_one(
|
||||
{
|
||||
'api_name': api_name,
|
||||
'api_version': api_version,
|
||||
'endpoint_method': method,
|
||||
'endpoint_uri': uri,
|
||||
},
|
||||
{'$set': to_set},
|
||||
)
|
||||
else:
|
||||
endpoint_collection.insert_one(to_set)
|
||||
|
||||
def _upsert_role(doc: Dict[str, Any]) -> None:
|
||||
|
||||
def _upsert_role(doc: dict[str, Any]) -> None:
|
||||
name = doc.get('role_name')
|
||||
if not name:
|
||||
return
|
||||
@@ -302,7 +512,8 @@ def _upsert_role(doc: Dict[str, Any]) -> None:
|
||||
else:
|
||||
role_collection.insert_one(to_set)
|
||||
|
||||
def _upsert_group(doc: Dict[str, Any]) -> None:
|
||||
|
||||
def _upsert_group(doc: dict[str, Any]) -> None:
|
||||
name = doc.get('group_name')
|
||||
if not name:
|
||||
return
|
||||
@@ -313,7 +524,8 @@ def _upsert_group(doc: Dict[str, Any]) -> None:
|
||||
else:
|
||||
group_collection.insert_one(to_set)
|
||||
|
||||
def _upsert_routing(doc: Dict[str, Any]) -> None:
|
||||
|
||||
def _upsert_routing(doc: dict[str, Any]) -> None:
|
||||
key = doc.get('client_key')
|
||||
if not key:
|
||||
return
|
||||
@@ -324,6 +536,7 @@ def _upsert_routing(doc: Dict[str, Any]) -> None:
|
||||
else:
|
||||
routing_collection.insert_one(to_set)
|
||||
|
||||
|
||||
"""
|
||||
Endpoint
|
||||
|
||||
@@ -333,11 +546,13 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@config_router.post('/config/import',
|
||||
description='Import platform configuration (any subset of apis, endpoints, roles, groups, routings)',
|
||||
response_model=ResponseModel)
|
||||
|
||||
async def import_all(request: Request, body: Dict[str, Any]):
|
||||
@config_router.post(
|
||||
'/config/import',
|
||||
description='Import platform configuration (any subset of apis, endpoints, roles, groups, routings)',
|
||||
response_model=ResponseModel,
|
||||
)
|
||||
async def import_all(request: Request, body: dict[str, Any]):
|
||||
request_id = str(uuid.uuid4())
|
||||
start = time.time() * 1000
|
||||
try:
|
||||
@@ -345,27 +560,52 @@ async def import_all(request: Request, body: Dict[str, Any]):
|
||||
username = payload.get('sub')
|
||||
|
||||
if not await platform_role_required_bool(username, 'manage_gateway'):
|
||||
return process_response(ResponseModel(status_code=403, error_code='CFG006', error_message='Insufficient permissions').dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=403, error_code='CFG006', error_message='Insufficient permissions'
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
counts = {'apis': 0, 'endpoints': 0, 'roles': 0, 'groups': 0, 'routings': 0}
|
||||
for api in body.get('apis', []) or []:
|
||||
_upsert_api(api); counts['apis'] += 1
|
||||
_upsert_api(api)
|
||||
counts['apis'] += 1
|
||||
for ep in body.get('endpoints', []) or []:
|
||||
_upsert_endpoint(ep); counts['endpoints'] += 1
|
||||
_upsert_endpoint(ep)
|
||||
counts['endpoints'] += 1
|
||||
for r in body.get('roles', []) or []:
|
||||
_upsert_role(r); counts['roles'] += 1
|
||||
_upsert_role(r)
|
||||
counts['roles'] += 1
|
||||
for g in body.get('groups', []) or []:
|
||||
_upsert_group(g); counts['groups'] += 1
|
||||
_upsert_group(g)
|
||||
counts['groups'] += 1
|
||||
for rt in body.get('routings', []) or []:
|
||||
_upsert_routing(rt); counts['routings'] += 1
|
||||
_upsert_routing(rt)
|
||||
counts['routings'] += 1
|
||||
|
||||
try:
|
||||
doorman_cache.clear_all_caches()
|
||||
except Exception:
|
||||
pass
|
||||
audit(request, actor=username, action='config.import', target='bulk', status='success', details={'imported': counts}, request_id=request_id)
|
||||
return process_response(ResponseModel(status_code=200, response={'imported': counts}).dict(), 'rest')
|
||||
audit(
|
||||
request,
|
||||
actor=username,
|
||||
action='config.import',
|
||||
target='bulk',
|
||||
status='success',
|
||||
details={'imported': counts},
|
||||
request_id=request_id,
|
||||
)
|
||||
return process_response(
|
||||
ResponseModel(status_code=200, response={'imported': counts}).dict(), 'rest'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'{request_id} | import_all error: {e}')
|
||||
return process_response(ResponseModel(status_code=500, error_code='GTW999', error_message='An unexpected error occurred').dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500, error_code='GTW999', error_message='An unexpected error occurred'
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
finally:
|
||||
logger.info(f'{request_id} | import_all took {time.time()*1000 - start:.2f}ms')
|
||||
logger.info(f'{request_id} | import_all took {time.time() * 1000 - start:.2f}ms')
|
||||
|
||||
@@ -4,20 +4,20 @@ Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
import uuid
|
||||
import time
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from models.credit_model import CreditModel
|
||||
from models.response_model import ResponseModel
|
||||
from models.user_credits_model import UserCreditModel
|
||||
from models.credit_model import CreditModel
|
||||
from services.credit_service import CreditService
|
||||
from utils.auth_util import auth_required
|
||||
from utils.response_util import respond_rest, process_response
|
||||
from utils.role_util import platform_role_required_bool
|
||||
from utils.audit_util import audit
|
||||
from utils.auth_util import auth_required
|
||||
from utils.response_util import process_response, respond_rest
|
||||
from utils.role_util import platform_role_required_bool
|
||||
|
||||
credit_router = APIRouter()
|
||||
|
||||
@@ -32,41 +32,46 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@credit_router.get('/defs',
|
||||
|
||||
@credit_router.get(
|
||||
'/defs',
|
||||
description='List credit definitions',
|
||||
response_model=ResponseModel,
|
||||
responses={
|
||||
200: {'description': 'Successful Response'}
|
||||
}
|
||||
responses={200: {'description': 'Successful Response'}},
|
||||
)
|
||||
|
||||
async def list_credit_definitions(request: Request, page: int = 1, page_size: int = 50):
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time() * 1000
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}')
|
||||
logger.info(
|
||||
f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}'
|
||||
)
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
if not await platform_role_required_bool(username, 'manage_credits'):
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=403,
|
||||
error_code='CRD002',
|
||||
error_message='Unable to retrieve credits'
|
||||
))
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=403, error_code='CRD002', error_message='Unable to retrieve credits'
|
||||
)
|
||||
)
|
||||
return respond_rest(await CreditService.list_credit_defs(page, page_size, request_id))
|
||||
except Exception as e:
|
||||
logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True)
|
||||
return process_response(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred'
|
||||
).dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred',
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
finally:
|
||||
end_time = time.time() * 1000
|
||||
logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms')
|
||||
|
||||
|
||||
"""
|
||||
Endpoint
|
||||
|
||||
@@ -76,38 +81,43 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@credit_router.get('/defs/{api_credit_group}',
|
||||
description='Get a credit definition',
|
||||
response_model=ResponseModel,
|
||||
)
|
||||
|
||||
@credit_router.get(
|
||||
'/defs/{api_credit_group}', description='Get a credit definition', response_model=ResponseModel
|
||||
)
|
||||
async def get_credit_definition(api_credit_group: str, request: Request):
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time() * 1000
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}')
|
||||
logger.info(
|
||||
f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}'
|
||||
)
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
if not await platform_role_required_bool(username, 'manage_credits'):
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=403,
|
||||
error_code='CRD002',
|
||||
error_message='Unable to retrieve credits'
|
||||
))
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=403, error_code='CRD002', error_message='Unable to retrieve credits'
|
||||
)
|
||||
)
|
||||
return respond_rest(await CreditService.get_credit_def(api_credit_group, request_id))
|
||||
except Exception as e:
|
||||
logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True)
|
||||
return process_response(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred'
|
||||
).dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred',
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
finally:
|
||||
end_time = time.time() * 1000
|
||||
logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms')
|
||||
|
||||
|
||||
"""
|
||||
Create a credit definition
|
||||
|
||||
@@ -117,7 +127,9 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@credit_router.post('',
|
||||
|
||||
@credit_router.post(
|
||||
'',
|
||||
description='Create a credit definition',
|
||||
response_model=ResponseModel,
|
||||
responses={
|
||||
@@ -125,22 +137,21 @@ Response:
|
||||
'description': 'Successful Response',
|
||||
'content': {
|
||||
'application/json': {
|
||||
'example': {
|
||||
'message': 'Credit definition created successfully'
|
||||
}
|
||||
'example': {'message': 'Credit definition created successfully'}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async def create_credit(credit_data: CreditModel, request: Request):
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time() * 1000
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}')
|
||||
logger.info(
|
||||
f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}'
|
||||
)
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
if not await platform_role_required_bool(username, 'manage_credits'):
|
||||
return respond_rest(
|
||||
@@ -148,24 +159,35 @@ async def create_credit(credit_data: CreditModel, request: Request):
|
||||
status_code=403,
|
||||
error_code='CRD001',
|
||||
error_message='You do not have permission to manage credits',
|
||||
))
|
||||
)
|
||||
)
|
||||
result = await CreditService.create_credit(credit_data, request_id)
|
||||
audit(request, actor=username, action='credit_def.create', target=credit_data.api_credit_group, status=result.get('status_code'), details=None, request_id=request_id)
|
||||
audit(
|
||||
request,
|
||||
actor=username,
|
||||
action='credit_def.create',
|
||||
target=credit_data.api_credit_group,
|
||||
status=result.get('status_code'),
|
||||
details=None,
|
||||
request_id=request_id,
|
||||
)
|
||||
return respond_rest(result)
|
||||
except Exception as e:
|
||||
logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True)
|
||||
return process_response(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={
|
||||
'request_id': request_id
|
||||
},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred'
|
||||
).dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred',
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
finally:
|
||||
end_time = time.time() * 1000
|
||||
logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms')
|
||||
|
||||
|
||||
"""
|
||||
Update a credit definition
|
||||
|
||||
@@ -175,7 +197,9 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@credit_router.put('/{api_credit_group}',
|
||||
|
||||
@credit_router.put(
|
||||
'/{api_credit_group}',
|
||||
description='Update a credit definition',
|
||||
response_model=ResponseModel,
|
||||
responses={
|
||||
@@ -183,22 +207,21 @@ Response:
|
||||
'description': 'Successful Response',
|
||||
'content': {
|
||||
'application/json': {
|
||||
'example': {
|
||||
'message': 'Credit definition updated successfully'
|
||||
}
|
||||
'example': {'message': 'Credit definition updated successfully'}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async def update_credit(api_credit_group:str, credit_data: CreditModel, request: Request):
|
||||
async def update_credit(api_credit_group: str, credit_data: CreditModel, request: Request):
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time() * 1000
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}')
|
||||
logger.info(
|
||||
f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}'
|
||||
)
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
if not await platform_role_required_bool(username, 'manage_credits'):
|
||||
return respond_rest(
|
||||
@@ -206,24 +229,35 @@ async def update_credit(api_credit_group:str, credit_data: CreditModel, request:
|
||||
status_code=403,
|
||||
error_code='CRD001',
|
||||
error_message='You do not have permission to manage credits',
|
||||
))
|
||||
)
|
||||
)
|
||||
result = await CreditService.update_credit(api_credit_group, credit_data, request_id)
|
||||
audit(request, actor=username, action='credit_def.update', target=api_credit_group, status=result.get('status_code'), details=None, request_id=request_id)
|
||||
audit(
|
||||
request,
|
||||
actor=username,
|
||||
action='credit_def.update',
|
||||
target=api_credit_group,
|
||||
status=result.get('status_code'),
|
||||
details=None,
|
||||
request_id=request_id,
|
||||
)
|
||||
return respond_rest(result)
|
||||
except Exception as e:
|
||||
logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True)
|
||||
return process_response(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={
|
||||
'request_id': request_id
|
||||
},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred'
|
||||
).dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred',
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
finally:
|
||||
end_time = time.time() * 1000
|
||||
logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms')
|
||||
|
||||
|
||||
"""
|
||||
Delete a credit definition
|
||||
|
||||
@@ -233,7 +267,9 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@credit_router.delete('/{api_credit_group}',
|
||||
|
||||
@credit_router.delete(
|
||||
'/{api_credit_group}',
|
||||
description='Delete a credit definition',
|
||||
response_model=ResponseModel,
|
||||
responses={
|
||||
@@ -241,22 +277,21 @@ Response:
|
||||
'description': 'Successful Response',
|
||||
'content': {
|
||||
'application/json': {
|
||||
'example': {
|
||||
'message': 'Credit definition deleted successfully'
|
||||
}
|
||||
'example': {'message': 'Credit definition deleted successfully'}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async def delete_credit(api_credit_group:str, request: Request):
|
||||
async def delete_credit(api_credit_group: str, request: Request):
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time() * 1000
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}')
|
||||
logger.info(
|
||||
f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}'
|
||||
)
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
if not await platform_role_required_bool(username, 'manage_credits'):
|
||||
return respond_rest(
|
||||
@@ -264,24 +299,35 @@ async def delete_credit(api_credit_group:str, request: Request):
|
||||
status_code=403,
|
||||
error_code='CRD001',
|
||||
error_message='You do not have permission to manage credits',
|
||||
))
|
||||
)
|
||||
)
|
||||
result = await CreditService.delete_credit(api_credit_group, request_id)
|
||||
audit(request, actor=username, action='credit_def.delete', target=api_credit_group, status=result.get('status_code'), details=None, request_id=request_id)
|
||||
audit(
|
||||
request,
|
||||
actor=username,
|
||||
action='credit_def.delete',
|
||||
target=api_credit_group,
|
||||
status=result.get('status_code'),
|
||||
details=None,
|
||||
request_id=request_id,
|
||||
)
|
||||
return respond_rest(result)
|
||||
except Exception as e:
|
||||
logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True)
|
||||
return process_response(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={
|
||||
'request_id': request_id
|
||||
},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred'
|
||||
).dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred',
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
finally:
|
||||
end_time = time.time() * 1000
|
||||
logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms')
|
||||
|
||||
|
||||
"""
|
||||
Add credits for a user
|
||||
|
||||
@@ -291,30 +337,27 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@credit_router.post('/{username}',
|
||||
|
||||
@credit_router.post(
|
||||
'/{username}',
|
||||
description='Add credits for a user',
|
||||
response_model=ResponseModel,
|
||||
responses={
|
||||
200: {
|
||||
'description': 'Successful Response',
|
||||
'content': {
|
||||
'application/json': {
|
||||
'example': {
|
||||
'message': 'Credits saved successfully'
|
||||
}
|
||||
}
|
||||
}
|
||||
'content': {'application/json': {'example': {'message': 'Credits saved successfully'}}},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async def add_user_credits(username: str, credit_data: UserCreditModel, request: Request):
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time() * 1000
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}')
|
||||
logger.info(
|
||||
f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}'
|
||||
)
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
if not await platform_role_required_bool(username, 'manage_credits'):
|
||||
return respond_rest(
|
||||
@@ -322,24 +365,35 @@ async def add_user_credits(username: str, credit_data: UserCreditModel, request:
|
||||
status_code=403,
|
||||
error_code='CRD001',
|
||||
error_message='You do not have permission to manage credits',
|
||||
))
|
||||
)
|
||||
)
|
||||
result = await CreditService.add_credits(username, credit_data, request_id)
|
||||
audit(request, actor=username, action='user_credits.save', target=username, status=result.get('status_code'), details={'groups': list((credit_data.users_credits or {}).keys())}, request_id=request_id)
|
||||
audit(
|
||||
request,
|
||||
actor=username,
|
||||
action='user_credits.save',
|
||||
target=username,
|
||||
status=result.get('status_code'),
|
||||
details={'groups': list((credit_data.users_credits or {}).keys())},
|
||||
request_id=request_id,
|
||||
)
|
||||
return respond_rest(result)
|
||||
except Exception as e:
|
||||
logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True)
|
||||
return process_response(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={
|
||||
'request_id': request_id
|
||||
},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred'
|
||||
).dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred',
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
finally:
|
||||
end_time = time.time() * 1000
|
||||
logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms')
|
||||
|
||||
|
||||
"""
|
||||
Endpoint
|
||||
|
||||
@@ -349,18 +403,19 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@credit_router.get('/all',
|
||||
description='Get all user credits',
|
||||
response_model=List[UserCreditModel]
|
||||
)
|
||||
|
||||
async def get_all_users_credits(request: Request, page: int = 1, page_size: int = 10, search: str = ''):
|
||||
@credit_router.get('/all', description='Get all user credits', response_model=list[UserCreditModel])
|
||||
async def get_all_users_credits(
|
||||
request: Request, page: int = 1, page_size: int = 10, search: str = ''
|
||||
):
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time() * 1000
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}')
|
||||
logger.info(
|
||||
f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}'
|
||||
)
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
if not await platform_role_required_bool(username, 'manage_credits'):
|
||||
return respond_rest(
|
||||
@@ -368,22 +423,27 @@ async def get_all_users_credits(request: Request, page: int = 1, page_size: int
|
||||
status_code=403,
|
||||
error_code='CRD002',
|
||||
error_message='Unable to retrieve credits for all users',
|
||||
))
|
||||
return respond_rest(await CreditService.get_all_credits(page, page_size, request_id, search=search))
|
||||
)
|
||||
)
|
||||
return respond_rest(
|
||||
await CreditService.get_all_credits(page, page_size, request_id, search=search)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True)
|
||||
return process_response(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={
|
||||
'request_id': request_id
|
||||
},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred'
|
||||
).dict(), 'rest')
|
||||
return process_response(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred',
|
||||
).dict(),
|
||||
'rest',
|
||||
)
|
||||
finally:
|
||||
end_time = time.time() * 1000
|
||||
logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms')
|
||||
|
||||
|
||||
"""
|
||||
Endpoint
|
||||
|
||||
@@ -393,38 +453,41 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@credit_router.get('/{username}',
|
||||
description='Get credits for a user',
|
||||
response_model=UserCreditModel
|
||||
)
|
||||
|
||||
@credit_router.get(
|
||||
'/{username}', description='Get credits for a user', response_model=UserCreditModel
|
||||
)
|
||||
async def get_credits(username: str, request: Request):
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time() * 1000
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
if not payload.get('sub') == username and not await platform_role_required_bool(payload.get('sub'), 'manage_credits'):
|
||||
if not payload.get('sub') == username and not await platform_role_required_bool(
|
||||
payload.get('sub'), 'manage_credits'
|
||||
):
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=403,
|
||||
error_code='CRD003',
|
||||
error_message='Unable to retrieve credits for user',
|
||||
))
|
||||
)
|
||||
)
|
||||
return respond_rest(await CreditService.get_user_credits(username, request_id))
|
||||
except Exception as e:
|
||||
logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True)
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={
|
||||
'request_id': request_id
|
||||
},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred'
|
||||
))
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred',
|
||||
)
|
||||
)
|
||||
finally:
|
||||
end_time = time.time() * 1000
|
||||
logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms')
|
||||
|
||||
|
||||
"""
|
||||
Rotate API key for a user
|
||||
|
||||
@@ -434,30 +497,25 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@credit_router.post('/rotate-key',
|
||||
|
||||
@credit_router.post(
|
||||
'/rotate-key',
|
||||
description='Rotate API key for the authenticated user',
|
||||
response_model=ResponseModel,
|
||||
responses={
|
||||
200: {
|
||||
'description': 'Successful Response',
|
||||
'content': {
|
||||
'application/json': {
|
||||
'example': {
|
||||
'api_key': '******************'
|
||||
}
|
||||
}
|
||||
}
|
||||
'content': {'application/json': {'example': {'api_key': '******************'}}},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async def rotate_key(request: Request):
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time() * 1000
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
|
||||
|
||||
# Get group from body
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -466,29 +524,33 @@ async def rotate_key(request: Request):
|
||||
group = None
|
||||
|
||||
if not group:
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=400,
|
||||
error_code='CRD020',
|
||||
error_message='api_credit_group is required'
|
||||
))
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=400,
|
||||
error_code='CRD020',
|
||||
error_message='api_credit_group is required',
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}')
|
||||
logger.info(
|
||||
f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}'
|
||||
)
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
|
||||
|
||||
# No special role required, just authentication
|
||||
|
||||
|
||||
return respond_rest(await CreditService.rotate_api_key(username, group, request_id))
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True)
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={
|
||||
'request_id': request_id
|
||||
},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred'
|
||||
))
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred',
|
||||
)
|
||||
)
|
||||
finally:
|
||||
end_time = time.time() * 1000
|
||||
logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms')
|
||||
|
||||
@@ -4,18 +4,18 @@ Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from typing import Dict, List
|
||||
import uuid
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from models.response_model import ResponseModel
|
||||
from utils.auth_util import auth_required
|
||||
from utils.response_util import respond_rest
|
||||
from utils.database import user_collection, api_collection, subscriptions_collection
|
||||
from utils.database import api_collection, subscriptions_collection, user_collection
|
||||
from utils.metrics_util import metrics_store
|
||||
from utils.response_util import respond_rest
|
||||
|
||||
dashboard_router = APIRouter()
|
||||
logger = logging.getLogger('doorman.gateway')
|
||||
@@ -29,11 +29,8 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@dashboard_router.get('',
|
||||
description='Get dashboard data',
|
||||
response_model=ResponseModel
|
||||
)
|
||||
|
||||
@dashboard_router.get('', description='Get dashboard data', response_model=ResponseModel)
|
||||
async def get_dashboard_data(request: Request):
|
||||
"""Get dashboard statistics and data"""
|
||||
request_id = str(uuid.uuid4())
|
||||
@@ -41,14 +38,16 @@ async def get_dashboard_data(request: Request):
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}')
|
||||
logger.info(
|
||||
f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}'
|
||||
)
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
|
||||
total_users = user_collection.count_documents({'active': True})
|
||||
total_apis = api_collection.count_documents({})
|
||||
|
||||
snap = metrics_store.snapshot('30d')
|
||||
monthly_usage: Dict[str, int] = {}
|
||||
monthly_usage: dict[str, int] = {}
|
||||
for pt in snap.get('series', []):
|
||||
try:
|
||||
ts = datetime.fromtimestamp(pt['timestamp'])
|
||||
@@ -61,15 +60,12 @@ async def get_dashboard_data(request: Request):
|
||||
for username, reqs in snap.get('top_users', [])[:5]:
|
||||
subs = subscriptions_collection.find_one({'username': username}) or {}
|
||||
subscribers = len(subs.get('apis', [])) if isinstance(subs.get('apis'), list) else 0
|
||||
active_users_list.append({
|
||||
'username': username,
|
||||
'requests': f'{int(reqs):,}',
|
||||
'subscribers': subscribers
|
||||
})
|
||||
active_users_list.append(
|
||||
{'username': username, 'requests': f'{int(reqs):,}', 'subscribers': subscribers}
|
||||
)
|
||||
|
||||
popular_apis = []
|
||||
for api_key, reqs in snap.get('top_apis', [])[:10]:
|
||||
|
||||
try:
|
||||
name = api_key
|
||||
|
||||
@@ -81,11 +77,9 @@ async def get_dashboard_data(request: Request):
|
||||
count += 1
|
||||
except Exception:
|
||||
count = 0
|
||||
popular_apis.append({
|
||||
'name': name,
|
||||
'requests': f'{int(reqs):,}',
|
||||
'subscribers': count
|
||||
})
|
||||
popular_apis.append(
|
||||
{'name': name, 'requests': f'{int(reqs):,}', 'subscribers': count}
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
@@ -95,23 +89,27 @@ async def get_dashboard_data(request: Request):
|
||||
'newApis': total_apis,
|
||||
'monthlyUsage': monthly_usage,
|
||||
'activeUsersList': active_users_list,
|
||||
'popularApis': popular_apis
|
||||
'popularApis': popular_apis,
|
||||
}
|
||||
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=200,
|
||||
response_headers={'request_id': request_id},
|
||||
response=dashboard_data
|
||||
))
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=200,
|
||||
response_headers={'request_id': request_id},
|
||||
response=dashboard_data,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.critical(f'{request_id} | Unexpected error: {str(e)}', exc_info=True)
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred'
|
||||
))
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=500,
|
||||
response_headers={'request_id': request_id},
|
||||
error_code='GTW999',
|
||||
error_message='An unexpected error occurred',
|
||||
)
|
||||
)
|
||||
finally:
|
||||
end_time = time.time() * 1000
|
||||
logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms')
|
||||
|
||||
@@ -3,17 +3,17 @@ Protected demo seeding routes for populating the running server with dummy data.
|
||||
Only available to users with 'manage_gateway' OR 'manage_credits'.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from typing import Optional
|
||||
import uuid
|
||||
import time
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from models.response_model import ResponseModel
|
||||
from utils.response_util import respond_rest
|
||||
from utils.role_util import platform_role_required_bool, is_admin_user
|
||||
from utils.auth_util import auth_required
|
||||
from utils.demo_seed_util import run_seed
|
||||
from utils.response_util import respond_rest
|
||||
from utils.role_util import is_admin_user
|
||||
|
||||
demo_router = APIRouter()
|
||||
logger = logging.getLogger('doorman.gateway')
|
||||
@@ -27,38 +27,55 @@ Response:
|
||||
{}
|
||||
"""
|
||||
|
||||
@demo_router.post('/seed',
|
||||
description='Seed the running server with demo data',
|
||||
response_model=ResponseModel
|
||||
)
|
||||
|
||||
async def demo_seed(request: Request,
|
||||
users: int = 40,
|
||||
apis: int = 15,
|
||||
endpoints: int = 6,
|
||||
groups: int = 8,
|
||||
protos: int = 6,
|
||||
logs: int = 1500,
|
||||
seed: Optional[int] = None):
|
||||
@demo_router.post(
|
||||
'/seed', description='Seed the running server with demo data', response_model=ResponseModel
|
||||
)
|
||||
async def demo_seed(
|
||||
request: Request,
|
||||
users: int = 40,
|
||||
apis: int = 15,
|
||||
endpoints: int = 6,
|
||||
groups: int = 8,
|
||||
protos: int = 6,
|
||||
logs: int = 1500,
|
||||
seed: int | None = None,
|
||||
):
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time() * 1000
|
||||
try:
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}')
|
||||
logger.info(
|
||||
f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}'
|
||||
)
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
|
||||
if not await is_admin_user(username):
|
||||
return respond_rest(ResponseModel(
|
||||
status_code=403,
|
||||
error_code='DEMO001',
|
||||
error_message='Permission denied to run seeder'
|
||||
))
|
||||
res = run_seed(users=users, apis=apis, endpoints=endpoints, groups=groups, protos=protos, logs=logs, seed=seed)
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=403,
|
||||
error_code='DEMO001',
|
||||
error_message='Permission denied to run seeder',
|
||||
)
|
||||
)
|
||||
res = run_seed(
|
||||
users=users,
|
||||
apis=apis,
|
||||
endpoints=endpoints,
|
||||
groups=groups,
|
||||
protos=protos,
|
||||
logs=logs,
|
||||
seed=seed,
|
||||
)
|
||||
return respond_rest(ResponseModel(status_code=200, response=res, message='Seed completed'))
|
||||
except Exception as e:
|
||||
logger.error(f'{request_id} | Demo seed error: {str(e)}', exc_info=True)
|
||||
return respond_rest(ResponseModel(status_code=500, error_code='DEMO999', error_message='Failed to seed demo data'))
|
||||
return respond_rest(
|
||||
ResponseModel(
|
||||
status_code=500, error_code='DEMO999', error_message='Failed to seed demo data'
|
||||
)
|
||||
)
|
||||
finally:
|
||||
end_time = time.time() * 1000
|
||||
logger.info(f'{request_id} | Total time: {str(end_time - start_time)}ms')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user