diff --git a/README.md b/README.md index 62d0944..1c23018 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,10 @@ REDIS_HOST=localhost REDIS_PORT=6379 REDIS_DB=0 -# Mem Cache Config -CACHE_DUMP_INTERVAL=300 -CACHE_MIN_DUMP_INTERVAL=60 -CACHE_DUMP_FILE=data/doorman_data.enc +# Memory Dump Config (memory-only mode) +# Base path/stem for encrypted in-memory database dumps (.bin). Timestamp is appended. +# Example produces files like generated/memory_dump-YYYYMMDDTHHMMSSZ.bin +MEM_DUMP_PATH=generated/memory_dump.bin # Authorization Config JWT_SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx @@ -137,4 +137,4 @@ Use at your own risk. By using this software, you agree to the [Apache 2.0 Licen ## -We welcome contributors and testers! \ No newline at end of file +We welcome contributors and testers! diff --git a/backend-services/doorman.py b/backend-services/doorman.py index 8ff50ad..2831934 100755 --- a/backend-services/doorman.py +++ b/backend-services/doorman.py @@ -49,6 +49,7 @@ import sys import subprocess import signal import uvicorn +import time import asyncio from utils.response_util import process_response @@ -187,6 +188,32 @@ async def startup_event(): except Exception as e: gateway_logger.error(f"Memory mode restore failed: {e}") + # Register SIGUSR1 handler to force a memory dump (Unix only) + try: + if hasattr(signal, "SIGUSR1"): + loop = asyncio.get_event_loop() + + async def _sigusr1_dump(): + try: + if not database.memory_only: + gateway_logger.info("SIGUSR1 ignored: not in memory-only mode") + return + if not os.getenv("MEM_ENCRYPTION_KEY"): + gateway_logger.error("SIGUSR1 dump skipped: MEM_ENCRYPTION_KEY not configured") + return + settings = get_cached_settings() + path_hint = settings.get("dump_path") + dump_path = await asyncio.to_thread(dump_memory_to_file, path_hint) + gateway_logger.info(f"SIGUSR1: memory dump written to {dump_path}") + except Exception as e: + gateway_logger.error(f"SIGUSR1 dump failed: {e}") + + loop.add_signal_handler(signal.SIGUSR1, lambda: asyncio.create_task(_sigusr1_dump())) + gateway_logger.info("SIGUSR1 handler registered for on-demand memory dumps") + except NotImplementedError: + # add_signal_handler not supported on this platform/event loop + pass + @doorman.on_event("shutdown") async def shutdown_event(): # Stop auto-save task cleanly @@ -265,9 +292,6 @@ def start(): gateway_logger.info(f"Starting doorman with PID {process.pid}.") def stop(): - if doorman_cache.cache_type == "MEM": - doorman_cache.force_save_cache() - doorman_cache.stop_cache_persistence() if not os.path.exists(PID_FILE): gateway_logger.info("No running instance found") return @@ -277,7 +301,18 @@ def stop(): if os.name == "nt": subprocess.call(["taskkill", "/F", "/PID", str(pid)]) else: + # Send SIGTERM to allow graceful shutdown; FastAPI shutdown event + # writes a final encrypted memory dump in memory-only mode. os.killpg(pid, signal.SIGTERM) + # Wait briefly for graceful shutdown so the dump can complete + deadline = time.time() + 15 + while time.time() < deadline: + try: + # Check if process group leader still exists + os.kill(pid, 0) + time.sleep(0.5) + except ProcessLookupError: + break print(f"Stopping doorman with PID {pid}") except ProcessLookupError: print("Process already terminated") diff --git a/backend-services/models/create_endpoint_model.py b/backend-services/models/create_endpoint_model.py index ae2d53a..7ec3abf 100644 --- a/backend-services/models/create_endpoint_model.py +++ b/backend-services/models/create_endpoint_model.py @@ -5,7 +5,7 @@ See https://github.com/pypeople-dev/doorman for more information """ from pydantic import BaseModel, Field -from typing import Optional +from typing import Optional, List class CreateEndpointModel(BaseModel): @@ -14,9 +14,10 @@ class CreateEndpointModel(BaseModel): 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) class Config: - arbitrary_types_allowed = True \ No newline at end of file + arbitrary_types_allowed = True diff --git a/backend-services/models/endpoint_model_response.py b/backend-services/models/endpoint_model_response.py index a29c10c..930bf15 100644 --- a/backend-services/models/endpoint_model_response.py +++ b/backend-services/models/endpoint_model_response.py @@ -5,7 +5,7 @@ See https://github.com/pypeople-dev/doorman for more information """ from pydantic import BaseModel, Field -from typing import Optional +from typing import Optional, List class EndpointModelResponse(BaseModel): @@ -14,8 +14,9 @@ class EndpointModelResponse(BaseModel): 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) class Config: - arbitrary_types_allowed = True \ No newline at end of file + arbitrary_types_allowed = True diff --git a/backend-services/models/update_endpoint_model.py b/backend-services/models/update_endpoint_model.py index 615275e..017b482 100644 --- a/backend-services/models/update_endpoint_model.py +++ b/backend-services/models/update_endpoint_model.py @@ -5,7 +5,7 @@ See https://github.com/pypeople-dev/doorman for more information """ from pydantic import BaseModel, Field -from typing import Optional +from typing import Optional, List class UpdateEndpointModel(BaseModel): @@ -14,8 +14,9 @@ class UpdateEndpointModel(BaseModel): 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) class Config: - arbitrary_types_allowed = True \ No newline at end of file + arbitrary_types_allowed = True diff --git a/backend-services/services/gateway_service.py b/backend-services/services/gateway_service.py index 9f14d64..20c1ca8 100644 --- a/backend-services/services/gateway_service.py +++ b/backend-services/services/gateway_service.py @@ -93,17 +93,10 @@ class GatewayService: logger.error(f"{endpoints} | REST gateway failed with code GTW003") return GatewayService.error_response(request_id, 'GTW003', 'Endpoint does not exist for the requested API') client_key = request.headers.get('client-key') - if client_key: - server = await routing_util.get_routing_info(client_key) - if not server: - return GatewayService.error_response(request_id, 'GTW007', 'Client key does not exist in routing') - logger.info(f"{request_id} | REST gateway to: {server}") - else: - server_index = doorman_cache.get_cache('endpoint_server_cache', api.get('api_id')) or 0 - api_servers = api.get('api_servers') or [] - server = api_servers[server_index] - doorman_cache.set_cache('endpoint_server_cache', api.get('api_id'), (server_index + 1) % len(api_servers)) - logger.info(f"{request_id} | REST gateway to: {server}") + server = await routing_util.pick_upstream_server(api, request.method, endpoint_uri, client_key) + if not server: + return GatewayService.error_response(request_id, 'GTW001', 'No upstream servers configured') + logger.info(f"{request_id} | REST gateway to: {server}") url = server.rstrip('/') + '/' + endpoint_uri.lstrip('/') method = request.method.upper() retry = api.get('api_allowed_retry_count') or 0 @@ -213,15 +206,9 @@ class GatewayService: if not any(re.fullmatch(regex_pattern.sub(r"([^/]+)", ep), composite) for ep in endpoints): return GatewayService.error_response(request_id, 'GTW003', 'Endpoint does not exist for the requested API') client_key = request.headers.get('client-key') - if client_key: - server = await routing_util.get_routing_info(client_key) - if not server: - return GatewayService.error_response(request_id, 'GTW007', 'Client key does not exist in routing') - else: - server_index = doorman_cache.get_cache('endpoint_server_cache', api.get('api_id')) or 0 - api_servers = api.get('api_servers') or [] - server = api_servers[server_index] - doorman_cache.set_cache('endpoint_server_cache', api.get('api_id'), (server_index + 1) % len(api_servers)) + server = await routing_util.pick_upstream_server(api, 'POST', endpoint_uri, client_key) + if not server: + return GatewayService.error_response(request_id, 'GTW001', 'No upstream servers configured') url = server.rstrip('/') + '/' + endpoint_uri.lstrip('/') logger.info(f"{request_id} | SOAP gateway to: {url}") retry = api.get('api_allowed_retry_count') or 0 @@ -302,10 +289,12 @@ class GatewayService: logger.error(f"{request_id} | API not found: {api_path}") return GatewayService.error_response(request_id, 'GTW001', f'API does not exist: {api_path}') doorman_cache.set_cache('api_cache', api_path, api) - if not api.get('api_servers'): - logger.error(f"{request_id} | No API servers configured for {api_path}") - return GatewayService.error_response(request_id, 'GTW001', 'No API servers configured') - url = api.get('api_servers', [])[0].rstrip('/') + client_key = request.headers.get('client-key') + server = await routing_util.pick_upstream_server(api, 'POST', '/graphql', client_key) + if not server: + logger.error(f"{request_id} | No upstream servers configured for {api_path}") + return GatewayService.error_response(request_id, 'GTW001', 'No upstream servers configured') + url = server.rstrip('/') retry = api.get('api_allowed_retry_count') or 0 if api.get('api_tokens_enabled'): if not await token_util.deduct_ai_token(api.get('api_token_group'), username): @@ -396,10 +385,12 @@ class GatewayService: logger.error(f"{request_id} | API not found: {api_path}") return GatewayService.error_response(request_id, 'GTW001', f'API does not exist: {api_path}', status=404) doorman_cache.set_cache('api_cache', api_path, api) - if not api.get('api_servers'): - logger.error(f"{request_id} | No API servers configured for {api_path}") - return GatewayService.error_response(request_id, 'GTW001', 'No API servers configured', status=404) - url = api.get('api_servers', [])[0].rstrip('/') + client_key = request.headers.get('client-key') + server = await routing_util.pick_upstream_server(api, 'POST', '/grpc', client_key) + if not server: + logger.error(f"{request_id} | No upstream servers configured for {api_path}") + return GatewayService.error_response(request_id, 'GTW001', 'No upstream servers configured', status=404) + url = server.rstrip('/') if url.startswith('grpc://'): url = url[7:] retry = api.get('api_allowed_retry_count') or 0 @@ -536,4 +527,4 @@ class GatewayService: 'message': f'Error making GraphQL request: {str(e)}', 'extensions': {'code': 'REQUEST_ERROR'} }] - } \ No newline at end of file + } diff --git a/backend-services/utils/api_util.py b/backend-services/utils/api_util.py index d3558f4..a2c0e5c 100644 --- a/backend-services/utils/api_util.py +++ b/backend-services/utils/api_util.py @@ -1,5 +1,6 @@ from utils.doorman_cache_util import doorman_cache from utils.database import api_collection, endpoint_collection +from typing import Optional, Dict async def get_api(api_key, api_name_version): api = doorman_cache.get_cache('api_cache', api_key) if api_key else None @@ -25,4 +26,28 @@ async def get_api_endpoints(api_id): for endpoint in endpoints_list ] doorman_cache.set_cache('api_endpoint_cache', api_id, endpoints) - return endpoints \ No newline at end of file + return endpoints + + +async def get_endpoint(api: Dict, method: str, endpoint_uri: str) -> Optional[Dict]: + """Return the endpoint document for a given API, method, and uri. + + Uses the same cache key pattern as EndpointService to avoid duplicate queries. + """ + api_name = api.get('api_name') + api_version = api.get('api_version') + cache_key = f"/{method}/{api_name}/{api_version}/{endpoint_uri}".replace("//", "/") + endpoint = doorman_cache.get_cache('endpoint_cache', cache_key) + if endpoint: + return endpoint + doc = endpoint_collection.find_one({ + 'api_name': api_name, + 'api_version': api_version, + 'endpoint_uri': endpoint_uri, + 'endpoint_method': method + }) + if not doc: + return None + doc.pop('_id', None) + doorman_cache.set_cache('endpoint_cache', cache_key, doc) + return doc diff --git a/backend-services/utils/doorman_cache_util.py b/backend-services/utils/doorman_cache_util.py index 4a3a014..2e345fe 100644 --- a/backend-services/utils/doorman_cache_util.py +++ b/backend-services/utils/doorman_cache_util.py @@ -9,27 +9,11 @@ import json import os import threading from typing import Dict, Any, Optional -import pickle -from cryptography.fernet import Fernet -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -import base64 class MemoryCache: def __init__(self): self._cache: Dict[str, Dict[str, Any]] = {} self._lock = threading.RLock() - self._dump_file = os.getenv("CACHE_DUMP_FILE", "cache_dump.enc") - self._encryption_key = self._get_encryption_key() - self._auto_save_thread = None - self._stop_auto_save = threading.Event() - self._dump_interval = int(os.getenv("CACHE_DUMP_INTERVAL", "300")) - self._min_dump_interval = int(os.getenv("CACHE_MIN_DUMP_INTERVAL", "60")) - self._last_dump_time = 0 - self._cache_modified = False - self._last_cache_size = 0 - self._load_cache() - self._start_auto_save() def setex(self, key: str, ttl: int, value: str): with self._lock: @@ -37,8 +21,7 @@ class MemoryCache: 'value': value, 'expires_at': self._get_current_time() + ttl } - self._cache_modified = True - + def get(self, key: str) -> Optional[str]: with self._lock: if key in self._cache: @@ -54,7 +37,6 @@ class MemoryCache: for key in keys: if key in self._cache: self._cache.pop(key, None) - self._cache_modified = True def keys(self, pattern: str) -> list: with self._lock: @@ -77,9 +59,7 @@ class MemoryCache: return { 'total_entries': total_entries, 'active_entries': active_entries, - 'expired_entries': expired_entries, - 'dump_file': self._dump_file, - 'auto_save_active': not self._stop_auto_save.is_set() + 'expired_entries': expired_entries } def _cleanup_expired(self): @@ -94,84 +74,9 @@ class MemoryCache: if expired_keys: print(f"Cleaned up {len(expired_keys)} expired cache entries") - def _get_encryption_key(self) -> bytes: - env_key = os.getenv("MEM_ENCRYPTION_KEY") - if not env_key: - raise ValueError("MEM_ENCRYPTION_KEY environment variable is required for memory cache") - salt = b'pygate_cache_salt' - kdf = PBKDF2HMAC( - algorithm=hashes.SHA256(), - length=32, - salt=salt, - iterations=100000, - ) - key = base64.urlsafe_b64encode(kdf.derive(env_key.encode())) - return key - - def _encrypt_data(self, data: bytes) -> bytes: - f = Fernet(self._encryption_key) - return f.encrypt(data) - - def _decrypt_data(self, encrypted_data: bytes) -> bytes: - f = Fernet(self._encryption_key) - return f.decrypt(encrypted_data) - - def _save_cache(self): - try: - with self._lock: - cache_data = {} - current_time = self._get_current_time() - for key, entry in self._cache.items(): - if current_time < entry['expires_at']: - cache_data[key] = entry - serialized_data = pickle.dumps(cache_data) - encrypted_data = self._encrypt_data(serialized_data) - temp_file = f"{self._dump_file}.tmp" - dump_dir = os.path.dirname(self._dump_file) - if dump_dir: - os.makedirs(dump_dir, exist_ok=True) - with open(temp_file, 'wb') as f: - f.write(encrypted_data) - os.replace(temp_file, self._dump_file) - except Exception as e: - print(f"Warning: Failed to save cache to {self._dump_file}: {e}") - - def _load_cache(self): - try: - if os.path.exists(self._dump_file): - with open(self._dump_file, 'rb') as f: - encrypted_data = f.read() - decrypted_data = self._decrypt_data(encrypted_data) - loaded_cache = pickle.loads(decrypted_data) - current_time = self._get_current_time() - with self._lock: - for key, entry in loaded_cache.items(): - if current_time < entry['expires_at']: - self._cache[key] = entry - print(f"Loaded {len(self._cache)} cache entries from {self._dump_file}") - except Exception as e: - print(f"Warning: Failed to load cache from {self._dump_file}: {e}") - - def _start_auto_save(self): - def auto_save_worker(): - while not self._stop_auto_save.wait(self._min_dump_interval): - current_time = self._get_current_time() - if (self._cache_modified and - current_time - self._last_dump_time >= self._dump_interval and - abs(len(self._cache) - self._last_cache_size) > 0): - self._save_cache() - self._last_dump_time = current_time - self._cache_modified = False - self._last_cache_size = len(self._cache) - self._auto_save_thread = threading.Thread(target=auto_save_worker, daemon=True) - self._auto_save_thread.start() - + # No-op stubs to keep interface compatibility def stop_auto_save(self): - """Stop the auto-save thread and perform final save.""" - self._stop_auto_save.set() - if self._auto_save_thread: - self._auto_save_thread.join(timeout=5) - self._save_cache() + return class DoormanCacheManager: def __init__(self): @@ -284,13 +189,12 @@ class DoormanCacheManager: self.cache._cleanup_expired() def force_save_cache(self): - if not self.is_redis and hasattr(self.cache, '_save_cache'): - self.cache._save_cache() + # No-op: cache persistence removed + return def stop_cache_persistence(self): - """Stop the auto-save thread (memory cache only).""" - if not self.is_redis and hasattr(self.cache, 'stop_auto_save'): - self.cache.stop_auto_save() + """No-op: cache persistence removed.""" + return @staticmethod def is_operational(): diff --git a/backend-services/utils/routing_util.py b/backend-services/utils/routing_util.py index 2c2e5d1..6e60d7f 100644 --- a/backend-services/utils/routing_util.py +++ b/backend-services/utils/routing_util.py @@ -1,5 +1,7 @@ from utils.doorman_cache_util import doorman_cache from utils.database import routing_collection +from utils import api_util +from typing import Optional, Dict import logging @@ -32,4 +34,43 @@ async def get_routing_info(client_key): server_index = (server_index + 1) % len(api_servers) routing['server_index'] = server_index doorman_cache.set_cache('client_routing_cache', client_key, routing) - return server \ No newline at end of file + return server + + +async def pick_upstream_server(api: Dict, method: str, endpoint_uri: str, client_key: Optional[str]) -> Optional[str]: + """Resolve upstream server with precedence: Routing (1) > Endpoint (2) > API (3). + + - Routing: client-specific routing list with round-robin in the routing doc/cache. + - Endpoint: endpoint_servers list on the endpoint doc, round-robin via cache key endpoint_id. + - API: api_servers list on the API doc, round-robin via cache key api_id. + """ + # 1) Client routing + if client_key: + server = await get_routing_info(client_key) + if server: + return server + + # 2) Endpoint-level servers + try: + endpoint = await api_util.get_endpoint(api, method, endpoint_uri) + except Exception: + endpoint = None + if endpoint: + ep_servers = endpoint.get('endpoint_servers') or [] + if isinstance(ep_servers, list) and len(ep_servers) > 0: + idx_key = endpoint.get('endpoint_id') or f"{api.get('api_id')}:{method}:{endpoint_uri}" + server_index = doorman_cache.get_cache('endpoint_server_cache', idx_key) or 0 + server = ep_servers[server_index % len(ep_servers)] + doorman_cache.set_cache('endpoint_server_cache', idx_key, (server_index + 1) % len(ep_servers)) + return server + + # 3) API-level servers + api_servers = api.get('api_servers') or [] + if isinstance(api_servers, list) and len(api_servers) > 0: + idx_key = api.get('api_id') + server_index = doorman_cache.get_cache('endpoint_server_cache', idx_key) or 0 + server = api_servers[server_index % len(api_servers)] + doorman_cache.set_cache('endpoint_server_cache', idx_key, (server_index + 1) % len(api_servers)) + return server + + return None diff --git a/web-client/src/app/apis/[apiId]/endpoints/add/page.tsx b/web-client/src/app/apis/[apiId]/endpoints/add/page.tsx new file mode 100644 index 0000000..3c33bd8 --- /dev/null +++ b/web-client/src/app/apis/[apiId]/endpoints/add/page.tsx @@ -0,0 +1,175 @@ +'use client' + +import React, { useEffect, useState } from 'react' +import Link from 'next/link' +import { useParams, useRouter } from 'next/navigation' +import Layout from '@/components/Layout' + +export default function AddEndpointPage() { + const params = useParams() + const router = useRouter() + const apiId = params.apiId as string + const [apiName, setApiName] = useState('') + const [apiVersion, setApiVersion] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + const [method, setMethod] = useState('GET') + const [uri, setUri] = useState('') + const [description, setDescription] = useState('') + const [useOverride, setUseOverride] = useState(false) + const [servers, setServers] = useState([]) + const [newServer, setNewServer] = useState('') + + useEffect(() => { + try { + const apiData = sessionStorage.getItem('selectedApi') + if (apiData) { + const parsed = JSON.parse(apiData) + setApiName(parsed.api_name || '') + setApiVersion(parsed.api_version || '') + } + } catch {} + }, []) + + const addServer = () => { + const v = newServer.trim() + if (!v) return + if (servers.includes(v)) return + setServers(prev => [...prev, v]) + setNewServer('') + } + + const removeServer = (idx: number) => { + setServers(prev => prev.filter((_, i) => i !== idx)) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!uri.trim()) return + setLoading(true) + setError(null) + try { + const body: any = { + api_name: apiName, + api_version: apiVersion, + endpoint_method: method, + endpoint_uri: uri.startsWith('/') ? uri : '/' + uri, + endpoint_description: description || `${method} ${uri}` + } + if (useOverride && servers.length > 0) { + body.endpoint_servers = servers + } + const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/endpoint`, { + method: 'POST', + credentials: 'include', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}` + }, + body: JSON.stringify(body) + }) + const data = await response.json() + if (!response.ok) throw new Error(data.error_message || 'Failed to create endpoint') + setSuccess('Endpoint created') + setTimeout(() => setSuccess(null), 1500) + router.push(`/apis/${encodeURIComponent(apiId)}/endpoints`) + } catch (e:any) { + setError(e?.message || 'Failed to create endpoint') + } finally { + setLoading(false) + } + } + + return ( + +
+
+
+

