mirror of
https://github.com/apidoorman/doorman.git
synced 2026-05-03 06:39:42 -05:00
v1.0.0 pre release items
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@@ -18,11 +18,46 @@ A lightweight API gateway built for AI, REST, SOAP, GraphQL, and gRPC APIs. No s
|
||||

|
||||
|
||||
## Features
|
||||
Doorman supports user management, authentication, authorizaiton, dynamic routing, roles, groups, rate limiting, throttling, logging, redis caching, and mongodb. It allows you to manage REST, AI, SOAP, GraphQL, and gRPC APIs.
|
||||
Doorman supports user management, authentication, authorizaiton, dynamic routing, roles, groups, rate limiting, throttling, logging, redis caching, mongodb, and endpoint request payload validation. It allows you to manage REST, AI, SOAP, GraphQL, and gRPC APIs.
|
||||
|
||||
## Coming Enhancements
|
||||
Doorman will soon support transformation, field encryption, and orchestration. More features to be announced.
|
||||
|
||||
## Request Validation
|
||||
Doorman can validate request payloads at the endpoint level before proxying to your upstream service.
|
||||
|
||||
- Scope: REST (JSON/XML), SOAP, GraphQL, and gRPC (JSON payload for gateway).
|
||||
- Configure: Use the platform API to attach a validation schema to an endpoint.
|
||||
- Behavior: If validation fails, the gateway responds with 400 and does not call the upstream.
|
||||
|
||||
Create a validation schema
|
||||
|
||||
```bash
|
||||
curl -X POST -b cookies.txt \
|
||||
-H 'Content-Type: application/json' \
|
||||
http://localhost:5001/platform/endpoint/endpoint/validation \
|
||||
-d '{
|
||||
"endpoint_id": "<endpoint_id>",
|
||||
"validation_enabled": true,
|
||||
"validation_schema": {
|
||||
"validation_schema": {
|
||||
"user.name": {"required": true, "type": "string", "min": 2}
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Schema path examples
|
||||
- REST (JSON): `user.name`, `items[0].price`
|
||||
- SOAP: refer to elements within the SOAP Body operation element (namespaces are stripped). Example: `name` for `<Operation><name>...</name></Operation>`
|
||||
- GraphQL: prefix with operation name. Example: `CreateUser.input.name` for `mutation CreateUser($input: UserInput!) { ... }`
|
||||
- gRPC (gateway JSON): for `{ "message": { "user": { "name": "..." } } }` use `user.name`
|
||||
|
||||
Notes
|
||||
- Enable/disable per-endpoint with `validation_enabled`.
|
||||
- Schemas are cached and enforced in the gateway path before proxying.
|
||||
- On failure, response code is 400 with a concise validation message.
|
||||
|
||||
## Get Started
|
||||
Doorman is simple to setup. In production you should run Redis and (optionally) MongoDB. In memory-only mode, Doorman persists encrypted dumps to disk for quick restarts.
|
||||
|
||||
@@ -124,7 +159,7 @@ Defaults
|
||||
|
||||
## Docker
|
||||
- Compose up: `docker compose up --build`
|
||||
- Services: backend (`:5001`), web (`:3000`), redis (`:6379`)
|
||||
- Services: backend (`:5001`), web (`:3000`)
|
||||
- Secrets: set `JWT_SECRET_KEY`, `TOKEN_ENCRYPTION_KEY`, `MEM_ENCRYPTION_KEY` via env/secret manager (avoid checking into git)
|
||||
- Override backend envs: `docker compose run -e KEY=value backend ...`
|
||||
- Reset volumes/logs: `docker compose down -v`
|
||||
@@ -139,7 +174,7 @@ Smoke checks
|
||||
Production notes
|
||||
- Use Redis in production (`MEM_OR_EXTERNAL=REDIS`) for distributed rate limiting.
|
||||
- In memory-only mode, run a single worker: `THREADS=1`.
|
||||
- Optional: set `LOG_FORMAT=json` for structured logs.
|
||||
- Prefer `LOG_FORMAT=json` for structured logs.
|
||||
|
||||
Production security defaults
|
||||
- Set `CORS_STRICT=true` and explicitly whitelist your origins via `ALLOWED_ORIGINS`.
|
||||
@@ -158,22 +193,6 @@ Quick go-live checklist
|
||||
Optional: run `bash scripts/smoke.sh` (uses `BASE_URL`, `STARTUP_ADMIN_EMAIL`, `STARTUP_ADMIN_PASSWORD`).
|
||||
|
||||
|
||||
## Web UI
|
||||
Utilize the built in web interface for ease of use!
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
## License Information
|
||||
The contents of this repository are property of doorman.so.
|
||||
|
||||
@@ -183,8 +202,6 @@ Review the Apache License 2.0 for valid authorization of use.
|
||||
|
||||
|
||||
## Disclaimer
|
||||
This project is under active development and is not yet ready for production environments.
|
||||
|
||||
Use at your own risk. By using this software, you agree to the [Apache 2.0 License](https://www.apache.org/licenses/LICENSE-2.0) and any annotations found in the source code.
|
||||
|
||||
##
|
||||
|
||||
@@ -7,13 +7,23 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user for better container security
|
||||
RUN groupadd -g 10001 doorman \
|
||||
&& useradd -m -u 10001 -g 10001 -s /usr/sbin/nologin doorman
|
||||
|
||||
COPY backend-services/requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
||||
|
||||
COPY backend-services /app
|
||||
|
||||
# Ensure writable dirs for non-root runtime
|
||||
RUN mkdir -p /app/logs /app/generated \
|
||||
&& chown -R doorman:doorman /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER doorman
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
# Default to run mode (development users may override with CMD/compose)
|
||||
CMD ["python", "doorman.py", "run"]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
@@ -68,6 +68,18 @@ async def app_lifespan(app: FastAPI):
|
||||
# Security: JWT secret must be configured
|
||||
if not os.getenv("JWT_SECRET_KEY"):
|
||||
raise RuntimeError("JWT_SECRET_KEY is not configured. Set it before starting the server.")
|
||||
# Production guard: require secure cookies via HTTPS
|
||||
try:
|
||||
if os.getenv("ENV", "").lower() == "production":
|
||||
https_only = os.getenv("HTTPS_ONLY", "false").lower() == "true"
|
||||
https_enabled = os.getenv("HTTPS_ENABLED", "false").lower() == "true"
|
||||
if not (https_only or https_enabled):
|
||||
raise RuntimeError(
|
||||
"In production (ENV=production), you must enable HTTPS_ONLY or HTTPS_ENABLED to enforce Secure cookies."
|
||||
)
|
||||
except Exception as e:
|
||||
# If misconfigured, fail early with a clear message
|
||||
raise
|
||||
app.state.redis = Redis.from_url(
|
||||
f'redis://{os.getenv("REDIS_HOST")}:{os.getenv("REDIS_PORT")}/{os.getenv("REDIS_DB")}',
|
||||
decode_responses=True
|
||||
@@ -169,7 +181,7 @@ async def app_lifespan(app: FastAPI):
|
||||
doorman = FastAPI(
|
||||
title="doorman",
|
||||
description="A lightweight API gateway for AI, REST, SOAP, GraphQL, gRPC, and WebSocket APIs — fully managed with built-in RESTful APIs for configuration and control. This is your application's gateway to the world.",
|
||||
version="0.0.1",
|
||||
version="1.0.0",
|
||||
lifespan=app_lifespan,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
syntax = 'proto3'; message Dummy {}
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, Response
|
||||
@@ -98,6 +98,7 @@ async def authorization(request: Request):
|
||||
_domain = os.getenv("COOKIE_DOMAIN", None)
|
||||
host = request.url.hostname or (request.client.host if request.client else None)
|
||||
safe_domain = _domain if (_domain and host and (host == _domain or host.endswith(_domain))) else None
|
||||
# codeql[py/insecure-cookie] Secure flag is tied to HTTPS env; dev uses HTTP on localhost for ease of testing
|
||||
response.set_cookie(
|
||||
key="csrf_token",
|
||||
value=csrf_token,
|
||||
@@ -108,6 +109,7 @@ async def authorization(request: Request):
|
||||
domain=safe_domain,
|
||||
max_age=1800
|
||||
)
|
||||
# codeql[py/insecure-cookie] Secure flag is tied to HTTPS env; dev uses HTTP on localhost for ease of testing
|
||||
response.set_cookie(
|
||||
key="access_token_cookie",
|
||||
value=access_token,
|
||||
@@ -381,6 +383,7 @@ async def extended_authorization(request: Request):
|
||||
_domain = os.getenv("COOKIE_DOMAIN", None)
|
||||
host = request.url.hostname or (request.client.host if request.client else None)
|
||||
safe_domain = _domain if (_domain and host and (host == _domain or host.endswith(_domain))) else None
|
||||
# codeql[py/insecure-cookie] Secure flag is tied to HTTPS env; dev uses HTTP on localhost for ease of testing
|
||||
response.set_cookie(
|
||||
key="csrf_token",
|
||||
value=csrf_token,
|
||||
@@ -391,6 +394,7 @@ async def extended_authorization(request: Request):
|
||||
domain=safe_domain,
|
||||
max_age=604800
|
||||
)
|
||||
# codeql[py/insecure-cookie] Secure flag is tied to HTTPS env; dev uses HTTP on localhost for ease of testing
|
||||
response.set_cookie(
|
||||
key="access_token_cookie",
|
||||
value=refresh_token,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, UploadFile, File, HTTPException
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from http.client import HTTPException
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from models.create_endpoint_validation_model import CreateEndpointValidationModel
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -118,21 +118,25 @@ class GatewayService:
|
||||
logger.info(f"{request_id} | REST gateway to: {url}")
|
||||
if api.get('api_authorization_field_swap'):
|
||||
headers[api.get('Authorization')] = headers.get(api.get('api_authorization_field_swap'))
|
||||
if api.get('validation_enabled'):
|
||||
try:
|
||||
if content_type in ["application/json", "text/json"]:
|
||||
# Endpoint-level payload validation (when configured)
|
||||
try:
|
||||
endpoint_doc = await api_util.get_endpoint(api, method, '/' + endpoint_uri.lstrip('/'))
|
||||
endpoint_id = endpoint_doc.get('endpoint_id') if endpoint_doc else None
|
||||
if endpoint_id:
|
||||
if "JSON" in content_type:
|
||||
body = await request.json()
|
||||
await validation_util.validate_rest_request(api.get('api_id'), body)
|
||||
elif content_type in ["application/xml", "text/xml"]:
|
||||
await validation_util.validate_rest_request(endpoint_id, body)
|
||||
elif "XML" in content_type:
|
||||
body = (await request.body()).decode("utf-8")
|
||||
await validation_util.validate_soap_request(api.get('api_id'), body)
|
||||
except Exception as e:
|
||||
return GatewayService.error_response(request_id, 'GTW011', str(e), status=400)
|
||||
await validation_util.validate_soap_request(endpoint_id, body)
|
||||
except Exception as e:
|
||||
logger.error(f"{request_id} | Validation error: {e}")
|
||||
return GatewayService.error_response(request_id, 'GTW011', str(e), status=400)
|
||||
async with httpx.AsyncClient(timeout=GatewayService.timeout) as client:
|
||||
if method == "GET":
|
||||
http_response = await client.get(url, params=query_params, headers=headers)
|
||||
elif method in ("POST", "PUT", "DELETE"):
|
||||
if content_type in ["application/json", "text/json"]:
|
||||
if "JSON" in content_type:
|
||||
body = await request.json()
|
||||
http_response = await getattr(client, method.lower())(
|
||||
url, json=body, params=query_params, headers=headers
|
||||
@@ -232,11 +236,15 @@ class GatewayService:
|
||||
envelope = (await request.body()).decode("utf-8")
|
||||
if api.get('api_authorization_field_swap'):
|
||||
headers[api.get('Authorization')] = headers.get(api.get('api_authorization_field_swap'))
|
||||
if api.get('validation_enabled'):
|
||||
try:
|
||||
await validation_util.validate_soap_request(api.get('api_id'), envelope)
|
||||
except Exception as e:
|
||||
return GatewayService.error_response(request_id, 'GTW011', str(e), status=400)
|
||||
# Endpoint-level payload validation (when configured)
|
||||
try:
|
||||
endpoint_doc = await api_util.get_endpoint(api, 'POST', '/' + endpoint_uri.lstrip('/'))
|
||||
endpoint_id = endpoint_doc.get('endpoint_id') if endpoint_doc else None
|
||||
if endpoint_id:
|
||||
await validation_util.validate_soap_request(endpoint_id, envelope)
|
||||
except Exception as e:
|
||||
logger.error(f"{request_id} | Validation error: {e}")
|
||||
return GatewayService.error_response(request_id, 'GTW011', str(e), status=400)
|
||||
async with httpx.AsyncClient(timeout=GatewayService.timeout) as client:
|
||||
http_response = await client.post(url, content=envelope, params=query_params, headers=headers)
|
||||
response_content = http_response.text
|
||||
@@ -316,11 +324,14 @@ class GatewayService:
|
||||
body = await request.json()
|
||||
query = body.get('query')
|
||||
variables = body.get('variables', {})
|
||||
if api.get('validation_enabled'):
|
||||
try:
|
||||
await validation_util.validate_graphql_request(api.get('api_id'), query, variables)
|
||||
except Exception as e:
|
||||
return GatewayService.error_response(request_id, 'GTW011', str(e), status=400)
|
||||
# Endpoint-level payload validation (when configured)
|
||||
try:
|
||||
endpoint_doc = await api_util.get_endpoint(api, 'POST', '/graphql')
|
||||
endpoint_id = endpoint_doc.get('endpoint_id') if endpoint_doc else None
|
||||
if endpoint_id:
|
||||
await validation_util.validate_graphql_request(endpoint_id, query, variables)
|
||||
except Exception as e:
|
||||
return GatewayService.error_response(request_id, 'GTW011', str(e), status=400)
|
||||
transport = AIOHTTPTransport(url=url, headers=headers)
|
||||
async with Client(transport=transport, fetch_schema_from_transport=True) as session:
|
||||
try:
|
||||
@@ -371,6 +382,27 @@ class GatewayService:
|
||||
api_path = f"{api_name}/{api_version}"
|
||||
logger.info(f"{request_id} | Processing gRPC request for API: {api_path}")
|
||||
logger.info(f"{request_id} | Processing gRPC request for API: {api_path}")
|
||||
# Parse body early and run validation before proto checks
|
||||
try:
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
logger.error(f"{request_id} | Invalid request body format")
|
||||
return GatewayService.error_response(request_id, 'GTW011', 'Invalid request body format', status=400)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"{request_id} | Invalid JSON in request body")
|
||||
return GatewayService.error_response(request_id, 'GTW011', 'Invalid JSON in request body', status=400)
|
||||
# Load API and validate payload against endpoint schema if configured
|
||||
api = doorman_cache.get_cache('api_cache', api_path)
|
||||
if not api:
|
||||
api = await api_util.get_api(None, api_path)
|
||||
if api:
|
||||
try:
|
||||
endpoint_doc = await api_util.get_endpoint(api, 'POST', '/grpc')
|
||||
endpoint_id = endpoint_doc.get('endpoint_id') if endpoint_doc else None
|
||||
if endpoint_id:
|
||||
await validation_util.validate_grpc_request(endpoint_id, body.get('message'))
|
||||
except Exception as e:
|
||||
return GatewayService.error_response(request_id, 'GTW011', str(e), status=400)
|
||||
proto_filename = f"{api_name}_{api_version}.proto"
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
proto_dir = os.path.join(project_root, 'proto')
|
||||
@@ -415,6 +447,14 @@ class GatewayService:
|
||||
logger.error(f"{request_id} | Missing message in request body")
|
||||
return GatewayService.error_response(request_id, 'GTW011', 'Missing message in request body', status=400)
|
||||
proto_filename = f"{api_name}_{api_version}.proto"
|
||||
# Endpoint-level payload validation (when configured)
|
||||
try:
|
||||
endpoint_doc = await api_util.get_endpoint(api, 'POST', '/grpc')
|
||||
endpoint_id = endpoint_doc.get('endpoint_id') if endpoint_doc else None
|
||||
if endpoint_id:
|
||||
await validation_util.validate_grpc_request(endpoint_id, body.get('message'))
|
||||
except Exception as e:
|
||||
return GatewayService.error_response(request_id, 'GTW011', str(e), status=400)
|
||||
proto_path = os.path.join(proto_dir, proto_filename)
|
||||
if not os.path.exists(proto_path):
|
||||
logger.error(f"{request_id} | Proto file not found: {proto_path}")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from models.response_model import ResponseModel
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -464,4 +464,4 @@ class LoggingService:
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error applying filters: {str(e)}", exc_info=True)
|
||||
return False
|
||||
return False
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from models.response_model import ResponseModel
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from models.response_model import ResponseModel
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
@@ -347,4 +347,4 @@ class UserService:
|
||||
return ResponseModel(
|
||||
status_code=200,
|
||||
response={'users': users}
|
||||
).dict()
|
||||
).dict()
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rest_payload_validation_blocks_bad_request(authed_client):
|
||||
# Setup API and endpoint
|
||||
from conftest import create_api, create_endpoint, subscribe_self # type: ignore
|
||||
api_name = "valrest"
|
||||
version = "v1"
|
||||
await create_api(authed_client, api_name, version)
|
||||
await create_endpoint(authed_client, api_name, version, "POST", "/do")
|
||||
await subscribe_self(authed_client, api_name, version)
|
||||
|
||||
# Fetch endpoint id
|
||||
g = await authed_client.get(f"/platform/endpoint/POST/{api_name}/{version}/do")
|
||||
assert g.status_code == 200
|
||||
eid = g.json().get("endpoint_id") or g.json().get("response", {}).get("endpoint_id")
|
||||
assert eid
|
||||
|
||||
# Create validation requiring user.name length >= 2
|
||||
schema = {
|
||||
"validation_schema": {
|
||||
"user.name": {"required": True, "type": "string", "min": 2, "max": 50}
|
||||
}
|
||||
}
|
||||
cv = await authed_client.post(
|
||||
"/platform/endpoint/endpoint/validation",
|
||||
json={"endpoint_id": eid, "validation_enabled": True, "validation_schema": schema},
|
||||
)
|
||||
assert cv.status_code in (200, 201, 400)
|
||||
|
||||
# Call gateway with invalid payload (name too short); should fail before hitting upstream
|
||||
r = await authed_client.post(
|
||||
f"/api/rest/{api_name}/{version}/do",
|
||||
json={"user": {"name": "A"}},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graphql_payload_validation_blocks_bad_request(authed_client):
|
||||
from conftest import create_api, create_endpoint, subscribe_self # type: ignore
|
||||
api_name = "valgql"
|
||||
version = "v1"
|
||||
await create_api(authed_client, api_name, version)
|
||||
# GraphQL endpoint is POST /graphql
|
||||
await create_endpoint(authed_client, api_name, version, "POST", "/graphql")
|
||||
await subscribe_self(authed_client, api_name, version)
|
||||
|
||||
# Fetch endpoint id
|
||||
g = await authed_client.get(f"/platform/endpoint/POST/{api_name}/{version}/graphql")
|
||||
assert g.status_code == 200
|
||||
eid = g.json().get("endpoint_id") or g.json().get("response", {}).get("endpoint_id")
|
||||
assert eid
|
||||
|
||||
schema = {
|
||||
"validation_schema": {
|
||||
# Scope to operation name CreateUser
|
||||
"CreateUser.input.name": {"required": True, "type": "string", "min": 2, "max": 50}
|
||||
}
|
||||
}
|
||||
cv = await authed_client.post(
|
||||
"/platform/endpoint/endpoint/validation",
|
||||
json={"endpoint_id": eid, "validation_enabled": True, "validation_schema": schema},
|
||||
)
|
||||
assert cv.status_code in (200, 201, 400)
|
||||
|
||||
query = "mutation CreateUser($input: UserInput!){ createUser(input: $input){ id } }"
|
||||
variables = {"input": {"name": "A"}}
|
||||
r = await authed_client.post(
|
||||
f"/api/graphql/{api_name}",
|
||||
headers={"X-API-Version": version, "Content-Type": "application/json"},
|
||||
json={"query": query, "variables": variables},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soap_payload_validation_blocks_bad_request(authed_client):
|
||||
from conftest import create_api, create_endpoint, subscribe_self # type: ignore
|
||||
api_name = "valsoap"
|
||||
version = "v1"
|
||||
await create_api(authed_client, api_name, version)
|
||||
await create_endpoint(authed_client, api_name, version, "POST", "/call")
|
||||
await subscribe_self(authed_client, api_name, version)
|
||||
|
||||
g = await authed_client.get(f"/platform/endpoint/POST/{api_name}/{version}/call")
|
||||
assert g.status_code == 200
|
||||
eid = g.json().get("endpoint_id") or g.json().get("response", {}).get("endpoint_id")
|
||||
assert eid
|
||||
|
||||
schema = {
|
||||
"validation_schema": {
|
||||
"Request.name": {"required": True, "type": "string", "min": 2, "max": 50}
|
||||
}
|
||||
}
|
||||
cv = await authed_client.post(
|
||||
"/platform/endpoint/endpoint/validation",
|
||||
json={"endpoint_id": eid, "validation_enabled": True, "validation_schema": schema},
|
||||
)
|
||||
assert cv.status_code in (200, 201, 400)
|
||||
|
||||
envelope = (
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
"<soapenv:Envelope xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
"<soapenv:Body>"
|
||||
"<Request><name>A</name></Request>"
|
||||
"</soapenv:Body>"
|
||||
"</soapenv:Envelope>"
|
||||
)
|
||||
r = await authed_client.post(
|
||||
f"/api/soap/{api_name}/{version}/call",
|
||||
headers={"Content-Type": "application/xml"},
|
||||
content=envelope,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grpc_payload_validation_blocks_bad_request(authed_client):
|
||||
from conftest import create_api, create_endpoint, subscribe_self # type: ignore
|
||||
api_name = "valgrpc"
|
||||
version = "v1"
|
||||
await create_api(authed_client, api_name, version)
|
||||
await create_endpoint(authed_client, api_name, version, "POST", "/grpc")
|
||||
await subscribe_self(authed_client, api_name, version)
|
||||
|
||||
g = await authed_client.get(f"/platform/endpoint/POST/{api_name}/{version}/grpc")
|
||||
assert g.status_code == 200
|
||||
eid = g.json().get("endpoint_id") or g.json().get("response", {}).get("endpoint_id")
|
||||
assert eid
|
||||
|
||||
schema = {
|
||||
"validation_schema": {
|
||||
"user.name": {"required": True, "type": "string", "min": 2, "max": 50}
|
||||
}
|
||||
}
|
||||
cv = await authed_client.post(
|
||||
"/platform/endpoint/endpoint/validation",
|
||||
json={"endpoint_id": eid, "validation_enabled": True, "validation_schema": schema},
|
||||
)
|
||||
assert cv.status_code in (200, 201, 400)
|
||||
|
||||
payload = {"method": "Service.Method", "message": {"user": {"name": "A"}}}
|
||||
r = await authed_client.post(
|
||||
f"/api/grpc/{api_name}",
|
||||
headers={"X-API-Version": version, "Content-Type": "application/json"},
|
||||
json=payload,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rest_payload_validation_allows_good_request(monkeypatch, authed_client):
|
||||
from conftest import create_api, create_endpoint, subscribe_self # type: ignore
|
||||
api_name = "okrest"
|
||||
version = "v1"
|
||||
await create_api(authed_client, api_name, version)
|
||||
await create_endpoint(authed_client, api_name, version, "POST", "/do")
|
||||
await subscribe_self(authed_client, api_name, version)
|
||||
|
||||
g = await authed_client.get(f"/platform/endpoint/POST/{api_name}/{version}/do")
|
||||
eid = g.json().get("endpoint_id") or g.json().get("response", {}).get("endpoint_id")
|
||||
schema = {"validation_schema": {"user.name": {"required": True, "type": "string", "min": 2}}}
|
||||
await authed_client.post(
|
||||
"/platform/endpoint/endpoint/validation",
|
||||
json={"endpoint_id": eid, "validation_enabled": True, "validation_schema": schema},
|
||||
)
|
||||
|
||||
class FakeResp:
|
||||
def __init__(self):
|
||||
self.status_code = 200
|
||||
self.headers = {"Content-Type": "application/json"}
|
||||
self._json = {"ok": True}
|
||||
self.text = "{}"
|
||||
|
||||
def json(self):
|
||||
return self._json
|
||||
|
||||
class FakeClient:
|
||||
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):
|
||||
return FakeResp()
|
||||
|
||||
import services.gateway_service as gw
|
||||
monkeypatch.setattr(gw.httpx, "AsyncClient", lambda timeout: FakeClient())
|
||||
|
||||
r = await authed_client.post(f"/api/rest/{api_name}/{version}/do", json={"user": {"name": "Ab"}})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soap_payload_validation_allows_good_request(monkeypatch, authed_client):
|
||||
from conftest import create_api, create_endpoint, subscribe_self # type: ignore
|
||||
api_name = "oksoap"
|
||||
version = "v1"
|
||||
await create_api(authed_client, api_name, version)
|
||||
await create_endpoint(authed_client, api_name, version, "POST", "/call")
|
||||
await subscribe_self(authed_client, api_name, version)
|
||||
|
||||
g = await authed_client.get(f"/platform/endpoint/POST/{api_name}/{version}/call")
|
||||
eid = g.json().get("endpoint_id") or g.json().get("response", {}).get("endpoint_id")
|
||||
schema = {"validation_schema": {"name": {"required": True, "type": "string", "min": 2}}}
|
||||
await authed_client.post(
|
||||
"/platform/endpoint/endpoint/validation",
|
||||
json={"endpoint_id": eid, "validation_enabled": True, "validation_schema": schema},
|
||||
)
|
||||
|
||||
class FakeResp:
|
||||
def __init__(self):
|
||||
self.status_code = 200
|
||||
self.headers = {"Content-Type": "text/xml"}
|
||||
self.text = "<ok/>"
|
||||
|
||||
class FakeClient:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def post(self, url, content=None, params=None, headers=None):
|
||||
return FakeResp()
|
||||
|
||||
import services.gateway_service as gw
|
||||
monkeypatch.setattr(gw.httpx, "AsyncClient", lambda timeout: FakeClient())
|
||||
|
||||
envelope = (
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
"<soapenv:Envelope xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\">"
|
||||
"<soapenv:Body>"
|
||||
"<Request><name>Ab</name></Request>"
|
||||
"</soapenv:Body>"
|
||||
"</soapenv:Envelope>"
|
||||
)
|
||||
r = await authed_client.post(
|
||||
f"/api/soap/{api_name}/{version}/call",
|
||||
headers={"Content-Type": "application/xml"},
|
||||
content=envelope,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graphql_payload_validation_allows_good_request(monkeypatch, authed_client):
|
||||
from conftest import create_api, create_endpoint, subscribe_self # type: ignore
|
||||
api_name = "okgql"
|
||||
version = "v1"
|
||||
await create_api(authed_client, api_name, version)
|
||||
await create_endpoint(authed_client, api_name, version, "POST", "/graphql")
|
||||
await subscribe_self(authed_client, api_name, version)
|
||||
|
||||
g = await authed_client.get(f"/platform/endpoint/POST/{api_name}/{version}/graphql")
|
||||
eid = g.json().get("endpoint_id") or g.json().get("response", {}).get("endpoint_id")
|
||||
schema = {"validation_schema": {"CreateUser.input.name": {"required": True, "type": "string", "min": 2}}}
|
||||
await authed_client.post(
|
||||
"/platform/endpoint/endpoint/validation",
|
||||
json={"endpoint_id": eid, "validation_enabled": True, "validation_schema": schema},
|
||||
)
|
||||
|
||||
class FakeSession:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def execute(self, *args, **kwargs):
|
||||
return {"ok": True}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, transport=None, fetch_schema_from_transport=False):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return FakeSession()
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
import services.gateway_service as gw
|
||||
monkeypatch.setattr(gw, "Client", FakeClient)
|
||||
|
||||
query = "mutation CreateUser($input: UserInput!){ createUser(input: $input){ id } }"
|
||||
variables = {"input": {"name": "Ab"}}
|
||||
r = await authed_client.post(
|
||||
f"/api/graphql/{api_name}",
|
||||
headers={"X-API-Version": version, "Content-Type": "application/json"},
|
||||
json={"query": query, "variables": variables},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grpc_payload_validation_allows_good_request_progresses(monkeypatch, authed_client, tmp_path):
|
||||
from conftest import create_api, create_endpoint, subscribe_self # type: ignore
|
||||
api_name = "okgrpc"
|
||||
version = "v1"
|
||||
await create_api(authed_client, api_name, version)
|
||||
await create_endpoint(authed_client, api_name, version, "POST", "/grpc")
|
||||
await subscribe_self(authed_client, api_name, version)
|
||||
|
||||
g = await authed_client.get(f"/platform/endpoint/POST/{api_name}/{version}/grpc")
|
||||
eid = g.json().get("endpoint_id") or g.json().get("response", {}).get("endpoint_id")
|
||||
schema = {"validation_schema": {"user.name": {"required": True, "type": "string", "min": 2}}}
|
||||
await authed_client.post(
|
||||
"/platform/endpoint/endpoint/validation",
|
||||
json={"endpoint_id": eid, "validation_enabled": True, "validation_schema": schema},
|
||||
)
|
||||
|
||||
# Ensure proto existence check passes to assert it's not a 400 from validation
|
||||
import os as _os
|
||||
import services.gateway_service as gw
|
||||
project_root = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))
|
||||
proto_dir = _os.path.join(project_root, "proto")
|
||||
_os.makedirs(proto_dir, exist_ok=True)
|
||||
with open(_os.path.join(proto_dir, f"{api_name}_{version}.proto"), "w") as f:
|
||||
f.write("syntax = 'proto3'; message Dummy {}")
|
||||
|
||||
# Monkeypatch importlib to simulate generated modules missing; request should result in non-400
|
||||
def fake_import(name):
|
||||
raise ImportError("fake")
|
||||
|
||||
monkeypatch.setattr(gw.importlib, "import_module", lambda n: (_ for _ in ()).throw(ImportError("fake")))
|
||||
|
||||
payload = {"method": "Service.Method", "message": {"user": {"name": "Ab"}}}
|
||||
r = await authed_client.post(
|
||||
f"/api/grpc/{api_name}",
|
||||
headers={"X-API-Version": version, "Content-Type": "application/json"},
|
||||
json=payload,
|
||||
)
|
||||
# Validation passed; accept non-400 outcome (likely 404 due to missing generated modules)
|
||||
assert r.status_code in (404, 500)
|
||||
@@ -0,0 +1,102 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_security_headers_and_hsts(monkeypatch, client):
|
||||
# HSTS disabled by default
|
||||
r = await client.get("/platform/monitor/liveness")
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert r.headers.get("X-Frame-Options") == "DENY"
|
||||
assert r.headers.get("Referrer-Policy") == "no-referrer"
|
||||
assert "Strict-Transport-Security" not in r.headers
|
||||
|
||||
# Enable HTTPS_ONLY to trigger HSTS
|
||||
monkeypatch.setenv("HTTPS_ONLY", "true")
|
||||
r = await client.get("/platform/monitor/liveness")
|
||||
assert r.status_code == 200
|
||||
assert "Strict-Transport-Security" in r.headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_body_size_limit_returns_413(monkeypatch, client):
|
||||
# Patch runtime constant used by middleware
|
||||
import doorman as appmod
|
||||
monkeypatch.setattr(appmod, "MAX_BODY_SIZE", 10, raising=False)
|
||||
payload = "x" * 100
|
||||
r = await client.post("/platform/authorization", content=payload, headers={"Content-Type": "text/plain"})
|
||||
assert r.status_code == 413
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strict_response_envelope(monkeypatch, authed_client):
|
||||
monkeypatch.setenv("STRICT_RESPONSE_ENVELOPE", "true")
|
||||
# Use an endpoint that returns ResponseModel envelope
|
||||
r = await authed_client.get("/platform/user/admin")
|
||||
assert r.status_code == 200
|
||||
# Envelope with status_code should be present
|
||||
data = r.json()
|
||||
assert isinstance(data, dict)
|
||||
assert "status_code" in data and data["status_code"] == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metrics_recording_snapshot(authed_client):
|
||||
# Create an API + endpoint and subscribe to generate an /api/* request
|
||||
from conftest import create_api, create_endpoint, subscribe_self # type: ignore
|
||||
name, ver = "metapi", "v1"
|
||||
await create_api(authed_client, name, ver)
|
||||
await create_endpoint(authed_client, name, ver, "GET", "/status")
|
||||
await subscribe_self(authed_client, name, ver)
|
||||
|
||||
# Make gateway request
|
||||
r1 = await authed_client.get(f"/api/rest/{name}/{ver}/status")
|
||||
# It may fail upstream; we only care it traversed /api/* path
|
||||
assert r1.status_code in (200, 400, 401, 404, 429, 500)
|
||||
|
||||
# Fetch metrics
|
||||
m = await authed_client.get("/platform/monitor/metrics")
|
||||
assert m.status_code == 200
|
||||
body = m.json()
|
||||
series = body.get("response", {}).get("series") or body.get("series")
|
||||
assert isinstance(series, list)
|
||||
assert body.get("response", {}).get("total_requests") or body.get("total_requests") >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cors_strict_allows_localhost(monkeypatch, client):
|
||||
# Simulate wildcard origins + credentials with strict CORS enabled
|
||||
monkeypatch.setenv("CORS_STRICT", "true")
|
||||
monkeypatch.setenv("ALLOWED_ORIGINS", "*")
|
||||
monkeypatch.setenv("ALLOW_CREDENTIALS", "true")
|
||||
r = await client.get("/platform/monitor/liveness", headers={"Origin": "http://localhost:3000"})
|
||||
# CORS middleware should echo allowed origin
|
||||
assert r.headers.get("access-control-allow-origin") in ("http://localhost:3000", "http://localhost")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_dump_and_restore(tmp_path, monkeypatch):
|
||||
# Ensure MEM mode and point dump path to tmp
|
||||
monkeypatch.setenv("MEM_OR_EXTERNAL", "MEM")
|
||||
dump_dir = tmp_path / "dumps"
|
||||
dump_dir.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("MEM_DUMP_PATH", str(dump_dir / "memory_dump.bin"))
|
||||
|
||||
# Import utilities after env set
|
||||
from utils.memory_dump_util import dump_memory_to_file, find_latest_dump_path, restore_memory_from_file
|
||||
from utils.database import database
|
||||
|
||||
# Seed some in-memory data
|
||||
database.db.users.insert_one({"username": "tmp", "email": "t@t.t", "password": "x"})
|
||||
path = dump_memory_to_file(None)
|
||||
assert os.path.exists(path)
|
||||
latest = find_latest_dump_path(str(dump_dir))
|
||||
assert latest and latest.endswith(".bin")
|
||||
# Wipe and restore
|
||||
database.db.users._docs.clear()
|
||||
assert database.db.users.count_documents({}) == 0
|
||||
info = restore_memory_from_file(latest)
|
||||
assert database.db.users.count_documents({}) >= 1
|
||||
@@ -1,14 +1,15 @@
|
||||
"""
|
||||
The contents of this file are property of doorman.so
|
||||
Review the Apache License 2.0 for valid authorization of use
|
||||
See https://github.com/pypeople-dev/doorman for more information
|
||||
See https://github.com/apidoorman/doorman for more information
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
from fastapi import HTTPException
|
||||
from models.field_validation_model import FieldValidation
|
||||
from models.validation_schema_model import ValidationSchema
|
||||
from utils.cache_manager_util import cache_manager
|
||||
from utils.doorman_cache_util import doorman_cache
|
||||
from utils.database import endpoint_validation_collection
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
@@ -48,10 +49,35 @@ class ValidationUtil:
|
||||
self.custom_validators[name] = validator
|
||||
|
||||
async def get_validation_schema(self, endpoint_id: str) -> Optional[ValidationSchema]:
|
||||
schema_data = await cache_manager.get(f"validation_schema:{endpoint_id}")
|
||||
if not schema_data:
|
||||
"""Return the ValidationSchema for an endpoint_id if configured.
|
||||
|
||||
Looks up the in-memory cache first, then falls back to the DB collection.
|
||||
Accepts both shapes:
|
||||
- { 'validation_schema': {<paths>: FieldValidation} }
|
||||
- {<paths>: FieldValidation}
|
||||
"""
|
||||
validation_doc = doorman_cache.get_cache('endpoint_validation_cache', endpoint_id)
|
||||
if not validation_doc:
|
||||
validation_doc = endpoint_validation_collection.find_one({'endpoint_id': endpoint_id})
|
||||
if validation_doc:
|
||||
try:
|
||||
vdoc = dict(validation_doc)
|
||||
vdoc.pop('_id', None)
|
||||
doorman_cache.set_cache('endpoint_validation_cache', endpoint_id, vdoc)
|
||||
validation_doc = vdoc
|
||||
except Exception:
|
||||
pass
|
||||
if not validation_doc:
|
||||
return None
|
||||
schema = ValidationSchema(validation_schema=json.loads(schema_data))
|
||||
if not bool(validation_doc.get('validation_enabled')):
|
||||
return None
|
||||
raw = validation_doc.get('validation_schema')
|
||||
if not raw:
|
||||
return None
|
||||
mapping = raw.get('validation_schema') if isinstance(raw, dict) and 'validation_schema' in raw else raw
|
||||
if not isinstance(mapping, dict):
|
||||
return None
|
||||
schema = ValidationSchema(validation_schema=mapping)
|
||||
self._validate_schema_paths(schema.validation_schema)
|
||||
return schema
|
||||
|
||||
@@ -74,97 +100,94 @@ class ValidationUtil:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _validate_string(self, value: Any, validation: FieldValidation) -> None:
|
||||
def _validate_string(self, value: Any, validation: FieldValidation, path: str) -> None:
|
||||
if not isinstance(value, str):
|
||||
raise ValidationError(f"Expected string, got {type(value).__name__}", validation.field_path)
|
||||
raise ValidationError(f"Expected string, got {type(value).__name__}", path)
|
||||
if validation.min is not None and len(value) < validation.min:
|
||||
raise ValidationError(f"String length must be at least {validation.min}", validation.field_path)
|
||||
raise ValidationError(f"String length must be at least {validation.min}", path)
|
||||
if validation.max is not None and len(value) > validation.max:
|
||||
raise ValidationError(f"String length must be at most {validation.max}", validation.field_path)
|
||||
raise ValidationError(f"String length must be at most {validation.max}", path)
|
||||
if validation.pattern and not re.match(validation.pattern, value):
|
||||
raise ValidationError(f"String does not match pattern {validation.pattern}", validation.field_path)
|
||||
raise ValidationError(f"String does not match pattern {validation.pattern}", path)
|
||||
if validation.format and validation.format in self.format_validators:
|
||||
self.format_validators[validation.format](value, validation)
|
||||
self.format_validators[validation.format](value, validation, path)
|
||||
|
||||
def _validate_number(self, value: Any, validation: FieldValidation) -> None:
|
||||
def _validate_number(self, value: Any, validation: FieldValidation, path: str) -> None:
|
||||
if not isinstance(value, (int, float)):
|
||||
raise ValidationError(f"Expected number, got {type(value).__name__}", validation.field_path)
|
||||
raise ValidationError(f"Expected number, got {type(value).__name__}", path)
|
||||
if validation.min is not None and value < validation.min:
|
||||
raise ValidationError(f"Value must be at least {validation.min}", validation.field_path)
|
||||
raise ValidationError(f"Value must be at least {validation.min}", path)
|
||||
if validation.max is not None and value > validation.max:
|
||||
raise ValidationError(f"Value must be at most {validation.max}", validation.field_path)
|
||||
raise ValidationError(f"Value must be at most {validation.max}", path)
|
||||
|
||||
def _validate_boolean(self, value: Any, validation: FieldValidation) -> None:
|
||||
def _validate_boolean(self, value: Any, validation: FieldValidation, path: str) -> None:
|
||||
if not isinstance(value, bool):
|
||||
raise ValidationError(f"Expected boolean, got {type(value).__name__}", validation.field_path)
|
||||
raise ValidationError(f"Expected boolean, got {type(value).__name__}", path)
|
||||
|
||||
def _validate_array(self, value: Any, validation: FieldValidation) -> None:
|
||||
def _validate_array(self, value: Any, validation: FieldValidation, path: str) -> None:
|
||||
if not isinstance(value, list):
|
||||
raise ValidationError(f"Expected array, got {type(value).__name__}", validation.field_path)
|
||||
raise ValidationError(f"Expected array, got {type(value).__name__}", path)
|
||||
if validation.min is not None and len(value) < validation.min:
|
||||
raise ValidationError(f"Array must have at least {validation.min} items", validation.field_path)
|
||||
raise ValidationError(f"Array must have at least {validation.min} items", path)
|
||||
if validation.max is not None and len(value) > validation.max:
|
||||
raise ValidationError(f"Array must have at most {validation.max} items", validation.field_path)
|
||||
raise ValidationError(f"Array must have at most {validation.max} items", path)
|
||||
if validation.array_items:
|
||||
for i, item in enumerate(value):
|
||||
try:
|
||||
self._validate_value(item, validation.array_items, f"{validation.field_path}[{i}]")
|
||||
except ValidationError as e:
|
||||
raise ValidationError(e.message, e.field_path)
|
||||
self._validate_value(item, validation.array_items, f"{path}[{i}]")
|
||||
|
||||
def _validate_object(self, value: Any, validation: FieldValidation) -> None:
|
||||
def _validate_object(self, value: Any, validation: FieldValidation, path: str) -> None:
|
||||
if not isinstance(value, dict):
|
||||
raise ValidationError(f"Expected object, got {type(value).__name__}", validation.field_path)
|
||||
raise ValidationError(f"Expected object, got {type(value).__name__}", path)
|
||||
if validation.nested_schema:
|
||||
for field_path, field_validation in validation.nested_schema.items():
|
||||
if field_validation.required and field_path not in value:
|
||||
raise ValidationError(f"Required field {field_path} is missing", validation.field_path)
|
||||
raise ValidationError(f"Required field {field_path} is missing", path)
|
||||
if field_path in value:
|
||||
try:
|
||||
self._validate_value(value[field_path], field_validation, f"{validation.field_path}.{field_path}")
|
||||
except ValidationError as e:
|
||||
raise ValidationError(e.message, e.field_path)
|
||||
self._validate_value(value[field_path], field_validation, f"{path}.{field_path}")
|
||||
|
||||
def _validate_value(self, value: Any, validation: FieldValidation, field_path: str) -> None:
|
||||
validation.field_path = field_path
|
||||
if validation.required and value is None:
|
||||
raise ValidationError("Field is required", field_path)
|
||||
if value is None:
|
||||
return
|
||||
if validation.type in self.type_validators:
|
||||
self.type_validators[validation.type](value, validation)
|
||||
self.type_validators[validation.type](value, validation, field_path)
|
||||
if validation.enum and value not in validation.enum:
|
||||
raise ValidationError(f"Value must be one of {validation.enum}", field_path)
|
||||
if validation.custom_validator and validation.custom_validator in self.custom_validators:
|
||||
self.custom_validators[validation.custom_validator](value, validation)
|
||||
try:
|
||||
self.custom_validators[validation.custom_validator](value, validation)
|
||||
except ValidationError as e:
|
||||
# ensure path is preserved
|
||||
raise ValidationError(e.message, field_path)
|
||||
|
||||
def _validate_email(self, value: str, validation: FieldValidation) -> None:
|
||||
def _validate_email(self, value: str, validation: FieldValidation, path: str) -> None:
|
||||
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
if not re.match(email_pattern, value):
|
||||
raise ValidationError("Invalid email format", validation.field_path)
|
||||
raise ValidationError("Invalid email format", path)
|
||||
|
||||
def _validate_url(self, value: str, validation: FieldValidation) -> None:
|
||||
def _validate_url(self, value: str, validation: FieldValidation, path: str) -> None:
|
||||
url_pattern = r'^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$'
|
||||
if not re.match(url_pattern, value):
|
||||
raise ValidationError("Invalid URL format", validation.field_path)
|
||||
raise ValidationError("Invalid URL format", path)
|
||||
|
||||
def _validate_date(self, value: str, validation: FieldValidation) -> None:
|
||||
def _validate_date(self, value: str, validation: FieldValidation, path: str) -> None:
|
||||
try:
|
||||
datetime.strptime(value, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise ValidationError("Invalid date format (YYYY-MM-DD)", validation.field_path)
|
||||
raise ValidationError("Invalid date format (YYYY-MM-DD)", path)
|
||||
|
||||
def _validate_datetime(self, value: str, validation: FieldValidation) -> None:
|
||||
def _validate_datetime(self, value: str, validation: FieldValidation, path: str) -> None:
|
||||
try:
|
||||
datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
raise ValidationError("Invalid datetime format (ISO 8601)", validation.field_path)
|
||||
raise ValidationError("Invalid datetime format (ISO 8601)", path)
|
||||
|
||||
def _validate_uuid(self, value: str, validation: FieldValidation) -> None:
|
||||
def _validate_uuid(self, value: str, validation: FieldValidation, path: str) -> None:
|
||||
try:
|
||||
uuid.UUID(value)
|
||||
except ValueError:
|
||||
raise ValidationError("Invalid UUID format", validation.field_path)
|
||||
raise ValidationError("Invalid UUID format", path)
|
||||
|
||||
async def validate_rest_request(self, endpoint_id: str, request_data: Dict[str, Any]) -> None:
|
||||
schema = await self.get_validation_schema(endpoint_id)
|
||||
@@ -175,6 +198,8 @@ class ValidationUtil:
|
||||
value = self._get_nested_value(request_data, field_path)
|
||||
self._validate_value(value, validation, field_path)
|
||||
except ValidationError as e:
|
||||
import logging
|
||||
logging.getLogger("doorman.gateway").error(f"Validation failed for {field_path}: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
async def validate_soap_request(self, endpoint_id: str, soap_envelope: str) -> None:
|
||||
@@ -207,7 +232,7 @@ class ValidationUtil:
|
||||
schema = await self.get_validation_schema(endpoint_id)
|
||||
if not schema:
|
||||
return
|
||||
request_data = self._protobuf_to_dict(request)
|
||||
request_data = request if isinstance(request, dict) else self._protobuf_to_dict(request)
|
||||
for field_path, validation in schema.validation_schema.items():
|
||||
try:
|
||||
value = self._get_nested_value(request_data, field_path)
|
||||
@@ -258,13 +283,19 @@ class ValidationUtil:
|
||||
return None
|
||||
return current
|
||||
|
||||
def _strip_ns(self, tag: str) -> str:
|
||||
if '}' in tag:
|
||||
return tag.split('}', 1)[1]
|
||||
return tag
|
||||
|
||||
def _xml_to_dict(self, element: ET.Element) -> Dict[str, Any]:
|
||||
result = {}
|
||||
for child in element:
|
||||
key = self._strip_ns(child.tag)
|
||||
if len(child) > 0:
|
||||
result[child.tag] = self._xml_to_dict(child)
|
||||
result[key] = self._xml_to_dict(child)
|
||||
else:
|
||||
result[child.tag] = child.text
|
||||
result[key] = child.text
|
||||
return result
|
||||
|
||||
def _protobuf_to_dict(self, message: Any) -> Dict[str, Any]:
|
||||
@@ -283,20 +314,11 @@ class ValidationUtil:
|
||||
async def _get_wsdl_client(self, endpoint_id: str) -> Optional[Client]:
|
||||
if endpoint_id in self.wsdl_clients:
|
||||
return self.wsdl_clients[endpoint_id]
|
||||
wsdl_url = await cache_manager.get(f"wsdl_url:{endpoint_id}")
|
||||
if not wsdl_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
settings = Settings(strict=True)
|
||||
client = Client(wsdl_url, settings=settings)
|
||||
self.wsdl_clients[endpoint_id] = client
|
||||
return client
|
||||
except Exception as e:
|
||||
return None
|
||||
# WSDL URL caching not wired yet; skip if not configured externally.
|
||||
return None
|
||||
|
||||
def _get_soap_operation(self, element_tag: str) -> Optional[str]:
|
||||
match = re.search(r'\{[^}]+\}([^}]+)$', element_tag)
|
||||
return match.group(1) if match else None
|
||||
|
||||
validation_util = ValidationUtil()
|
||||
validation_util = ValidationUtil()
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
version: '3.9'
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
@@ -18,11 +13,6 @@ services:
|
||||
ALLOWED_ORIGINS: http://localhost:3000
|
||||
HTTPS_ONLY: "false"
|
||||
HTTPS_ENABLED: "false"
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
REDIS_DB: 0
|
||||
depends_on:
|
||||
- redis
|
||||
ports:
|
||||
- "5001:5001"
|
||||
volumes:
|
||||
@@ -44,4 +34,3 @@ services:
|
||||
volumes:
|
||||
backend_generated:
|
||||
backend_logs:
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// 4) default: http://localhost:3002
|
||||
const fromGlobal = typeof window !== 'undefined' ? (window as any).__SERVER_URL : undefined
|
||||
const fromStorage = typeof window !== 'undefined' ? window.localStorage.getItem('SERVER_URL') : null
|
||||
export const SERVER_URL = (fromGlobal || fromStorage || process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002') as string
|
||||
export const SERVER_URL = (fromGlobal || fromStorage || process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:5001') as string
|
||||
// Helpful in dev: log the resolved base URL once in the browser
|
||||
if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'production') {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
Reference in New Issue
Block a user