formatting and style fixes. lint fixes

This commit is contained in:
seniorswe
2025-12-10 23:09:05 -05:00
parent 291e9cb66a
commit 32afdd2fdc
320 changed files with 19871 additions and 11801 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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('/'))

View File

@@ -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)}')

View File

@@ -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')

View File

@@ -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()

View File

@@ -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]

View File

@@ -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]

View File

@@ -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]

View File

@@ -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}')

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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)

View File

@@ -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]

View File

@@ -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:

View File

@@ -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)

View File

@@ -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()

View File

@@ -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]

View File

@@ -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'}}

View File

@@ -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)

View File

@@ -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]

View File

@@ -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)

View File

@@ -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

View File

@@ -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]

View File

@@ -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]

View File

@@ -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)

View File

@@ -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}')

View File

@@ -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)

View File

@@ -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]

View File

@@ -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

View File

@@ -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]

View File

@@ -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')

View File

@@ -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]

View File

@@ -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]

View File

@@ -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]

View File

@@ -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]

View File

@@ -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)

View File

@@ -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]

View File

@@ -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]

View File

@@ -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]

View File

@@ -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')

View File

@@ -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 '')

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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'}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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')

View File

@@ -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

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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'
)

View File

@@ -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

View File

@@ -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}'

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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',
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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},
},
)

View File

@@ -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:

View File

@@ -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')

View File

@@ -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

View File

@@ -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')

View File

@@ -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')

View File

@@ -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')

View File

@@ -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')

View File

@@ -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