v1.0.0 pre release items

This commit is contained in:
seniorswe
2025-09-19 21:50:46 -04:00
committed by seniorswe
parent e3fc971248
commit da5508a194
30 changed files with 674 additions and 139 deletions
+39 -22
View File
@@ -6,7 +6,7 @@
![api-gateway](https://img.shields.io/badge/API-Gateway-blue)
![Python](https://img.shields.io/badge/Python-3.10%2B-blue)
![License](https://img.shields.io/badge/license-Apache%202.0-green)
![Release](https://img.shields.io/badge/release-pre--release-orange)
![Release](https://img.shields.io/badge/release-v1.0.0-brightgreen)
![Last Commit](https://img.shields.io/github/last-commit/apidoorman/doorman)
![GitHub issues](https://img.shields.io/github/issues/apidoorman/doorman)
@@ -18,11 +18,46 @@ A lightweight API gateway built for AI, REST, SOAP, GraphQL, and gRPC APIs. No s
![Example](https://i.ibb.co/9dkgPLP/dashboardpage.png)
## 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!
![Create APIs](https://i.ibb.co/j9vQJGL0/apispage.png)
![Custom Routings](https://i.ibb.co/D0CCYGJ/routespage.png)
![Edit Roles](https://i.ibb.co/jk2F7vk8/rolespage.png)
![Add Groups](https://i.ibb.co/1G3jMPvG/groupspage.png)
![User Management](https://i.ibb.co/3y2xVTv5/userspage.png)
![Advanced Logs](https://i.ibb.co/BKvVhW4B/logspage.png)
## 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.
##
+11 -1
View File
@@ -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"]
+14 -2
View File
@@ -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,
)
+1
View File
@@ -0,0 +1 @@
syntax = 'proto3'; message Dummy {}
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
+60 -20
View File
@@ -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 -1
View File
@@ -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
+2 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
+2 -2
View File
@@ -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
+82 -60
View File
@@ -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()
-11
View File
@@ -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:
+1 -1
View File
@@ -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