Add Endpoint

+

For API {apiName}/{apiVersion}

+
+
+ Back to Endpoints +
+
+ + {success && ( +
+

{success}

+
+ )} + {error && ( +
+

{error}

+
+ )} + +
+
+
+ + +
+
+ + setUri(e.target.value)} placeholder="/path/{id}" /> +
+
+ + setDescription(e.target.value)} placeholder="Describe endpoint" /> +
+ +
+
+ setUseOverride(e.target.checked)} /> + +
+
+ setNewServer(e.target.value)} + placeholder="e.g., http://localhost:8082" + onKeyPress={(e) => useOverride && e.key === 'Enter' && addServer()} + disabled={!useOverride} + /> + +
+
+ {servers.map((srv, idx) => ( +
+ {srv} + +
+ ))} + {!useOverride && ( +

Disabled — API servers will be used unless enabled.

+ )} + {useOverride && servers.length === 0 && ( +

No endpoint-specific servers. API servers will be used.

+ )} +
+
+ +
+ + Cancel +
+
+
+
+
+ ) +} + diff --git a/web-client/src/app/apis/[apiId]/endpoints/page.tsx b/web-client/src/app/apis/[apiId]/endpoints/page.tsx new file mode 100644 index 0000000..9f77670 --- /dev/null +++ b/web-client/src/app/apis/[apiId]/endpoints/page.tsx @@ -0,0 +1,338 @@ +'use client' + +import React, { useEffect, useMemo, useState } from 'react' +import Link from 'next/link' +import { useParams, useRouter } from 'next/navigation' +import Layout from '@/components/Layout' + +interface EndpointItem { + api_name: string + api_version: string + endpoint_method: string + endpoint_uri: string + endpoint_description?: string + endpoint_id?: string + endpoint_servers?: string[] +} + +export default function ApiEndpointsPage() { + const params = useParams() + const router = useRouter() + const apiId = params.apiId as string + const [apiName, setApiName] = useState('') + const [apiVersion, setApiVersion] = useState('') + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [endpoints, setEndpoints] = useState([]) + const [allEndpoints, setAllEndpoints] = useState([]) + const [searchTerm, setSearchTerm] = useState('') + const [sortBy, setSortBy] = useState<'method' | 'uri' | 'servers'>('method') + const [working, setWorking] = useState>({}) + const [epNewServer, setEpNewServer] = useState>({}) + + useEffect(() => { + // Try to read API selection from session storage to display name/version for breadcrumbs + try { + const apiData = sessionStorage.getItem('selectedApi') + if (apiData) { + const parsed = JSON.parse(apiData) + setApiName(parsed.api_name || '') + setApiVersion(parsed.api_version || '') + } + } catch {} + }, []) + + const loadEndpoints = async () => { + setLoading(true) + setError(null) + try { + if (!apiName || !apiVersion) { + // Fallback: fetch from server using apiId? Backend doesn’t have by-id endpoint list; rely on session. + } + const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/endpoint/${encodeURIComponent(apiName)}/${encodeURIComponent(apiVersion)}` ,{ + credentials: 'include', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}` + } + }) + const data = await response.json() + if (!response.ok) throw new Error(data.error_message || 'Failed to load endpoints') + const list = data.endpoints || [] + setEndpoints(list) + setAllEndpoints(list) + } catch (e:any) { + setError(e?.message || 'Failed to load endpoints') + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (apiName && apiVersion) { + loadEndpoints() + } else { + setLoading(false) + } + }, [apiName, apiVersion]) + + const keyFor = (ep: EndpointItem) => `${ep.endpoint_method}:${ep.endpoint_uri}` + const [expandedKeys, setExpandedKeys] = useState>(new Set()) + + const filtered = useMemo(() => { + const t = searchTerm.trim().toLowerCase() + let list = allEndpoints + if (t) { + list = list.filter(ep => + ep.endpoint_method.toLowerCase().includes(t) || + ep.endpoint_uri.toLowerCase().includes(t) || + (ep.endpoint_description || '').toLowerCase().includes(t) + ) + } + const sorted = [...list].sort((a, b) => { + if (sortBy === 'method') return a.endpoint_method.localeCompare(b.endpoint_method) + if (sortBy === 'uri') return a.endpoint_uri.localeCompare(b.endpoint_uri) + const ac = (a.endpoint_servers || []).length + const bc = (b.endpoint_servers || []).length + return ac - bc + }) + return sorted + }, [allEndpoints, searchTerm, sortBy]) + + const deleteEndpoint = async (ep: EndpointItem) => { + const k = keyFor(ep) + setWorking(prev => ({ ...prev, [k]: true })) + setError(null) + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/endpoint/${encodeURIComponent(ep.endpoint_method)}/${encodeURIComponent(ep.api_name)}/${encodeURIComponent(ep.api_version)}/${encodeURIComponent(ep.endpoint_uri.replace(/^\//, ''))}`, { + method: 'DELETE', + credentials: 'include', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}` + } + }) + const data = await response.json() + if (!response.ok) throw new Error(data.error_message || 'Failed to delete endpoint') + await loadEndpoints() + setSuccess('Endpoint deleted') + setTimeout(() => setSuccess(null), 2000) + } catch (e:any) { + setError(e?.message || 'Failed to delete endpoint') + } finally { + setWorking(prev => ({ ...prev, [k]: false })) + } + } + + const saveEndpointServers = async (ep: EndpointItem, servers: string[]) => { + const k = keyFor(ep) + setWorking(prev => ({ ...prev, [k]: true })) + setError(null) + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/endpoint/${encodeURIComponent(ep.endpoint_method)}/${encodeURIComponent(ep.api_name)}/${encodeURIComponent(ep.api_version)}/${encodeURIComponent(ep.endpoint_uri.replace(/^\//, ''))}`, { + method: 'PUT', + credentials: 'include', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}` + }, + body: JSON.stringify({ endpoint_servers: servers }) + }) + const data = await response.json() + if (!response.ok) throw new Error(data.error_message || 'Failed to update endpoint') + await loadEndpoints() + setSuccess('Endpoint servers updated') + setTimeout(() => setSuccess(null), 2000) + } catch (e:any) { + setError(e?.message || 'Failed to update endpoint') + } finally { + setWorking(prev => ({ ...prev, [k]: false })) + } + } + + const addEndpointServer = async (ep: EndpointItem) => { + const k = keyFor(ep) + const value = (epNewServer[k] || '').trim() + if (!value) return + const next = [...(ep.endpoint_servers || [])] + if (!next.includes(value)) next.push(value) + await saveEndpointServers(ep, next) + setEpNewServer(prev => ({ ...prev, [k]: '' })) + } + + const removeEndpointServer = async (ep: EndpointItem, index: number) => { + const next = (ep.endpoint_servers || []).filter((_, i) => i !== index) + await saveEndpointServers(ep, next) + } + + return ( + +
+
+
+

Endpoints for {apiName}/{apiVersion}

+

Create, edit, and delete endpoints. Precedence: Routing (client-key) → Endpoint servers → API servers.

+
+
+ + + + + Add Endpoint + + Back +
+
+ + {success && ( +
+

{success}

+
+ )} + {error && ( +
+

{error}

+
+ )} + + {/* Search and Filters */} +
+
+
{ e.preventDefault(); }} className="flex-1"> +
+ + + + setSearchTerm(e.target.value)} + /> +
+
+ +
+ + + +
+
+
+ +
+
+ + + + + + + + + + + + + + {loading ? ( + + ) : filtered.length === 0 ? ( + + ) : ( + filtered.map((ep) => { + const k = keyFor(ep) + const saving = !!working[k] + const hasOverride = (ep.endpoint_servers || []).length > 0 + const [expanded, setExpanded] = [undefined as any, undefined as any] + return ( + + setExpandedKeys(prev => { const n = new Set(prev); n.has(k) ? n.delete(k) : n.add(k); return n })}> + + + + + + + + + {expandedKeys.has(k) && ( + + + + )} + + ) + }) + )} + +
MethodURIDescriptionRoutingServers
Loading endpoints...
No endpoints found.
+ + + {ep.endpoint_method} + {ep.endpoint_uri}{ep.endpoint_description || '-'} + + {hasOverride ? 'Endpoint override' : 'API default'} + + + {(ep.endpoint_servers || []).length} +
+
+
+
+
+ { + const on = e.target.checked + if (!on) { + await saveEndpointServers(ep, []) + } + }} + /> + Use endpoint servers +
+
+ +
+
+
Endpoint Servers (override API servers)
+
+ {(ep.endpoint_servers || []).map((srv, idx) => ( +
+ {srv} + +
+ ))} + {(ep.endpoint_servers || []).length === 0 && ( +

No endpoint-specific servers. Using API servers.

+ )} +
+
+ setEpNewServer(prev => ({ ...prev, [k]: e.target.value }))} placeholder="Add server URL" onKeyPress={(e) => e.key === 'Enter' && hasOverride && addEndpointServer(ep)} disabled={!hasOverride} /> + +
+
+
+
+
+
+
+
+
+ ) +} diff --git a/web-client/src/app/apis/[apiId]/page.tsx b/web-client/src/app/apis/[apiId]/page.tsx index f85bf33..febdf4b 100644 --- a/web-client/src/app/apis/[apiId]/page.tsx +++ b/web-client/src/app/apis/[apiId]/page.tsx @@ -22,6 +22,16 @@ interface API { api_path?: string } +interface EndpointItem { + api_name: string + api_version: string + endpoint_method: string + endpoint_uri: string + endpoint_description?: string + endpoint_id?: string + endpoint_servers?: string[] +} + interface UpdateApiData { api_name?: string api_version?: string @@ -52,6 +62,9 @@ const ApiDetailPage = () => { const [newGroup, setNewGroup] = useState('') const [newServer, setNewServer] = useState('') const [newHeader, setNewHeader] = useState('') + const [endpoints, setEndpoints] = useState([]) + const [epNewServer, setEpNewServer] = useState>({}) + const [epSaving, setEpSaving] = useState>({}) const [showDeleteModal, setShowDeleteModal] = useState(false) const [deleteConfirmation, setDeleteConfirmation] = useState('') const [deleting, setDeleting] = useState(false) @@ -87,6 +100,29 @@ const ApiDetailPage = () => { } }, [apiId]) + useEffect(() => { + const loadEndpoints = async () => { + if (!api) return + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/endpoint/${encodeURIComponent(api.api_name)}/${encodeURIComponent(api.api_version)}` ,{ + credentials: 'include', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}` + } + }) + const data = await response.json() + if (!response.ok) throw new Error(data.error_message || 'Failed to load endpoints') + setEndpoints(data.endpoints || []) + } catch (e) { + // endpoints optional; do not hard fail page + console.warn('Failed to load endpoints for API', e) + } + } + loadEndpoints() + }, [api]) + const handleBack = () => { router.push('/apis') } @@ -222,6 +258,60 @@ const ApiDetailPage = () => { })) } + const addEndpointServer = async (ep: EndpointItem) => { + const key = `${ep.endpoint_method}:${ep.endpoint_uri}` + const value = (epNewServer[key] || '').trim() + if (!value) return + const next = [...(ep.endpoint_servers || [])] + if (next.includes(value)) return + next.push(value) + await saveEndpointServers(ep, next) + setEpNewServer(prev => ({ ...prev, [key]: '' })) + } + + const removeEndpointServer = async (ep: EndpointItem, index: number) => { + const next = (ep.endpoint_servers || []).filter((_, i) => i !== index) + await saveEndpointServers(ep, next) + } + + const saveEndpointServers = async (ep: EndpointItem, servers: string[]) => { + if (!api) return + const key = `${ep.endpoint_method}:${ep.endpoint_uri}` + setEpSaving(prev => ({ ...prev, [key]: true })) + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/endpoint/${encodeURIComponent(ep.endpoint_method)}/${encodeURIComponent(ep.api_name)}/${encodeURIComponent(ep.api_version)}/${encodeURIComponent(ep.endpoint_uri.replace(/^\//, ''))}`, { + method: 'PUT', + credentials: 'include', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}` + }, + body: JSON.stringify({ endpoint_servers: servers }) + }) + const data = await response.json() + if (!response.ok) throw new Error(data.error_message || 'Failed to save endpoint servers') + // refresh endpoints + const refreshed = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/endpoint/${encodeURIComponent(api.api_name)}/${encodeURIComponent(api.api_version)}` ,{ + credentials: 'include', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}` + } + }) + const refreshedData = await refreshed.json() + setEndpoints(refreshedData.endpoints || []) + setSuccess('Endpoint servers updated') + setTimeout(() => setSuccess(null), 2000) + } catch (e:any) { + setError(e?.message || 'Failed to update endpoint') + setTimeout(() => setError(null), 3000) + } finally { + setEpSaving(prev => ({ ...prev, [key]: false })) + } + } + const addHeader = () => { if (newHeader.trim() && !editData.api_allowed_headers?.includes(newHeader.trim())) { setEditData(prev => ({ @@ -349,6 +439,12 @@ const ApiDetailPage = () => { Edit API + + + + + Manage Endpoints + + + ))} + {(ep.endpoint_servers || []).length === 0 && ( +

No endpoint-specific servers. Using API servers.

+ )} + +
+ setEpNewServer(prev => ({ ...prev, [key]: e.target.value }))} + className={`input flex-1 ${enabled ? '' : 'opacity-60'}`} + placeholder="Add endpoint server URL" + onKeyPress={(e) => e.key === 'Enter' && enabled && addEndpointServer(ep)} + disabled={!enabled} + /> + +
+ + ) + }) + )} + + + {/* Allowed Headers */}
diff --git a/web-client/src/app/apis/add/page.tsx b/web-client/src/app/apis/add/page.tsx index 0e57b8b..dc0a4cb 100644 --- a/web-client/src/app/apis/add/page.tsx +++ b/web-client/src/app/apis/add/page.tsx @@ -13,10 +13,11 @@ const AddApiPage = () => { api_name: '', api_version: '', api_type: 'REST', - api_path: '', + api_servers: [] as string[], api_description: '', validation_enabled: false }) + const [newServer, setNewServer] = useState('') const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -56,15 +57,27 @@ const AddApiPage = () => { })) } + const addServer = () => { + const value = newServer.trim() + if (!value) return + if (formData.api_servers.includes(value)) return + setFormData(prev => ({ ...prev, api_servers: [...prev.api_servers, value] })) + setNewServer('') + } + + const removeServer = (index: number) => { + setFormData(prev => ({ ...prev, api_servers: prev.api_servers.filter((_, i) => i !== index) })) + } + return (
{/* Page Header */}
-

Add New API

+

Add API

- Create a new API endpoint for your gateway + Define a new API and its default upstream servers

@@ -158,23 +171,37 @@ const AddApiPage = () => {
-
@@ -237,4 +264,4 @@ const AddApiPage = () => { ) } -export default AddApiPage \ No newline at end of file +export default AddApiPage diff --git a/web-client/src/app/apis/page.tsx b/web-client/src/app/apis/page.tsx index 021447a..ae1b972 100644 --- a/web-client/src/app/apis/page.tsx +++ b/web-client/src/app/apis/page.tsx @@ -9,7 +9,7 @@ interface API { api_version: React.ReactNode api_type: React.ReactNode api_description: React.ReactNode - api_path: React.ReactNode + api_servers?: string[] api_id: React.ReactNode api_name: React.ReactNode id: string @@ -38,7 +38,7 @@ const APIsPage = () => { try { setLoading(true) setError(null) - const response = await fetch(`http://localhost:3002/platform/api/all`, { + const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/api/all`, { credentials: 'include', headers: { 'Accept': 'application/json', @@ -73,7 +73,7 @@ const APIsPage = () => { (api.api_name as string)?.toLowerCase().includes(searchTerm.toLowerCase()) || (api.api_version as string)?.toLowerCase().includes(searchTerm.toLowerCase()) || (api.api_type as string)?.toLowerCase().includes(searchTerm.toLowerCase()) || - (api.api_path as string)?.toLowerCase().includes(searchTerm.toLowerCase()) || + ((api.api_servers || []).join(',').toLowerCase().includes(searchTerm.toLowerCase())) || (api.api_description as string)?.toLowerCase().includes(searchTerm.toLowerCase()) ) setApis(filteredApis) @@ -99,6 +99,12 @@ const APIsPage = () => { router.push(`/apis/${api.api_id}`) } + const handleViewEndpoints = (e: React.MouseEvent, api: API) => { + e.stopPropagation() + sessionStorage.setItem('selectedApi', JSON.stringify(api)) + router.push(`/apis/${api.api_id}/endpoints`) + } + return (
@@ -192,10 +198,10 @@ const APIsPage = () => { Name Version - Path + Servers Description Type - + Actions @@ -222,9 +228,14 @@ const APIsPage = () => { {api.api_version} - - {api.api_path} - + {Array.isArray((api as any).api_servers) && (api as any).api_servers.length > 0 ? ( +
+ {(api as any).api_servers.slice(0, 3).join(', ')} + {(api as any).api_servers.length > 3 && ' …'} +
+ ) : ( + None + )}

@@ -241,12 +252,28 @@ const APIsPage = () => { {api.api_type} - - + +

+ + +
))} diff --git a/web-client/src/app/logging/page.tsx b/web-client/src/app/logging/page.tsx index db20fa5..f0cfbc8 100644 --- a/web-client/src/app/logging/page.tsx +++ b/web-client/src/app/logging/page.tsx @@ -49,6 +49,8 @@ interface GroupedLogs { expanded_logs?: Log[] // Store all logs for this request when expanded } +type OverrideKey = string // `${method}|${api_name}|${api_version}|${endpoint_uri}` + export default function LogsPage() { const [logs, setLogs] = useState([]) const [groupedLogs, setGroupedLogs] = useState([]) @@ -80,6 +82,36 @@ export default function LogsPage() { } }) + const [overrideMap, setOverrideMap] = useState>({}) + + const ensureEndpointOverridesLoaded = async (apiPath: string) => { + try { + const parts = apiPath.replace(/^\//, '').split('/') + if (parts.length < 2) return + const api_name = parts[0] + const api_version = parts[1] + const keyPrefix = `${api_name}|${api_version}|` + if (Object.keys(overrideMap).some(k => k.includes(keyPrefix))) return + const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/endpoint/${encodeURIComponent(api_name)}/${encodeURIComponent(api_version)}`, { + credentials: 'include', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}` + } + }) + const data = await response.json() + if (!response.ok) return + const eps: any[] = data.endpoints || [] + const next: Record = {} + eps.forEach(ep => { + const k: OverrideKey = `${ep.endpoint_method}|${ep.api_name}|${ep.api_version}|${ep.endpoint_uri}` + next[k] = Array.isArray(ep.endpoint_servers) && ep.endpoint_servers.length > 0 + }) + setOverrideMap(prev => ({ ...prev, ...next })) + } catch {} + } + const fetchLogs = useCallback(async () => { try { setLoading(true) @@ -259,6 +291,11 @@ export default function LogsPage() { const responseTimeLog = sortedLogs.find(log => log.response_time) const userLog = sortedLogs.find(log => log.user) const endpointLog = sortedLogs.find(log => log.endpoint && log.method) + const apiHintLog = sortedLogs.find(log => log.api)?.api + if (apiHintLog) { + // Best effort: load endpoint override info for this API path + ensureEndpointOverridesLoaded(apiHintLog as string) + } const hasError = sortedLogs.some(log => log.level.toLowerCase() === 'error') return { @@ -658,6 +695,7 @@ export default function LogsPage() { Duration User Endpoint + Routing Method Response Time Status @@ -707,6 +745,26 @@ export default function LogsPage() { {group.endpoint || '-'}

+ + {(() => { + if (!group.endpoint || !group.method) return '-' + const m = (group.endpoint || '').match(/^\/?([^/]+\/v\d+)(?:\/(.*))?$/) + if (!m) return '-' + const apiPath = m[1] + const epUri = '/' + (m[2] || '') + const parts = apiPath.split('/') + if (parts.length < 2) return '-' + const api_name = parts[0] + const api_version = parts[1] + const k: OverrideKey = `${group.method}|${api_name}|${api_version}|${epUri}` + const hasOverride = !!overrideMap[k] + return ( + + {hasOverride ? 'Endpoint override' : 'API default'} + + ) + })()} + {group.method || '-'} @@ -727,7 +785,7 @@ export default function LogsPage() { {/* Expanded logs for this request */} {expandedRequests.has(group.request_id) && ( - +

@@ -804,4 +862,4 @@ export default function LogsPage() {

) -} \ No newline at end of file +} diff --git a/web-client/src/app/routings/add/page.tsx b/web-client/src/app/routings/add/page.tsx index 8e5183d..eba36d8 100644 --- a/web-client/src/app/routings/add/page.tsx +++ b/web-client/src/app/routings/add/page.tsx @@ -147,26 +147,6 @@ const AddRoutingPage = () => {

-
- - handleInputChange('server_index', parseInt(e.target.value))} - disabled={loading} - min="0" - /> -

- Index of the default server in the list (0-based) -

-
-