forked from aegis/pyserveX
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd0b381195 | ||
|
|
81ac5c4d29 | ||
|
|
881028c1e6 | ||
|
|
8f5b9a5cd1 | ||
|
|
c04ab283a6 | ||
|
|
d03ade18c5 | ||
|
|
129785706c | ||
|
|
3b59994fc9 | ||
|
|
7662a7924a | ||
|
|
cec6e927a7 | ||
|
|
80544d5b95 | ||
|
|
b4f63c6804 | ||
|
|
59d6ae2fd2 | ||
|
|
edaccb59bb | ||
|
|
3454801be7 | ||
|
|
bb2c3aa357 | ||
|
|
6761b791c3 | ||
|
|
fb87445cbd | ||
|
|
5d863bc97c | ||
|
|
6c50a35aa3 | ||
|
|
3e2704f870 | ||
|
|
40e39efa37 | ||
|
|
0d0d1aec80 |
+18
-1
@@ -13,4 +13,21 @@ static/*
|
||||
.coverage
|
||||
|
||||
docs/
|
||||
dist/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Cython generated files
|
||||
*.c
|
||||
*.so
|
||||
*.pyd
|
||||
*.html
|
||||
*.egg-info/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Go binaries
|
||||
go/bin
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: help install build clean test lint format run dev-install dev-deps check release-patch release-minor release-major pipeline-check
|
||||
.PHONY: help install build build-cython clean test lint format run dev-install dev-deps check release-patch release-minor release-major pipeline-check benchmark
|
||||
|
||||
PYTHON = python3
|
||||
POETRY = poetry
|
||||
@@ -21,12 +21,14 @@ help:
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "install-package" "Installing package locally"
|
||||
@echo ""
|
||||
@echo "$(YELLOW)Building:$(NC)"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "build" "Building package"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "build" "Building package (with Cython)"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "build-cython" "Building Cython extensions only"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "clean" "Cleaning temporary files"
|
||||
@echo ""
|
||||
@echo "$(YELLOW)Testing:$(NC)"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "test" "Running tests"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "test-cov" "Running tests with coverage"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "benchmark" "Running performance benchmarks"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "lint" "Checking code with linters"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "format" "Formatting code"
|
||||
@printf " $(YELLOW)%-20s$(CYAN) %s$(NC)\n" "check" "Lint and test"
|
||||
@@ -75,18 +77,27 @@ dev-deps:
|
||||
@echo "$(GREEN)Installing additional tools...$(NC)"
|
||||
$(POETRY) add --group dev pytest pytest-cov black isort mypy flake8
|
||||
|
||||
build: clean
|
||||
build: clean build-cython
|
||||
@echo "$(GREEN)Building package...$(NC)"
|
||||
$(POETRY) build
|
||||
|
||||
build-cython:
|
||||
@echo "$(GREEN)Building Cython extensions...$(NC)"
|
||||
$(POETRY) run python scripts/build_cython.py build_ext --inplace || echo "$(YELLOW)Cython build skipped (optional)$(NC)"
|
||||
|
||||
clean:
|
||||
@echo "$(GREEN)Cleaning temporary files...$(NC)"
|
||||
rm -rf dist/
|
||||
rm -rf build/
|
||||
rm -rf *.egg-info/
|
||||
find . -type d -name __pycache__ -exec rm -rf {} +
|
||||
find . -type f -name "*.pyc" -delete
|
||||
find . -type f -name "*.pyo" -delete
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
find . -type f -name "*.pyo" -delete 2>/dev/null || true
|
||||
@# Cython artifacts
|
||||
find $(PACKAGE_NAME) -type f -name "*.c" -delete 2>/dev/null || true
|
||||
find $(PACKAGE_NAME) -type f -name "*.so" -delete 2>/dev/null || true
|
||||
find $(PACKAGE_NAME) -type f -name "*.pyd" -delete 2>/dev/null || true
|
||||
find $(PACKAGE_NAME) -type f -name "*.html" -delete 2>/dev/null || true
|
||||
|
||||
test:
|
||||
@echo "$(GREEN)Running tests...$(NC)"
|
||||
@@ -98,16 +109,20 @@ test-cov:
|
||||
|
||||
lint:
|
||||
@echo "$(GREEN)Checking code with linters...$(NC)"
|
||||
$(POETRY) run flake8 $(PACKAGE_NAME)/
|
||||
$(POETRY) run flake8 $(PACKAGE_NAME)/ --exclude='*.pyx,*.pxd'
|
||||
$(POETRY) run mypy $(PACKAGE_NAME)/
|
||||
|
||||
format:
|
||||
@echo "$(GREEN)Formatting code...$(NC)"
|
||||
$(POETRY) run black $(PACKAGE_NAME)/
|
||||
$(POETRY) run isort $(PACKAGE_NAME)/
|
||||
$(POETRY) run black $(PACKAGE_NAME)/ --exclude='\.pyx$$'
|
||||
$(POETRY) run isort $(PACKAGE_NAME)/ --skip-glob='*.pyx'
|
||||
|
||||
check: lint test
|
||||
|
||||
benchmark: build-cython
|
||||
@echo "$(GREEN)Running benchmarks...$(NC)"
|
||||
$(POETRY) run python benchmarks/bench_path_matcher.py
|
||||
|
||||
run:
|
||||
@echo "$(GREEN)Starting server in development mode...$(NC)"
|
||||
$(POETRY) run python run.py --debug
|
||||
|
||||
@@ -1,145 +1,97 @@
|
||||
# PyServe
|
||||
|
||||
PyServe is a modern, async HTTP server written in Python. Originally created for educational purposes, it has evolved into a powerful tool for rapid prototyping and serving web applications with unique features like AI-generated content.
|
||||
Python application orchestrator and HTTP server. Runs multiple ASGI/WSGI applications through a single entry point with process isolation, health monitoring, and auto-restart.
|
||||
|
||||
<img src="https://raw.githubusercontent.com/ShiftyX1/PyServe/refs/heads/master/images/logo.png" alt="isolated" width="150"/>
|
||||
<img src="https://raw.githubusercontent.com/ShiftyX1/PyServe/refs/heads/master/images/logo.png" alt="PyServe Logo" width="150"/>
|
||||
|
||||
[More on web page](https://pyserve.org/)
|
||||
Website: [pyserve.org](https://pyserve.org) · Documentation: [docs.pyserve.org](https://docs.pyserve.org)
|
||||
|
||||
## Project Overview
|
||||
## Overview
|
||||
|
||||
PyServe v0.6.0 introduces a completely refactored architecture with modern async/await syntax and new exciting features like **Vibe-Serving** - AI-powered dynamic content generation.
|
||||
PyServe manages multiple Python web applications (FastAPI, Flask, Django, etc.) as isolated subprocesses behind a single gateway. Each app runs on its own port with independent lifecycle, health checks, and automatic restarts on failure.
|
||||
|
||||
### Key Features:
|
||||
```
|
||||
PyServe Gateway (:8000)
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
▼ ▼ ▼
|
||||
FastAPI Flask Starlette
|
||||
:9001 :9002 :9003
|
||||
/api/* /admin/* /ws/*
|
||||
```
|
||||
|
||||
- **Async HTTP Server** - Built with Python's asyncio for high performance
|
||||
- **Advanced Configuration System V2** - Powerful extensible configuration with full backward compatibility
|
||||
- **Regex Routing & SPA Support** - nginx-style routing patterns with Single Page Application fallback
|
||||
- **Static File Serving** - Efficient serving with correct MIME types
|
||||
- **Template System** - Dynamic content generation
|
||||
- **Vibe-Serving Mode** - AI-generated content using language models (OpenAI, Claude, etc.)
|
||||
- **Reverse Proxy** - Forward requests to backend services with advanced routing
|
||||
- **SSL/HTTPS Support** - Secure connections with certificate configuration
|
||||
- **Modular Extensions** - Plugin-like architecture for security, caching, monitoring
|
||||
- **Beautiful Logging** - Colored terminal output with file rotation
|
||||
- **Error Handling** - Styled error pages and graceful fallbacks
|
||||
- **CLI Interface** - Command-line interface for easy deployment and configuration
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12 or higher
|
||||
- Poetry (recommended) or pip
|
||||
|
||||
### Installation
|
||||
|
||||
#### Via Poetry (рекомендуется)
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ShiftyX1/PyServe.git
|
||||
cd PyServe
|
||||
make init # Initialize project
|
||||
make init
|
||||
```
|
||||
|
||||
#### Или установка пакета
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# local install
|
||||
make install-package
|
||||
```yaml
|
||||
# config.yaml
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8000
|
||||
|
||||
# after installing project you can use command pyserve
|
||||
pyserve --help
|
||||
extensions:
|
||||
- type: process_orchestration
|
||||
config:
|
||||
apps:
|
||||
- name: api
|
||||
path: /api
|
||||
app_path: myapp.api:app
|
||||
|
||||
- name: admin
|
||||
path: /admin
|
||||
app_path: myapp.admin:app
|
||||
```
|
||||
|
||||
### Running the Server
|
||||
|
||||
#### Using Makefile (recommended)
|
||||
|
||||
```bash
|
||||
# start in development mode
|
||||
make run
|
||||
|
||||
# start in production mode
|
||||
make run-prod
|
||||
|
||||
# show all available commands
|
||||
make help
|
||||
pyserve -c config.yaml
|
||||
```
|
||||
|
||||
#### Using CLI directly
|
||||
Requests to `/api/*` are proxied to the api process, `/admin/*` to admin.
|
||||
|
||||
## Process Orchestration
|
||||
|
||||
The main case of using PyServe is orchestration of python web applications. Each application runs as a separate uvicorn process on a dynamically or manually allocated port (9000-9999 by default). PyServe proxies requests to the appropriate process based on URL path.
|
||||
|
||||
For each application you can configure the number of workers, environment variables, health check endpoint path, and auto-restart parameters. If a process crashes or stops responding to health checks, PyServe automatically restarts it with exponential backoff.
|
||||
|
||||
WSGI applications (Flask, Django) are supported through automatic wrapping — just specify `app_type: wsgi`.
|
||||
|
||||
## In-Process Mounting
|
||||
|
||||
For simpler cases when process isolation is not needed, applications can be mounted directly into the PyServe process via the `asgi` extension. This is lighter and faster, but all applications share one process.
|
||||
|
||||
## Static Files & Routing
|
||||
|
||||
PyServe can serve static files with nginx-like routing: regex patterns, SPA fallback for frontend applications, custom caching headers. Routes are processed in priority order — exact match, then regex, then default.
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
Requests can be proxied to external backends. Useful for integration with legacy services or microservices in other languages.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
# after installing package
|
||||
pyserve
|
||||
|
||||
# or with Poetry
|
||||
poetry run pyserve
|
||||
|
||||
# or legacy (for backward compatibility)
|
||||
python run.py
|
||||
```
|
||||
|
||||
#### CLI options
|
||||
|
||||
```bash
|
||||
# help
|
||||
pyserve --help
|
||||
|
||||
# path to config
|
||||
pyserve -c /path/to/config.yaml
|
||||
|
||||
# rewrite host and port
|
||||
pyserve -c config.yaml
|
||||
pyserve --host 0.0.0.0 --port 9000
|
||||
|
||||
# debug mode
|
||||
pyserve --debug
|
||||
|
||||
# show version
|
||||
pyserve --version
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Makefile Commands
|
||||
|
||||
```bash
|
||||
make help # Show help for commands
|
||||
make install # Install dependencies
|
||||
make dev-install # Install development dependencies
|
||||
make build # Build the package
|
||||
make test # Run tests
|
||||
make test-cov # Tests with code coverage
|
||||
make lint # Check code with linters
|
||||
make format # Format code
|
||||
make clean # Clean up temporary files
|
||||
make version # Show version
|
||||
make publish-test # Publish to Test PyPI
|
||||
make publish # Publish to PyPI
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
pyserveX/
|
||||
├── pyserve/ # Main package
|
||||
│ ├── __init__.py
|
||||
│ ├── cli.py # CLI interface
|
||||
│ ├── server.py # Main server module
|
||||
│ ├── config.py # Configuration system
|
||||
│ ├── routing.py # Routing
|
||||
│ ├── extensions.py # Extensions
|
||||
│ └── logging_utils.py
|
||||
├── tests/ # Tests
|
||||
├── static/ # Static files
|
||||
├── templates/ # Templates
|
||||
├── logs/ # Logs
|
||||
├── Makefile # Automation tasks
|
||||
├── pyproject.toml # Project configuration
|
||||
├── config.yaml # Server configuration
|
||||
└── run.py # Entry point (backward compatibility)
|
||||
make test # run tests
|
||||
make lint # linting
|
||||
make format # formatting
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is distributed under the MIT license.
|
||||
[MIT License](./LICENSE)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Benchmark script for path_matcher performance comparison.
|
||||
|
||||
Compares:
|
||||
- Pure Python implementation
|
||||
- Cython implementation (if available)
|
||||
- Original MountedApp from asgi_mount.py
|
||||
|
||||
Usage:
|
||||
python benchmarks/bench_path_matcher.py
|
||||
"""
|
||||
|
||||
import time
|
||||
import statistics
|
||||
from typing import Callable, List, Tuple
|
||||
|
||||
from pyserve._path_matcher_py import (
|
||||
FastMountedPath as PyFastMountedPath,
|
||||
FastMountManager as PyFastMountManager,
|
||||
path_matches_prefix as py_path_matches_prefix,
|
||||
)
|
||||
|
||||
try:
|
||||
from pyserve._path_matcher import (
|
||||
FastMountedPath as CyFastMountedPath,
|
||||
FastMountManager as CyFastMountManager,
|
||||
path_matches_prefix as cy_path_matches_prefix,
|
||||
)
|
||||
CYTHON_AVAILABLE = True
|
||||
except ImportError:
|
||||
CYTHON_AVAILABLE = False
|
||||
print("Cython module not compiled. Run: python setup_cython.py build_ext --inplace\n")
|
||||
|
||||
from pyserve.asgi_mount import MountedApp
|
||||
|
||||
|
||||
def benchmark(func: Callable, iterations: int = 100000) -> Tuple[float, float]:
|
||||
times = []
|
||||
|
||||
for _ in range(1000):
|
||||
func()
|
||||
|
||||
for _ in range(iterations):
|
||||
start = time.perf_counter_ns()
|
||||
func()
|
||||
end = time.perf_counter_ns()
|
||||
times.append(end - start)
|
||||
|
||||
return statistics.mean(times), statistics.stdev(times)
|
||||
|
||||
|
||||
def format_time(ns: float) -> str:
|
||||
if ns < 1000:
|
||||
return f"{ns:.1f} ns"
|
||||
elif ns < 1_000_000:
|
||||
return f"{ns/1000:.2f} µs"
|
||||
else:
|
||||
return f"{ns/1_000_000:.2f} ms"
|
||||
|
||||
|
||||
def run_benchmarks():
|
||||
print("=" * 70)
|
||||
print("PATH MATCHER BENCHMARK")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Test paths
|
||||
mount_path = "/api/v1"
|
||||
test_paths = [
|
||||
"/api/v1/users/123/posts", # Matching - long
|
||||
"/api/v1", # Matching - exact
|
||||
"/api/v2/users", # Not matching - similar prefix
|
||||
"/other/path", # Not matching - completely different
|
||||
]
|
||||
|
||||
iterations = 100000
|
||||
|
||||
# =========================================================================
|
||||
# Benchmark 1: Single path matching
|
||||
# =========================================================================
|
||||
print("BENCHMARK 1: Single Path Matching")
|
||||
print("-" * 70)
|
||||
print(f" Mount path: {mount_path}")
|
||||
print(f" Iterations: {iterations:,}")
|
||||
print()
|
||||
|
||||
results = {}
|
||||
|
||||
# Original MountedApp
|
||||
original_mount = MountedApp(mount_path, app=None, name="test") # type: ignore
|
||||
|
||||
for test_path in test_paths:
|
||||
print(f" Test path: {test_path}")
|
||||
|
||||
# Original
|
||||
mean, std = benchmark(lambda: original_mount.matches(test_path), iterations)
|
||||
results[("Original", test_path)] = mean
|
||||
print(f" Original MountedApp: {format_time(mean):>12} ± {format_time(std)}")
|
||||
|
||||
# Pure Python
|
||||
py_mount = PyFastMountedPath(mount_path)
|
||||
mean, std = benchmark(lambda: py_mount.matches(test_path), iterations)
|
||||
results[("Python", test_path)] = mean
|
||||
print(f" Pure Python: {format_time(mean):>12} ± {format_time(std)}")
|
||||
|
||||
# Cython (if available)
|
||||
if CYTHON_AVAILABLE:
|
||||
cy_mount = CyFastMountedPath(mount_path)
|
||||
mean, std = benchmark(lambda: cy_mount.matches(test_path), iterations)
|
||||
results[("Cython", test_path)] = mean
|
||||
print(f" Cython: {format_time(mean):>12} ± {format_time(std)}")
|
||||
|
||||
print()
|
||||
|
||||
# =========================================================================
|
||||
# Benchmark 2: Mount Manager lookup
|
||||
# =========================================================================
|
||||
print()
|
||||
print("BENCHMARK 2: Mount Manager Lookup (10 mounts)")
|
||||
print("-" * 70)
|
||||
|
||||
# Setup managers with 10 mounts
|
||||
mount_paths = [f"/api/v{i}" for i in range(10)]
|
||||
|
||||
py_manager = PyFastMountManager()
|
||||
for p in mount_paths:
|
||||
py_manager.add_mount(PyFastMountedPath(p, name=p))
|
||||
|
||||
if CYTHON_AVAILABLE:
|
||||
cy_manager = CyFastMountManager()
|
||||
for p in mount_paths:
|
||||
cy_manager.add_mount(CyFastMountedPath(p, name=p))
|
||||
|
||||
test_lookups = [
|
||||
"/api/v5/users/123", # Middle mount
|
||||
"/api/v0/items", # First mount (longest)
|
||||
"/api/v9/data", # Last mount
|
||||
"/other/not/found", # No match
|
||||
]
|
||||
|
||||
for test_path in test_lookups:
|
||||
print(f" Lookup path: {test_path}")
|
||||
|
||||
# Pure Python
|
||||
mean, std = benchmark(lambda: py_manager.get_mount(test_path), iterations)
|
||||
print(f" Pure Python: {format_time(mean):>12} ± {format_time(std)}")
|
||||
|
||||
# Cython
|
||||
if CYTHON_AVAILABLE:
|
||||
mean, std = benchmark(lambda: cy_manager.get_mount(test_path), iterations)
|
||||
print(f" Cython: {format_time(mean):>12} ± {format_time(std)}")
|
||||
|
||||
print()
|
||||
|
||||
# =========================================================================
|
||||
# Benchmark 3: Combined match + modify
|
||||
# =========================================================================
|
||||
print()
|
||||
print("BENCHMARK 3: Combined Match + Modify Path")
|
||||
print("-" * 70)
|
||||
|
||||
from pyserve._path_matcher_py import match_and_modify_path as py_match_modify
|
||||
if CYTHON_AVAILABLE:
|
||||
from pyserve._path_matcher import match_and_modify_path as cy_match_modify
|
||||
|
||||
test_path = "/api/v1/users/123/posts"
|
||||
print(f" Test path: {test_path}")
|
||||
print(f" Mount path: {mount_path}")
|
||||
print()
|
||||
|
||||
# Original (separate calls)
|
||||
def original_match_modify():
|
||||
if original_mount.matches(test_path):
|
||||
return original_mount.get_modified_path(test_path)
|
||||
return None
|
||||
|
||||
mean, std = benchmark(original_match_modify, iterations)
|
||||
print(f" Original (2 calls): {format_time(mean):>12} ± {format_time(std)}")
|
||||
|
||||
# Pure Python combined
|
||||
mean, std = benchmark(lambda: py_match_modify(test_path, mount_path), iterations)
|
||||
print(f" Pure Python (combined): {format_time(mean):>12} ± {format_time(std)}")
|
||||
|
||||
# Cython combined
|
||||
if CYTHON_AVAILABLE:
|
||||
mean, std = benchmark(lambda: cy_match_modify(test_path, mount_path), iterations)
|
||||
print(f" Cython (combined): {format_time(mean):>12} ± {format_time(std)}")
|
||||
|
||||
# =========================================================================
|
||||
# Summary
|
||||
# =========================================================================
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("SUMMARY")
|
||||
print("=" * 70)
|
||||
|
||||
if CYTHON_AVAILABLE:
|
||||
print("Cython module is available and was benchmarked")
|
||||
else:
|
||||
print("Cython module not available - only Pure Python was benchmarked")
|
||||
print(" To build Cython module:")
|
||||
print(" 1. Install Cython: pip install cython")
|
||||
print(" 2. Build: python setup_cython.py build_ext --inplace")
|
||||
|
||||
print()
|
||||
print("The optimized path matcher provides:")
|
||||
print(" - Pre-computed path length and trailing slash")
|
||||
print(" - Boundary-aware prefix matching (prevents /api matching /api-v2)")
|
||||
print(" - Combined match+modify operation to reduce function calls")
|
||||
print(" - Longest-prefix-first ordering in MountManager")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_benchmarks()
|
||||
@@ -1,32 +0,0 @@
|
||||
# PyServe configuration for serving documentation
|
||||
# Usage: pyserve -c config.docs.yaml
|
||||
|
||||
http:
|
||||
static_dir: ./docs
|
||||
templates_dir: ./templates
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8000
|
||||
backlog: 5
|
||||
default_root: false
|
||||
|
||||
ssl:
|
||||
enabled: false
|
||||
|
||||
logging:
|
||||
level: INFO
|
||||
console_output: true
|
||||
|
||||
extensions:
|
||||
- type: routing
|
||||
config:
|
||||
regex_locations:
|
||||
"~*\\.(css)$":
|
||||
root: "./docs"
|
||||
cache_control: "public, max-age=3600"
|
||||
|
||||
"__default__":
|
||||
root: "./docs"
|
||||
index_file: "index.html"
|
||||
cache_control: "no-cache"
|
||||
@@ -0,0 +1 @@
|
||||
"""Example applications package."""
|
||||
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Example custom ASGI application for PyServe ASGI mounting.
|
||||
|
||||
This demonstrates how to create a raw ASGI application without
|
||||
any framework - similar to Python's http.server but async.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Callable, Awaitable, Optional
|
||||
import json
|
||||
|
||||
Scope = Dict[str, Any]
|
||||
Receive = Callable[[], Awaitable[Dict[str, Any]]]
|
||||
Send = Callable[[Dict[str, Any]], Awaitable[None]]
|
||||
|
||||
|
||||
class SimpleASGIApp:
|
||||
def __init__(self):
|
||||
self.routes: Dict[str, Callable] = {}
|
||||
self._setup_routes()
|
||||
|
||||
def _setup_routes(self) -> None:
|
||||
self.routes = {
|
||||
"/": self._handle_root,
|
||||
"/health": self._handle_health,
|
||||
"/echo": self._handle_echo,
|
||||
"/info": self._handle_info,
|
||||
"/headers": self._handle_headers,
|
||||
}
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
return
|
||||
|
||||
path = scope.get("path", "/")
|
||||
method = scope.get("method", "GET")
|
||||
|
||||
handler = self.routes.get(path)
|
||||
|
||||
if handler is None:
|
||||
if path.startswith("/echo/"):
|
||||
handler = self._handle_echo_path
|
||||
else:
|
||||
await self._send_response(
|
||||
send,
|
||||
status=404,
|
||||
body={"error": "Not found", "path": path}
|
||||
)
|
||||
return
|
||||
|
||||
await handler(scope, receive, send)
|
||||
|
||||
async def _handle_root(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
await self._send_response(
|
||||
send,
|
||||
body={
|
||||
"message": "Welcome to Custom ASGI App mounted in PyServe!",
|
||||
"description": "This is a raw ASGI application without any framework",
|
||||
"endpoints": list(self.routes.keys()) + ["/echo/{message}"],
|
||||
}
|
||||
)
|
||||
|
||||
async def _handle_health(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
await self._send_response(
|
||||
send,
|
||||
body={"status": "healthy", "app": "custom-asgi"}
|
||||
)
|
||||
|
||||
async def _handle_echo(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
method = scope.get("method", "GET")
|
||||
|
||||
if method == "POST":
|
||||
body = await self._read_body(receive)
|
||||
await self._send_response(
|
||||
send,
|
||||
body={"echo": body.decode("utf-8") if body else ""}
|
||||
)
|
||||
else:
|
||||
await self._send_response(
|
||||
send,
|
||||
body={"message": "Send a POST request to echo data"}
|
||||
)
|
||||
|
||||
async def _handle_echo_path(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
path = scope.get("path", "")
|
||||
message = path.replace("/echo/", "", 1)
|
||||
await self._send_response(
|
||||
send,
|
||||
body={"echo": message}
|
||||
)
|
||||
|
||||
async def _handle_info(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
await self._send_response(
|
||||
send,
|
||||
body={
|
||||
"method": scope.get("method"),
|
||||
"path": scope.get("path"),
|
||||
"query_string": scope.get("query_string", b"").decode("utf-8"),
|
||||
"root_path": scope.get("root_path", ""),
|
||||
"scheme": scope.get("scheme", "http"),
|
||||
"server": list(scope.get("server", ())),
|
||||
"asgi": scope.get("asgi", {}),
|
||||
}
|
||||
)
|
||||
|
||||
async def _handle_headers(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
headers = {}
|
||||
for name, value in scope.get("headers", []):
|
||||
headers[name.decode("utf-8")] = value.decode("utf-8")
|
||||
|
||||
await self._send_response(
|
||||
send,
|
||||
body={"headers": headers}
|
||||
)
|
||||
|
||||
async def _read_body(self, receive: Receive) -> bytes:
|
||||
body = b""
|
||||
more_body = True
|
||||
while more_body:
|
||||
message = await receive()
|
||||
body += message.get("body", b"")
|
||||
more_body = message.get("more_body", False)
|
||||
return body
|
||||
|
||||
async def _send_response(
|
||||
self,
|
||||
send: Send,
|
||||
status: int = 200,
|
||||
body: Any = None,
|
||||
content_type: str = "application/json",
|
||||
headers: Optional[List[tuple]] = None,
|
||||
) -> None:
|
||||
response_headers = [
|
||||
(b"content-type", content_type.encode("utf-8")),
|
||||
]
|
||||
|
||||
if headers:
|
||||
response_headers.extend(headers)
|
||||
|
||||
if body is not None:
|
||||
if content_type == "application/json":
|
||||
body_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
elif isinstance(body, bytes):
|
||||
body_bytes = body
|
||||
else:
|
||||
body_bytes = str(body).encode("utf-8")
|
||||
else:
|
||||
body_bytes = b""
|
||||
|
||||
response_headers.append(
|
||||
(b"content-length", str(len(body_bytes)).encode("utf-8"))
|
||||
)
|
||||
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": status,
|
||||
"headers": response_headers,
|
||||
})
|
||||
|
||||
await send({
|
||||
"type": "http.response.body",
|
||||
"body": body_bytes,
|
||||
})
|
||||
|
||||
|
||||
app = SimpleASGIApp()
|
||||
|
||||
|
||||
async def simple_asgi_app(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
return
|
||||
|
||||
response_body = json.dumps({
|
||||
"message": "Hello from minimal ASGI app!",
|
||||
"path": scope.get("path", "/"),
|
||||
}).encode("utf-8")
|
||||
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": 200,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"content-length", str(len(response_body)).encode("utf-8")),
|
||||
],
|
||||
})
|
||||
|
||||
await send({
|
||||
"type": "http.response.body",
|
||||
"body": response_body,
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8004)
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Example FastAPI application for PyServe ASGI mounting.
|
||||
|
||||
This demonstrates how to create a FastAPI application that can be
|
||||
mounted at a specific path in PyServe.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"FastAPI is not installed. Install with: pip install fastapi"
|
||||
)
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Example FastAPI App",
|
||||
description="This is an example FastAPI application mounted in PyServe",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
price: float
|
||||
tax: Optional[float] = None
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
items_db: Dict[int, Dict[str, Any]] = {
|
||||
1: {"name": "Item 1", "description": "First item", "price": 10.5, "tax": 1.05},
|
||||
2: {"name": "Item 2", "description": "Second item", "price": 20.0, "tax": 2.0},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Welcome to FastAPI mounted in PyServe!"}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy", "app": "fastapi"}
|
||||
|
||||
|
||||
@app.get("/items")
|
||||
async def list_items():
|
||||
return {"items": list(items_db.values()), "count": len(items_db)}
|
||||
|
||||
|
||||
@app.get("/items/{item_id}")
|
||||
async def get_item(item_id: int):
|
||||
if item_id not in items_db:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
return items_db[item_id]
|
||||
|
||||
|
||||
@app.post("/items", response_model=Message)
|
||||
async def create_item(item: Item):
|
||||
new_id = max(items_db.keys()) + 1 if items_db else 1
|
||||
items_db[new_id] = item.model_dump()
|
||||
return {"message": f"Item created with ID {new_id}"}
|
||||
|
||||
|
||||
@app.put("/items/{item_id}")
|
||||
async def update_item(item_id: int, item: Item):
|
||||
if item_id not in items_db:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
items_db[item_id] = item.model_dump()
|
||||
return {"message": f"Item {item_id} updated"}
|
||||
|
||||
|
||||
@app.delete("/items/{item_id}")
|
||||
async def delete_item(item_id: int):
|
||||
if item_id not in items_db:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
del items_db[item_id]
|
||||
return {"message": f"Item {item_id} deleted"}
|
||||
|
||||
|
||||
def create_app(debug: bool = False, **kwargs) -> FastAPI:
|
||||
application = FastAPI(
|
||||
title="Example FastAPI App (Factory)",
|
||||
description="FastAPI application created via factory function",
|
||||
version="2.0.0",
|
||||
debug=debug,
|
||||
)
|
||||
|
||||
@application.get("/")
|
||||
async def factory_root():
|
||||
return {
|
||||
"message": "Welcome to FastAPI (factory) mounted in PyServe!",
|
||||
"debug": debug,
|
||||
"config": kwargs,
|
||||
}
|
||||
|
||||
@application.get("/health")
|
||||
async def factory_health():
|
||||
return {"status": "healthy", "app": "fastapi-factory", "debug": debug}
|
||||
|
||||
@application.get("/echo/{message}")
|
||||
async def echo(message: str):
|
||||
return {"echo": message}
|
||||
|
||||
return application
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Example Flask application for PyServe ASGI mounting.
|
||||
|
||||
This demonstrates how to create a Flask application that can be
|
||||
mounted at a specific path in PyServe (via WSGI-to-ASGI adapter).
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
from flask import Flask, jsonify, request
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Flask is not installed. Install with: pip install flask"
|
||||
)
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
users_db = {
|
||||
1: {"id": 1, "name": "Alice", "email": "alice@example.com"},
|
||||
2: {"id": 2, "name": "Bob", "email": "bob@example.com"},
|
||||
}
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def root():
|
||||
return jsonify({"message": "Welcome to Flask mounted in PyServe!"})
|
||||
|
||||
|
||||
@app.route("/health")
|
||||
def health_check():
|
||||
return jsonify({"status": "healthy", "app": "flask"})
|
||||
|
||||
|
||||
@app.route("/users")
|
||||
def list_users():
|
||||
return jsonify({"users": list(users_db.values()), "count": len(users_db)})
|
||||
|
||||
|
||||
@app.route("/users/<int:user_id>")
|
||||
def get_user(user_id: int):
|
||||
if user_id not in users_db:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
return jsonify(users_db[user_id])
|
||||
|
||||
|
||||
@app.route("/users", methods=["POST"])
|
||||
def create_user():
|
||||
data = request.get_json()
|
||||
if not data or "name" not in data:
|
||||
return jsonify({"error": "Name is required"}), 400
|
||||
|
||||
new_id = max(users_db.keys()) + 1 if users_db else 1
|
||||
users_db[new_id] = {
|
||||
"id": new_id,
|
||||
"name": data["name"],
|
||||
"email": data.get("email", ""),
|
||||
}
|
||||
return jsonify({"message": f"User created with ID {new_id}", "user": users_db[new_id]}), 201
|
||||
|
||||
|
||||
@app.route("/users/<int:user_id>", methods=["PUT"])
|
||||
def update_user(user_id: int):
|
||||
if user_id not in users_db:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
data = request.get_json()
|
||||
if data:
|
||||
if "name" in data:
|
||||
users_db[user_id]["name"] = data["name"]
|
||||
if "email" in data:
|
||||
users_db[user_id]["email"] = data["email"]
|
||||
|
||||
return jsonify({"message": f"User {user_id} updated", "user": users_db[user_id]})
|
||||
|
||||
|
||||
@app.route("/users/<int:user_id>", methods=["DELETE"])
|
||||
def delete_user(user_id: int):
|
||||
if user_id not in users_db:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
||||
del users_db[user_id]
|
||||
return jsonify({"message": f"User {user_id} deleted"})
|
||||
|
||||
|
||||
def create_app(config: Optional[dict] = None) -> Flask:
|
||||
application = Flask(__name__)
|
||||
|
||||
if config:
|
||||
application.config.update(config)
|
||||
|
||||
@application.route("/")
|
||||
def factory_root():
|
||||
return jsonify({
|
||||
"message": "Welcome to Flask (factory) mounted in PyServe!",
|
||||
"config": config or {},
|
||||
})
|
||||
|
||||
@application.route("/health")
|
||||
def factory_health():
|
||||
return jsonify({"status": "healthy", "app": "flask-factory"})
|
||||
|
||||
@application.route("/echo/<message>")
|
||||
def echo(message: str):
|
||||
return jsonify({"echo": message})
|
||||
|
||||
return application
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8002, debug=True)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Example Starlette application for PyServe ASGI mounting.
|
||||
|
||||
This demonstrates how to create a Starlette application that can be
|
||||
mounted at a specific path in PyServe.
|
||||
"""
|
||||
|
||||
try:
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
from starlette.requests import Request
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Starlette is not installed. Install with: pip install starlette"
|
||||
)
|
||||
|
||||
|
||||
tasks_db = {
|
||||
1: {"id": 1, "title": "Task 1", "completed": False},
|
||||
2: {"id": 2, "title": "Task 2", "completed": True},
|
||||
}
|
||||
|
||||
|
||||
async def homepage(request: Request) -> JSONResponse:
|
||||
return JSONResponse({
|
||||
"message": "Welcome to Starlette mounted in PyServe!"
|
||||
})
|
||||
|
||||
|
||||
async def health_check(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"status": "healthy", "app": "starlette"})
|
||||
|
||||
|
||||
async def list_tasks(request: Request) -> JSONResponse:
|
||||
return JSONResponse({
|
||||
"tasks": list(tasks_db.values()),
|
||||
"count": len(tasks_db)
|
||||
})
|
||||
|
||||
|
||||
async def get_task(request: Request) -> JSONResponse:
|
||||
task_id = int(request.path_params["task_id"])
|
||||
if task_id not in tasks_db:
|
||||
return JSONResponse({"error": "Task not found"}, status_code=404)
|
||||
return JSONResponse(tasks_db[task_id])
|
||||
|
||||
|
||||
async def create_task(request: Request) -> JSONResponse:
|
||||
data = await request.json()
|
||||
if not data or "title" not in data:
|
||||
return JSONResponse({"error": "Title is required"}, status_code=400)
|
||||
|
||||
new_id = max(tasks_db.keys()) + 1 if tasks_db else 1
|
||||
tasks_db[new_id] = {
|
||||
"id": new_id,
|
||||
"title": data["title"],
|
||||
"completed": data.get("completed", False),
|
||||
}
|
||||
return JSONResponse(
|
||||
{"message": f"Task created with ID {new_id}", "task": tasks_db[new_id]},
|
||||
status_code=201
|
||||
)
|
||||
|
||||
|
||||
async def update_task(request: Request) -> JSONResponse:
|
||||
task_id = int(request.path_params["task_id"])
|
||||
if task_id not in tasks_db:
|
||||
return JSONResponse({"error": "Task not found"}, status_code=404)
|
||||
|
||||
data = await request.json()
|
||||
if data:
|
||||
if "title" in data:
|
||||
tasks_db[task_id]["title"] = data["title"]
|
||||
if "completed" in data:
|
||||
tasks_db[task_id]["completed"] = data["completed"]
|
||||
|
||||
return JSONResponse({
|
||||
"message": f"Task {task_id} updated",
|
||||
"task": tasks_db[task_id]
|
||||
})
|
||||
|
||||
|
||||
async def delete_task(request: Request) -> JSONResponse:
|
||||
task_id = int(request.path_params["task_id"])
|
||||
if task_id not in tasks_db:
|
||||
return JSONResponse({"error": "Task not found"}, status_code=404)
|
||||
|
||||
del tasks_db[task_id]
|
||||
return JSONResponse({"message": f"Task {task_id} deleted"})
|
||||
|
||||
|
||||
routes = [
|
||||
Route("/", homepage),
|
||||
Route("/health", health_check),
|
||||
Route("/tasks", list_tasks, methods=["GET"]),
|
||||
Route("/tasks", create_task, methods=["POST"]),
|
||||
Route("/tasks/{task_id:int}", get_task, methods=["GET"]),
|
||||
Route("/tasks/{task_id:int}", update_task, methods=["PUT"]),
|
||||
Route("/tasks/{task_id:int}", delete_task, methods=["DELETE"]),
|
||||
]
|
||||
|
||||
app = Starlette(debug=True, routes=routes)
|
||||
|
||||
|
||||
def create_app(debug: bool = False) -> Starlette:
|
||||
return Starlette(debug=debug, routes=routes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8003)
|
||||
@@ -0,0 +1,103 @@
|
||||
# Example configuration for ASGI application mounts
|
||||
# This demonstrates how to mount various Python web frameworks
|
||||
|
||||
http:
|
||||
static_dir: ./static
|
||||
templates_dir: ./templates
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
backlog: 5
|
||||
proxy_timeout: 30.0
|
||||
|
||||
logging:
|
||||
level: DEBUG
|
||||
console_output: true
|
||||
format:
|
||||
type: standard
|
||||
use_colors: true
|
||||
|
||||
extensions:
|
||||
# ASGI Application Mount Extension
|
||||
- type: asgi
|
||||
config:
|
||||
mounts:
|
||||
# FastAPI application
|
||||
- path: "/api"
|
||||
app_path: "examples.apps.fastapi_app:app"
|
||||
app_type: asgi
|
||||
name: "fastapi-api"
|
||||
strip_path: true
|
||||
|
||||
# FastAPI with factory pattern
|
||||
- path: "/api/v2"
|
||||
app_path: "examples.apps.fastapi_app:create_app"
|
||||
app_type: asgi
|
||||
factory: true
|
||||
factory_args:
|
||||
debug: true
|
||||
name: "fastapi-api-v2"
|
||||
strip_path: true
|
||||
|
||||
# Flask application (WSGI wrapped to ASGI)
|
||||
- path: "/flask"
|
||||
app_path: "examples.apps.flask_app:app"
|
||||
app_type: wsgi
|
||||
name: "flask-app"
|
||||
strip_path: true
|
||||
|
||||
# Flask with factory pattern
|
||||
- path: "/flask-v2"
|
||||
app_path: "examples.apps.flask_app:create_app"
|
||||
app_type: wsgi
|
||||
factory: true
|
||||
name: "flask-app-factory"
|
||||
strip_path: true
|
||||
|
||||
# Django application
|
||||
# Uncomment and configure for your Django project
|
||||
# - path: "/django"
|
||||
# django_settings: "myproject.settings"
|
||||
# module_path: "/path/to/django/project"
|
||||
# name: "django-app"
|
||||
# strip_path: true
|
||||
|
||||
# Starlette application
|
||||
- path: "/starlette"
|
||||
app_path: "examples.apps.starlette_app:app"
|
||||
app_type: asgi
|
||||
name: "starlette-app"
|
||||
strip_path: true
|
||||
|
||||
# Custom ASGI application (http.server style)
|
||||
- path: "/custom"
|
||||
app_path: "examples.apps.custom_asgi:app"
|
||||
app_type: asgi
|
||||
name: "custom-asgi"
|
||||
strip_path: true
|
||||
|
||||
# Standard routing for other paths
|
||||
- type: routing
|
||||
config:
|
||||
regex_locations:
|
||||
# Health check
|
||||
"=/health":
|
||||
return: "200 OK"
|
||||
content_type: "text/plain"
|
||||
|
||||
# Static files
|
||||
"~*\\.(js|css|png|jpg|gif|ico|svg|woff2?)$":
|
||||
root: "./static"
|
||||
cache_control: "public, max-age=31536000"
|
||||
|
||||
# Root path
|
||||
"=/":
|
||||
root: "./static"
|
||||
index_file: "index.html"
|
||||
|
||||
# Default fallback
|
||||
"__default__":
|
||||
spa_fallback: true
|
||||
root: "./static"
|
||||
index_file: "index.html"
|
||||
@@ -0,0 +1,113 @@
|
||||
# PyServe Process Orchestration Example
|
||||
#
|
||||
# This configuration demonstrates running multiple ASGI/WSGI applications
|
||||
# as isolated processes with automatic health monitoring and restart.
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8000
|
||||
backlog: 2048
|
||||
proxy_timeout: 60.0
|
||||
|
||||
logging:
|
||||
level: DEBUG
|
||||
console_output: true
|
||||
format:
|
||||
type: standard
|
||||
use_colors: true
|
||||
|
||||
extensions:
|
||||
# Process Orchestration - runs each app in its own process
|
||||
- type: process_orchestration
|
||||
config:
|
||||
# Port range for worker processes
|
||||
port_range: [9000, 9999]
|
||||
|
||||
# Enable health monitoring
|
||||
health_check_enabled: true
|
||||
|
||||
# Proxy timeout for requests
|
||||
proxy_timeout: 60.0
|
||||
|
||||
apps:
|
||||
# FastAPI application
|
||||
- name: api
|
||||
path: /api
|
||||
app_path: examples.apps.fastapi_app:app
|
||||
module_path: "."
|
||||
workers: 2
|
||||
health_check_path: /health
|
||||
health_check_interval: 10.0
|
||||
health_check_timeout: 5.0
|
||||
health_check_retries: 3
|
||||
max_restart_count: 5
|
||||
restart_delay: 1.0
|
||||
shutdown_timeout: 30.0
|
||||
strip_path: true
|
||||
env:
|
||||
APP_ENV: "production"
|
||||
DEBUG: "false"
|
||||
|
||||
# Flask application (WSGI wrapped to ASGI)
|
||||
- name: admin
|
||||
path: /admin
|
||||
app_path: examples.apps.flask_app:app
|
||||
app_type: wsgi
|
||||
module_path: "."
|
||||
workers: 1
|
||||
health_check_path: /health
|
||||
strip_path: true
|
||||
|
||||
# Starlette application
|
||||
- name: web
|
||||
path: /web
|
||||
app_path: examples.apps.starlette_app:app
|
||||
module_path: "."
|
||||
workers: 2
|
||||
health_check_path: /health
|
||||
strip_path: true
|
||||
|
||||
# Custom ASGI application
|
||||
- name: custom
|
||||
path: /custom
|
||||
app_path: examples.apps.custom_asgi:app
|
||||
module_path: "."
|
||||
workers: 1
|
||||
health_check_path: /health
|
||||
strip_path: true
|
||||
|
||||
# Routing for static files and reverse proxy
|
||||
- type: routing
|
||||
config:
|
||||
regex_locations:
|
||||
# Static files
|
||||
"^/static/.*":
|
||||
type: static
|
||||
root: "./static"
|
||||
strip_prefix: "/static"
|
||||
|
||||
# Documentation
|
||||
"^/docs/?.*":
|
||||
type: static
|
||||
root: "./docs"
|
||||
strip_prefix: "/docs"
|
||||
|
||||
# External API proxy
|
||||
"^/external/.*":
|
||||
type: proxy
|
||||
upstream: "https://api.example.com"
|
||||
strip_prefix: "/external"
|
||||
|
||||
# Security headers
|
||||
- type: security
|
||||
config:
|
||||
security_headers:
|
||||
X-Content-Type-Options: "nosniff"
|
||||
X-Frame-Options: "DENY"
|
||||
X-XSS-Protection: "1; mode=block"
|
||||
Strict-Transport-Security: "max-age=31536000; includeSubDomains"
|
||||
|
||||
# Monitoring
|
||||
- type: monitoring
|
||||
config:
|
||||
enable_metrics: true
|
||||
@@ -0,0 +1,34 @@
|
||||
# Multi-stage build for Konduktor
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git make
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY go.mod go.sum* ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN make build
|
||||
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
RUN adduser -D -g '' konduktor
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build/bin/konduktor /usr/local/bin/
|
||||
COPY --from=builder /build/bin/konduktorctl /usr/local/bin/
|
||||
|
||||
RUN mkdir -p /app/static /app/templates /app/logs && \
|
||||
chown -R konduktor:konduktor /app
|
||||
|
||||
USER konduktor
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["konduktor"]
|
||||
CMD ["-c", "/app/config.yaml"]
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# Konduktor Go Build
|
||||
# Makefile for building and testing Konduktor
|
||||
|
||||
.PHONY: all build build-konduktor build-konduktorctl test clean deps fmt lint run
|
||||
|
||||
# Build configuration
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
BUILD_TIME ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
LDFLAGS := -X main.Version=$(VERSION) -X main.GitCommit=$(GIT_COMMIT) -X main.BuildTime=$(BUILD_TIME)
|
||||
|
||||
# Output directories
|
||||
BIN_DIR := bin
|
||||
|
||||
all: deps build
|
||||
|
||||
# Download dependencies
|
||||
deps:
|
||||
@echo "==> Downloading dependencies..."
|
||||
go mod download
|
||||
go mod tidy
|
||||
|
||||
# Build all binaries
|
||||
build: build-konduktor build-konduktorctl
|
||||
|
||||
# Build konduktor server
|
||||
build-konduktor:
|
||||
@echo "==> Building konduktor..."
|
||||
@mkdir -p $(BIN_DIR)
|
||||
go build -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/konduktor ./cmd/konduktor
|
||||
|
||||
# Build konduktorctl CLI
|
||||
build-konduktorctl:
|
||||
@echo "==> Building konduktorctl..."
|
||||
@mkdir -p $(BIN_DIR)
|
||||
go build -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/konduktorctl ./cmd/konduktorctl
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
@echo "==> Running tests..."
|
||||
go test -v -race -cover ./...
|
||||
|
||||
# Run tests with coverage report
|
||||
test-coverage:
|
||||
@echo "==> Running tests with coverage..."
|
||||
go test -v -race -coverprofile=coverage.out ./...
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
@echo "Coverage report: coverage.html"
|
||||
|
||||
# Format code
|
||||
fmt:
|
||||
@echo "==> Formatting code..."
|
||||
go fmt ./...
|
||||
goimports -w .
|
||||
|
||||
# Lint code
|
||||
lint:
|
||||
@echo "==> Linting code..."
|
||||
golangci-lint run ./...
|
||||
|
||||
# Run the server (development)
|
||||
run: build-konduktor
|
||||
@echo "==> Running konduktor..."
|
||||
./$(BIN_DIR)/konduktor -c ../config.yaml
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
@echo "==> Cleaning..."
|
||||
rm -rf $(BIN_DIR)
|
||||
rm -f coverage.out coverage.html
|
||||
|
||||
# Install binaries to GOPATH/bin
|
||||
install: build
|
||||
@echo "==> Installing binaries..."
|
||||
cp $(BIN_DIR)/konduktor $(GOPATH)/bin/
|
||||
cp $(BIN_DIR)/konduktorctl $(GOPATH)/bin/
|
||||
|
||||
# Generate mocks (for testing)
|
||||
generate:
|
||||
@echo "==> Generating code..."
|
||||
go generate ./...
|
||||
|
||||
# Docker build
|
||||
docker-build:
|
||||
@echo "==> Building Docker image..."
|
||||
docker build -t konduktor:$(VERSION) .
|
||||
|
||||
# Show help
|
||||
help:
|
||||
@echo "Konduktor Build System"
|
||||
@echo ""
|
||||
@echo "Usage: make [target]"
|
||||
@echo ""
|
||||
@echo "Targets:"
|
||||
@echo " all Download deps and build all binaries"
|
||||
@echo " deps Download and tidy dependencies"
|
||||
@echo " build Build all binaries"
|
||||
@echo " build-konduktor Build the server binary"
|
||||
@echo " build-konduktorctl Build the CLI binary"
|
||||
@echo " test Run tests"
|
||||
@echo " test-coverage Run tests with coverage report"
|
||||
@echo " fmt Format code"
|
||||
@echo " lint Lint code"
|
||||
@echo " run Build and run the server"
|
||||
@echo " clean Clean build artifacts"
|
||||
@echo " install Install binaries to GOPATH/bin"
|
||||
@echo " docker-build Build Docker image"
|
||||
@echo " help Show this help"
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
# Konduktor (Go)
|
||||
|
||||
High-performance HTTP web server with extensible routing and process orchestration. (Previously known as PyServe in Python)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
go/
|
||||
├── cmd/
|
||||
│ ├── konduktor/ # Main server binary
|
||||
│ └── konduktorctl/ # CLI management tool
|
||||
├── internal/
|
||||
│ ├── config/ # Configuration management
|
||||
│ ├── logging/ # Structured logging
|
||||
│ ├── middleware/ # HTTP middleware
|
||||
│ ├── routing/ # HTTP routing
|
||||
│ ├── extensions/ # Extension system (TODO)
|
||||
│ └── process/ # Process management (TODO)
|
||||
├── pkg/ # Public packages (TODO)
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
└── Makefile
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cd go
|
||||
|
||||
# Download dependencies
|
||||
make deps
|
||||
|
||||
# Build all binaries
|
||||
make build
|
||||
|
||||
# Or build individually
|
||||
make build-konduktor
|
||||
make build-konduktorctl
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Run with default config
|
||||
./bin/konduktor
|
||||
|
||||
# Run with custom config
|
||||
./bin/konduktor -c ../config.yaml
|
||||
|
||||
# Run with flags
|
||||
./bin/konduktor --host 127.0.0.1 --port 3000 --debug
|
||||
```
|
||||
|
||||
## CLI Commands (konduktorctl)
|
||||
|
||||
```bash
|
||||
# Start services
|
||||
konduktorctl up
|
||||
|
||||
# Stop services
|
||||
konduktorctl down
|
||||
|
||||
# View status
|
||||
konduktorctl status
|
||||
|
||||
# View logs
|
||||
konduktorctl logs -f
|
||||
|
||||
# Health check
|
||||
konduktorctl health
|
||||
|
||||
# Scale services
|
||||
konduktorctl scale api=3
|
||||
|
||||
# Configuration management
|
||||
konduktorctl config show
|
||||
konduktorctl config validate
|
||||
|
||||
# Initialize new project
|
||||
konduktorctl init
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Uses the same YAML configuration format as the Python version:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
|
||||
http:
|
||||
static_dir: ./static
|
||||
templates_dir: ./templates
|
||||
|
||||
ssl:
|
||||
enabled: false
|
||||
cert_file: ./ssl/cert.pem
|
||||
key_file: ./ssl/key.pem
|
||||
|
||||
logging:
|
||||
level: INFO
|
||||
console_output: true
|
||||
|
||||
extensions:
|
||||
- type: routing
|
||||
config:
|
||||
regex_locations:
|
||||
"=/health":
|
||||
return: "200 OK"
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
make fmt
|
||||
|
||||
# Run linter
|
||||
make lint
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
|
||||
# Run with coverage
|
||||
make test-coverage
|
||||
```
|
||||
|
||||
## Migration from Python
|
||||
|
||||
This is a gradual rewrite of PyServe to Go. The project is now called **Konduktor**.
|
||||
|
||||
### Completed
|
||||
- [x] Basic project structure
|
||||
- [x] Configuration loading
|
||||
- [x] HTTP server with graceful shutdown
|
||||
- [x] Basic routing
|
||||
- [x] Middleware (access log, recovery, server header)
|
||||
- [x] CLI structure (konduktor, konduktorctl)
|
||||
|
||||
### TODO
|
||||
- [ ] Extension system
|
||||
- [x] Regex routing
|
||||
- [x] Reverse proxy
|
||||
- [ ] Process orchestration
|
||||
- [ ] ASGI/WSGI adapter support
|
||||
- [ ] WebSocket support
|
||||
- [ ] Hot reload
|
||||
- [ ] Metrics and monitoring
|
||||
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/config"
|
||||
"github.com/konduktor/konduktor/internal/server"
|
||||
)
|
||||
|
||||
var (
|
||||
Version = "0.1.0"
|
||||
BuildTime = "unknown"
|
||||
GitCommit = "unknown"
|
||||
)
|
||||
|
||||
var (
|
||||
cfgFile string
|
||||
host string
|
||||
port int
|
||||
debug bool
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "konduktor",
|
||||
Short: "Konduktor - HTTP web server",
|
||||
Long: `Konduktor is a high-performance HTTP web server with extensible routing and process orchestration.`,
|
||||
Version: fmt.Sprintf("%s (commit: %s, built: %s)", Version, GitCommit, BuildTime),
|
||||
RunE: runServer,
|
||||
}
|
||||
|
||||
rootCmd.Flags().StringVarP(&cfgFile, "config", "c", "config.yaml", "Path to configuration file")
|
||||
rootCmd.Flags().StringVar(&host, "host", "", "Host to bind the server to")
|
||||
rootCmd.Flags().IntVar(&port, "port", 0, "Port to bind the server to")
|
||||
rootCmd.Flags().BoolVar(&debug, "debug", false, "Enable debug mode")
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runServer(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load(cfgFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Printf("Configuration file %s not found, using defaults\n", cfgFile)
|
||||
cfg = config.Default()
|
||||
} else {
|
||||
return fmt.Errorf("configuration loading error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if host != "" {
|
||||
cfg.Server.Host = host
|
||||
}
|
||||
if port != 0 {
|
||||
cfg.Server.Port = port
|
||||
}
|
||||
if debug {
|
||||
cfg.Logging.Level = "DEBUG"
|
||||
}
|
||||
|
||||
srv, err := server.New(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server creation error: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Starting Konduktor server on %s:%d\n", cfg.Server.Host, cfg.Server.Port)
|
||||
|
||||
if err := srv.Run(); err != nil {
|
||||
return fmt.Errorf("server startup error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
Version = "0.1.0"
|
||||
BuildTime = "unknown"
|
||||
GitCommit = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "konduktorctl",
|
||||
Short: "Konduktorctl - Service management CLI",
|
||||
Long: `Konduktorctl is a CLI tool for managing Konduktor services.`,
|
||||
Version: fmt.Sprintf("%s (commit: %s, built: %s)", Version, GitCommit, BuildTime),
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(
|
||||
newUpCmd(),
|
||||
newDownCmd(),
|
||||
newStatusCmd(),
|
||||
newLogsCmd(),
|
||||
newHealthCmd(),
|
||||
newScaleCmd(),
|
||||
newConfigCmd(),
|
||||
newInitCmd(),
|
||||
newTopCmd(),
|
||||
)
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func newUpCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "up [service...]",
|
||||
Short: "Start services",
|
||||
Long: `Start one or more services. If no service is specified, all services are started.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Starting services...")
|
||||
// TODO: Implement service start logic
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolP("detach", "d", false, "Run in background")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newDownCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "down [service...]",
|
||||
Short: "Stop services",
|
||||
Long: `Stop one or more services. If no service is specified, all services are stopped.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Stopping services...")
|
||||
// TODO: Implement service stop logic
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newStatusCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status [service...]",
|
||||
Short: "Show service status",
|
||||
Long: `Show the status of one or more services.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Service status:")
|
||||
// TODO: Implement status display logic
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newLogsCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "logs [service]",
|
||||
Short: "View service logs",
|
||||
Long: `View logs for a specific service.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Fetching logs...")
|
||||
// TODO: Implement logs viewing logic
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolP("follow", "f", false, "Follow log output")
|
||||
cmd.Flags().IntP("tail", "n", 100, "Number of lines to show from the end")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newHealthCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "health",
|
||||
Short: "Check service health",
|
||||
Long: `Check the health status of all services.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Health check:")
|
||||
// TODO: Implement health check logic
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newScaleCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "scale <service>=<count>",
|
||||
Short: "Scale a service",
|
||||
Long: `Scale a service to a specific number of instances.`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf("Scaling: %v\n", args)
|
||||
// TODO: Implement scaling logic
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newConfigCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Manage configuration",
|
||||
Long: `View and validate configuration.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Show current configuration",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Current configuration:")
|
||||
// TODO: Implement config show logic
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "validate",
|
||||
Short: "Validate configuration file",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Validating configuration...")
|
||||
// TODO: Implement config validation logic
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInitCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Initialize a new project",
|
||||
Long: `Create a new Konduktor project with default configuration.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Initializing new project...")
|
||||
// TODO: Implement init logic
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newTopCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "top",
|
||||
Short: "Display running processes",
|
||||
Long: `Display real-time view of running processes and resource usage.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Process monitor:")
|
||||
// TODO: Implement top-like display
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
module github.com/konduktor/konduktor
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.24.2
|
||||
|
||||
require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,134 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTP HTTPConfig `yaml:"http"`
|
||||
Server ServerConfig `yaml:"server"`
|
||||
SSL SSLConfig `yaml:"ssl"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
Extensions []ExtensionConfig `yaml:"extensions"`
|
||||
}
|
||||
|
||||
type HTTPConfig struct {
|
||||
StaticDir string `yaml:"static_dir"`
|
||||
TemplatesDir string `yaml:"templates_dir"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Backlog int `yaml:"backlog"`
|
||||
DefaultRoot bool `yaml:"default_root"`
|
||||
ProxyTimeout time.Duration `yaml:"proxy_timeout"`
|
||||
RedirectInstructions map[string]string `yaml:"redirect_instructions"`
|
||||
}
|
||||
|
||||
type SSLConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
CertFile string `yaml:"cert_file"`
|
||||
KeyFile string `yaml:"key_file"`
|
||||
}
|
||||
|
||||
type LoggingConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
ConsoleOutput bool `yaml:"console_output"`
|
||||
Format LogFormatConfig `yaml:"format"`
|
||||
Console *ConsoleLogConfig `yaml:"console"`
|
||||
Files []FileLogConfig `yaml:"files"`
|
||||
}
|
||||
|
||||
type LogFormatConfig struct {
|
||||
Type string `yaml:"type"`
|
||||
UseColors bool `yaml:"use_colors"`
|
||||
ShowModule bool `yaml:"show_module"`
|
||||
TimestampFormat string `yaml:"timestamp_format"`
|
||||
}
|
||||
|
||||
type ConsoleLogConfig struct {
|
||||
Format LogFormatConfig `yaml:"format"`
|
||||
Level string `yaml:"level"`
|
||||
}
|
||||
|
||||
type FileLogConfig struct {
|
||||
Path string `yaml:"path"`
|
||||
Level string `yaml:"level"`
|
||||
Loggers []string `yaml:"loggers"`
|
||||
Format LogFormatConfig `yaml:"format"`
|
||||
MaxBytes int64 `yaml:"max_bytes"`
|
||||
BackupCount int `yaml:"backup_count"`
|
||||
}
|
||||
|
||||
type ExtensionConfig struct {
|
||||
Type string `yaml:"type"`
|
||||
Config map[string]interface{} `yaml:"config"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
HTTP: HTTPConfig{
|
||||
StaticDir: "./static",
|
||||
TemplatesDir: "./templates",
|
||||
},
|
||||
Server: ServerConfig{
|
||||
Host: "0.0.0.0",
|
||||
Port: 8080,
|
||||
Backlog: 5,
|
||||
DefaultRoot: false,
|
||||
ProxyTimeout: 30 * time.Second,
|
||||
},
|
||||
SSL: SSLConfig{
|
||||
Enabled: false,
|
||||
CertFile: "./ssl/cert.pem",
|
||||
KeyFile: "./ssl/key.pem",
|
||||
},
|
||||
Logging: LoggingConfig{
|
||||
Level: "INFO",
|
||||
ConsoleOutput: true,
|
||||
Format: LogFormatConfig{
|
||||
Type: "standard",
|
||||
UseColors: true,
|
||||
ShowModule: true,
|
||||
TimestampFormat: "2006-01-02 15:04:05",
|
||||
},
|
||||
},
|
||||
Extensions: []ExtensionConfig{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
if c.Server.Port < 1 || c.Server.Port > 65535 {
|
||||
return fmt.Errorf("invalid port: %d", c.Server.Port)
|
||||
}
|
||||
|
||||
if c.SSL.Enabled {
|
||||
if c.SSL.CertFile == "" {
|
||||
return fmt.Errorf("SSL enabled but cert_file not specified")
|
||||
}
|
||||
if c.SSL.KeyFile == "" {
|
||||
return fmt.Errorf("SSL enabled but key_file not specified")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefault(t *testing.T) {
|
||||
cfg := Default()
|
||||
|
||||
if cfg.Server.Host != "0.0.0.0" {
|
||||
t.Errorf("Expected host 0.0.0.0, got %s", cfg.Server.Host)
|
||||
}
|
||||
|
||||
if cfg.Server.Port != 8080 {
|
||||
t.Errorf("Expected port 8080, got %d", cfg.Server.Port)
|
||||
}
|
||||
|
||||
if cfg.SSL.Enabled {
|
||||
t.Error("Expected SSL to be disabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
modify func(*Config)
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid default config",
|
||||
modify: func(c *Config) {},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid port - too low",
|
||||
modify: func(c *Config) {
|
||||
c.Server.Port = 0
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid port - too high",
|
||||
modify: func(c *Config) {
|
||||
c.Server.Port = 70000
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "SSL enabled without cert",
|
||||
modify: func(c *Config) {
|
||||
c.SSL.Enabled = true
|
||||
c.SSL.CertFile = ""
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "SSL enabled without key",
|
||||
modify: func(c *Config) {
|
||||
c.SSL.Enabled = true
|
||||
c.SSL.CertFile = "cert.pem"
|
||||
c.SSL.KeyFile = ""
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
tt.modify(cfg)
|
||||
|
||||
err := cfg.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
// Create temporary config file
|
||||
content := `
|
||||
server:
|
||||
host: 127.0.0.1
|
||||
port: 3000
|
||||
|
||||
logging:
|
||||
level: DEBUG
|
||||
`
|
||||
tmpfile, err := os.CreateTemp("", "config-*.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Remove(tmpfile.Name())
|
||||
|
||||
if _, err := tmpfile.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tmpfile.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(tmpfile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Server.Host != "127.0.0.1" {
|
||||
t.Errorf("Expected host 127.0.0.1, got %s", cfg.Server.Host)
|
||||
}
|
||||
|
||||
if cfg.Server.Port != 3000 {
|
||||
t.Errorf("Expected port 3000, got %d", cfg.Server.Port)
|
||||
}
|
||||
|
||||
if cfg.Logging.Level != "DEBUG" {
|
||||
t.Errorf("Expected level DEBUG, got %s", cfg.Logging.Level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNotFound(t *testing.T) {
|
||||
_, err := Load("/nonexistent/config.yaml")
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-existent file")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package extension
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/logging"
|
||||
)
|
||||
|
||||
type CachingExtension struct {
|
||||
BaseExtension
|
||||
cache map[string]*cacheEntry
|
||||
cachePatterns []*cachePattern
|
||||
defaultTTL time.Duration
|
||||
maxSize int
|
||||
currentSize int
|
||||
mu sync.RWMutex
|
||||
|
||||
hits int64
|
||||
misses int64
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
key string
|
||||
body []byte
|
||||
headers http.Header
|
||||
statusCode int
|
||||
contentType string
|
||||
createdAt time.Time
|
||||
expiresAt time.Time
|
||||
size int
|
||||
}
|
||||
|
||||
type cachePattern struct {
|
||||
pattern *regexp.Regexp
|
||||
ttl time.Duration
|
||||
methods []string
|
||||
}
|
||||
|
||||
type CachingConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
DefaultTTL string `yaml:"default_ttl"` // e.g., "5m", "1h"
|
||||
MaxSizeMB int `yaml:"max_size_mb"` // Max cache size in MB
|
||||
CachePatterns []PatternConfig `yaml:"cache_patterns"` // Patterns to cache
|
||||
}
|
||||
|
||||
type PatternConfig struct {
|
||||
Pattern string `yaml:"pattern"` // Regex pattern
|
||||
TTL string `yaml:"ttl"` // TTL for this pattern
|
||||
Methods []string `yaml:"methods"` // HTTP methods to cache (default: GET)
|
||||
}
|
||||
|
||||
func NewCachingExtension(config map[string]interface{}, logger *logging.Logger) (Extension, error) {
|
||||
ext := &CachingExtension{
|
||||
BaseExtension: NewBaseExtension("caching", 20, logger),
|
||||
cache: make(map[string]*cacheEntry),
|
||||
cachePatterns: make([]*cachePattern, 0),
|
||||
defaultTTL: 5 * time.Minute,
|
||||
maxSize: 100 * 1024 * 1024, // 100MB default
|
||||
}
|
||||
|
||||
if ttl, ok := config["default_ttl"].(string); ok {
|
||||
if duration, err := time.ParseDuration(ttl); err == nil {
|
||||
ext.defaultTTL = duration
|
||||
}
|
||||
}
|
||||
|
||||
if maxSize, ok := config["max_size_mb"].(int); ok {
|
||||
ext.maxSize = maxSize * 1024 * 1024
|
||||
} else if maxSizeFloat, ok := config["max_size_mb"].(float64); ok {
|
||||
ext.maxSize = int(maxSizeFloat) * 1024 * 1024
|
||||
}
|
||||
|
||||
if patterns, ok := config["cache_patterns"].([]interface{}); ok {
|
||||
for _, p := range patterns {
|
||||
if patternCfg, ok := p.(map[string]interface{}); ok {
|
||||
pattern := &cachePattern{
|
||||
ttl: ext.defaultTTL,
|
||||
methods: []string{"GET"},
|
||||
}
|
||||
|
||||
if patternStr, ok := patternCfg["pattern"].(string); ok {
|
||||
re, err := regexp.Compile(patternStr)
|
||||
if err != nil {
|
||||
logger.Error("Invalid cache pattern", "pattern", patternStr, "error", err)
|
||||
continue
|
||||
}
|
||||
pattern.pattern = re
|
||||
}
|
||||
|
||||
if ttl, ok := patternCfg["ttl"].(string); ok {
|
||||
if duration, err := time.ParseDuration(ttl); err == nil {
|
||||
pattern.ttl = duration
|
||||
}
|
||||
}
|
||||
|
||||
if methods, ok := patternCfg["methods"].([]interface{}); ok {
|
||||
pattern.methods = make([]string, 0)
|
||||
for _, m := range methods {
|
||||
if method, ok := m.(string); ok {
|
||||
pattern.methods = append(pattern.methods, strings.ToUpper(method))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ext.cachePatterns = append(ext.cachePatterns, pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
go ext.cleanupLoop()
|
||||
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func (e *CachingExtension) ProcessRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
|
||||
if !e.shouldCache(r) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
key := e.cacheKey(r)
|
||||
|
||||
e.mu.RLock()
|
||||
entry, exists := e.cache[key]
|
||||
e.mu.RUnlock()
|
||||
|
||||
if exists && time.Now().Before(entry.expiresAt) {
|
||||
e.mu.Lock()
|
||||
e.hits++
|
||||
e.mu.Unlock()
|
||||
|
||||
// Mark as cache hit to prevent setting X-Cache: MISS
|
||||
// Try to find cachingResponseWriter in the wrapper chain
|
||||
setCacheHitFlag(w)
|
||||
|
||||
for k, values := range entry.headers {
|
||||
for _, v := range values {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
w.Header().Set("X-Cache", "HIT")
|
||||
w.Header().Set("Content-Type", entry.contentType)
|
||||
w.WriteHeader(entry.statusCode)
|
||||
w.Write(entry.body)
|
||||
|
||||
e.logger.Debug("Cache hit", "key", key, "path", r.URL.Path)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
e.misses++
|
||||
e.mu.Unlock()
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// setCacheHitFlag tries to find cachingResponseWriter and set cache hit flag
|
||||
func setCacheHitFlag(w http.ResponseWriter) {
|
||||
// Direct match
|
||||
if cw, ok := w.(*cachingResponseWriter); ok {
|
||||
cw.SetCacheHit()
|
||||
return
|
||||
}
|
||||
|
||||
// Try unwrapping
|
||||
type unwrapper interface {
|
||||
Unwrap() http.ResponseWriter
|
||||
}
|
||||
|
||||
for {
|
||||
if u, ok := w.(unwrapper); ok {
|
||||
w = u.Unwrap()
|
||||
if cw, ok := w.(*cachingResponseWriter); ok {
|
||||
cw.SetCacheHit()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessResponse caches the response if applicable
|
||||
func (e *CachingExtension) ProcessResponse(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
// Response caching is handled by the CachingResponseWriter
|
||||
// X-Cache header is set in the cachingResponseWriter.WriteHeader
|
||||
}
|
||||
|
||||
// WrapResponseWriter wraps the response writer to capture the response for caching
|
||||
func (e *CachingExtension) WrapResponseWriter(w http.ResponseWriter, r *http.Request) http.ResponseWriter {
|
||||
if !e.shouldCache(r) {
|
||||
return w
|
||||
}
|
||||
|
||||
return &cachingResponseWriter{
|
||||
ResponseWriter: w,
|
||||
ext: e,
|
||||
request: r,
|
||||
buffer: &bytes.Buffer{},
|
||||
}
|
||||
}
|
||||
|
||||
type cachingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
ext *CachingExtension
|
||||
request *http.Request
|
||||
buffer *bytes.Buffer
|
||||
statusCode int
|
||||
wroteHeader bool
|
||||
cacheHit bool // Flag to indicate if this was a cache hit
|
||||
}
|
||||
|
||||
func (cw *cachingResponseWriter) WriteHeader(code int) {
|
||||
if !cw.wroteHeader {
|
||||
cw.statusCode = code
|
||||
cw.wroteHeader = true
|
||||
// Set X-Cache: MISS header before writing headers (only if not a cache hit)
|
||||
if !cw.cacheHit {
|
||||
cw.ResponseWriter.Header().Set("X-Cache", "MISS")
|
||||
}
|
||||
cw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
}
|
||||
|
||||
// SetCacheHit marks this response as a cache hit (to avoid setting X-Cache: MISS)
|
||||
func (cw *cachingResponseWriter) SetCacheHit() {
|
||||
cw.cacheHit = true
|
||||
}
|
||||
|
||||
func (cw *cachingResponseWriter) Write(b []byte) (int, error) {
|
||||
if !cw.wroteHeader {
|
||||
cw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
cw.buffer.Write(b)
|
||||
|
||||
return cw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (cw *cachingResponseWriter) Finalize() {
|
||||
if cw.statusCode < 200 || cw.statusCode >= 400 {
|
||||
return
|
||||
}
|
||||
|
||||
body := cw.buffer.Bytes()
|
||||
if len(body) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
key := cw.ext.cacheKey(cw.request)
|
||||
ttl := cw.ext.getTTL(cw.request)
|
||||
|
||||
entry := &cacheEntry{
|
||||
key: key,
|
||||
body: body,
|
||||
headers: cw.Header().Clone(),
|
||||
statusCode: cw.statusCode,
|
||||
contentType: cw.Header().Get("Content-Type"),
|
||||
createdAt: time.Now(),
|
||||
expiresAt: time.Now().Add(ttl),
|
||||
size: len(body),
|
||||
}
|
||||
|
||||
cw.ext.store(entry)
|
||||
}
|
||||
|
||||
func (e *CachingExtension) shouldCache(r *http.Request) bool {
|
||||
path := r.URL.Path
|
||||
method := r.Method
|
||||
|
||||
for _, pattern := range e.cachePatterns {
|
||||
if pattern.pattern.MatchString(path) {
|
||||
for _, m := range pattern.methods {
|
||||
if m == method {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: only cache GET requests
|
||||
return method == "GET" && len(e.cachePatterns) == 0
|
||||
}
|
||||
|
||||
func (e *CachingExtension) cacheKey(r *http.Request) string {
|
||||
// Create cache key from method + URL + relevant headers
|
||||
h := sha256.New()
|
||||
h.Write([]byte(r.Method))
|
||||
h.Write([]byte(r.URL.String()))
|
||||
|
||||
// Include Accept-Encoding for vary
|
||||
if ae := r.Header.Get("Accept-Encoding"); ae != "" {
|
||||
h.Write([]byte(ae))
|
||||
}
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func (e *CachingExtension) getTTL(r *http.Request) time.Duration {
|
||||
path := r.URL.Path
|
||||
|
||||
for _, pattern := range e.cachePatterns {
|
||||
if pattern.pattern.MatchString(path) {
|
||||
return pattern.ttl
|
||||
}
|
||||
}
|
||||
|
||||
return e.defaultTTL
|
||||
}
|
||||
|
||||
func (e *CachingExtension) store(entry *cacheEntry) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
// Evict old entries if needed
|
||||
for e.currentSize+entry.size > e.maxSize && len(e.cache) > 0 {
|
||||
e.evictOldest()
|
||||
}
|
||||
|
||||
// Store new entry
|
||||
if existing, ok := e.cache[entry.key]; ok {
|
||||
e.currentSize -= existing.size
|
||||
}
|
||||
|
||||
e.cache[entry.key] = entry
|
||||
e.currentSize += entry.size
|
||||
|
||||
e.logger.Debug("Cached response",
|
||||
"key", entry.key[:16],
|
||||
"size", entry.size,
|
||||
"ttl", entry.expiresAt.Sub(entry.createdAt).String())
|
||||
}
|
||||
|
||||
func (e *CachingExtension) evictOldest() {
|
||||
var oldestKey string
|
||||
var oldestTime time.Time
|
||||
|
||||
for key, entry := range e.cache {
|
||||
if oldestKey == "" || entry.createdAt.Before(oldestTime) {
|
||||
oldestKey = key
|
||||
oldestTime = entry.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
if oldestKey != "" {
|
||||
entry := e.cache[oldestKey]
|
||||
e.currentSize -= entry.size
|
||||
delete(e.cache, oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *CachingExtension) cleanupLoop() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
e.cleanupExpired()
|
||||
}
|
||||
}
|
||||
|
||||
func (e *CachingExtension) cleanupExpired() {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, entry := range e.cache {
|
||||
if now.After(entry.expiresAt) {
|
||||
e.currentSize -= entry.size
|
||||
delete(e.cache, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *CachingExtension) Invalidate(key string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
if entry, ok := e.cache[key]; ok {
|
||||
e.currentSize -= entry.size
|
||||
delete(e.cache, key)
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidatePattern removes all entries matching a pattern, unlike Invalidate
|
||||
func (e *CachingExtension) InvalidatePattern(pattern string) error {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
for key, entry := range e.cache {
|
||||
if re.MatchString(key) {
|
||||
e.currentSize -= entry.size
|
||||
delete(e.cache, key)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear removes all entries from cache
|
||||
func (e *CachingExtension) Clear() {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
e.cache = make(map[string]*cacheEntry)
|
||||
e.currentSize = 0
|
||||
}
|
||||
|
||||
// GetMetrics returns caching metrics
|
||||
func (e *CachingExtension) GetMetrics() map[string]interface{} {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
hitRate := float64(0)
|
||||
total := e.hits + e.misses
|
||||
if total > 0 {
|
||||
hitRate = float64(e.hits) / float64(total) * 100
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"entries": len(e.cache),
|
||||
"size_bytes": e.currentSize,
|
||||
"max_size": e.maxSize,
|
||||
"hits": e.hits,
|
||||
"misses": e.misses,
|
||||
"hit_rate": hitRate,
|
||||
"patterns": len(e.cachePatterns),
|
||||
"default_ttl": e.defaultTTL.String(),
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup stops the cleanup goroutine
|
||||
func (e *CachingExtension) Cleanup() error {
|
||||
e.Clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
// CacheReader wraps an io.ReadCloser to cache the body
|
||||
type CacheReader struct {
|
||||
io.ReadCloser
|
||||
buffer *bytes.Buffer
|
||||
}
|
||||
|
||||
func (cr *CacheReader) Read(p []byte) (int, error) {
|
||||
n, err := cr.ReadCloser.Read(p)
|
||||
if n > 0 {
|
||||
cr.buffer.Write(p[:n])
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (cr *CacheReader) GetBody() []byte {
|
||||
return cr.buffer.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package extension
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/logging"
|
||||
)
|
||||
|
||||
// Extension is the interface that all extensions must implement
|
||||
type Extension interface {
|
||||
// Name returns the unique name of the extension
|
||||
Name() string
|
||||
|
||||
// Initialize is called when the extension is loaded
|
||||
Initialize() error
|
||||
|
||||
// ProcessRequest processes an incoming request before routing.
|
||||
// Returns:
|
||||
// - response: if non-nil, the request is handled and no further processing occurs
|
||||
// - handled: if true, the request was handled by this extension
|
||||
// - err: any error that occurred
|
||||
ProcessRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (handled bool, err error)
|
||||
|
||||
// ProcessResponse is called after the response is generated but before it's sent.
|
||||
// Extensions can modify the response here.
|
||||
ProcessResponse(ctx context.Context, w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// Cleanup is called when the extension is being unloaded
|
||||
Cleanup() error
|
||||
|
||||
// Enabled returns whether the extension is currently enabled
|
||||
Enabled() bool
|
||||
|
||||
// SetEnabled enables or disables the extension
|
||||
SetEnabled(enabled bool)
|
||||
|
||||
// Priority returns the extension's priority (lower = earlier execution)
|
||||
Priority() int
|
||||
}
|
||||
|
||||
// BaseExtension provides a default implementation for common Extension methods
|
||||
type BaseExtension struct {
|
||||
name string
|
||||
enabled bool
|
||||
priority int
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewBaseExtension creates a new BaseExtension
|
||||
func NewBaseExtension(name string, priority int, logger *logging.Logger) BaseExtension {
|
||||
return BaseExtension{
|
||||
name: name,
|
||||
enabled: true,
|
||||
priority: priority,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the extension name
|
||||
func (b *BaseExtension) Name() string {
|
||||
return b.name
|
||||
}
|
||||
|
||||
// Enabled returns whether the extension is enabled
|
||||
func (b *BaseExtension) Enabled() bool {
|
||||
return b.enabled
|
||||
}
|
||||
|
||||
// SetEnabled sets the enabled state
|
||||
func (b *BaseExtension) SetEnabled(enabled bool) {
|
||||
b.enabled = enabled
|
||||
}
|
||||
|
||||
// Priority returns the extension priority
|
||||
func (b *BaseExtension) Priority() int {
|
||||
return b.priority
|
||||
}
|
||||
|
||||
// Initialize default implementation (no-op)
|
||||
func (b *BaseExtension) Initialize() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cleanup default implementation (no-op)
|
||||
func (b *BaseExtension) Cleanup() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessRequest default implementation (pass-through)
|
||||
func (b *BaseExtension) ProcessRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ProcessResponse default implementation (no-op)
|
||||
func (b *BaseExtension) ProcessResponse(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Logger returns the extension's logger
|
||||
func (b *BaseExtension) Logger() *logging.Logger {
|
||||
return b.logger
|
||||
}
|
||||
|
||||
// ExtensionConfig holds configuration for creating extensions
|
||||
type ExtensionConfig struct {
|
||||
Type string
|
||||
Config map[string]interface{}
|
||||
}
|
||||
|
||||
// ExtensionFactory is a function that creates an extension from config
|
||||
type ExtensionFactory func(config map[string]interface{}, logger *logging.Logger) (Extension, error)
|
||||
|
||||
// ResponseWriterWrapper is an optional interface that extensions can implement
|
||||
// to wrap the response writer for capturing/modifying responses
|
||||
type ResponseWriterWrapper interface {
|
||||
WrapResponseWriter(w http.ResponseWriter, r *http.Request) http.ResponseWriter
|
||||
}
|
||||
|
||||
// ResponseFinalizer is an optional interface for response writers that need
|
||||
// to perform finalization after the response is written (e.g., caching)
|
||||
type ResponseFinalizer interface {
|
||||
Finalize()
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package extension
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/logging"
|
||||
)
|
||||
|
||||
// Manager manages all loaded extensions
|
||||
type Manager struct {
|
||||
extensions []Extension
|
||||
registry map[string]ExtensionFactory
|
||||
logger *logging.Logger
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewManager creates a new extension manager
|
||||
func NewManager(logger *logging.Logger) *Manager {
|
||||
m := &Manager{
|
||||
extensions: make([]Extension, 0),
|
||||
registry: make(map[string]ExtensionFactory),
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
// Register built-in extensions
|
||||
m.RegisterFactory("routing", NewRoutingExtension)
|
||||
m.RegisterFactory("security", NewSecurityExtension)
|
||||
m.RegisterFactory("caching", NewCachingExtension)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// RegisterFactory registers an extension factory
|
||||
func (m *Manager) RegisterFactory(name string, factory ExtensionFactory) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.registry[name] = factory
|
||||
}
|
||||
|
||||
// LoadExtension loads an extension by type and config
|
||||
func (m *Manager) LoadExtension(extType string, config map[string]interface{}) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
factory, ok := m.registry[extType]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown extension type: %s", extType)
|
||||
}
|
||||
|
||||
ext, err := factory(config, m.logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create extension %s: %w", extType, err)
|
||||
}
|
||||
|
||||
if err := ext.Initialize(); err != nil {
|
||||
return fmt.Errorf("failed to initialize extension %s: %w", extType, err)
|
||||
}
|
||||
|
||||
m.extensions = append(m.extensions, ext)
|
||||
|
||||
// Sort by priority (lower first)
|
||||
sort.Slice(m.extensions, func(i, j int) bool {
|
||||
return m.extensions[i].Priority() < m.extensions[j].Priority()
|
||||
})
|
||||
|
||||
m.logger.Info("Loaded extension", "type", extType, "name", ext.Name(), "priority", ext.Priority())
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddExtension adds a pre-created extension
|
||||
func (m *Manager) AddExtension(ext Extension) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if err := ext.Initialize(); err != nil {
|
||||
return fmt.Errorf("failed to initialize extension %s: %w", ext.Name(), err)
|
||||
}
|
||||
|
||||
m.extensions = append(m.extensions, ext)
|
||||
|
||||
// Sort by priority
|
||||
sort.Slice(m.extensions, func(i, j int) bool {
|
||||
return m.extensions[i].Priority() < m.extensions[j].Priority()
|
||||
})
|
||||
|
||||
m.logger.Info("Added extension", "name", ext.Name(), "priority", ext.Priority())
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessRequest runs all extensions' ProcessRequest in order
|
||||
// Returns true if any extension handled the request
|
||||
func (m *Manager) ProcessRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
|
||||
m.mu.RLock()
|
||||
extensions := m.extensions
|
||||
m.mu.RUnlock()
|
||||
|
||||
for _, ext := range extensions {
|
||||
if !ext.Enabled() {
|
||||
continue
|
||||
}
|
||||
|
||||
handled, err := ext.ProcessRequest(ctx, w, r)
|
||||
if err != nil {
|
||||
m.logger.Error("Extension error", "extension", ext.Name(), "error", err)
|
||||
// Continue to next extension on error
|
||||
continue
|
||||
}
|
||||
|
||||
if handled {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ProcessResponse runs all extensions' ProcessResponse in reverse order
|
||||
func (m *Manager) ProcessResponse(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
m.mu.RLock()
|
||||
extensions := m.extensions
|
||||
m.mu.RUnlock()
|
||||
|
||||
// Process in reverse order for response
|
||||
for i := len(extensions) - 1; i >= 0; i-- {
|
||||
ext := extensions[i]
|
||||
if !ext.Enabled() {
|
||||
continue
|
||||
}
|
||||
|
||||
ext.ProcessResponse(ctx, w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup cleans up all extensions
|
||||
func (m *Manager) Cleanup() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for _, ext := range m.extensions {
|
||||
if err := ext.Cleanup(); err != nil {
|
||||
m.logger.Error("Extension cleanup error", "extension", ext.Name(), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
m.extensions = nil
|
||||
}
|
||||
|
||||
// GetExtension returns an extension by name
|
||||
func (m *Manager) GetExtension(name string) Extension {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Name() == name {
|
||||
return ext
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extensions returns all loaded extensions
|
||||
func (m *Manager) Extensions() []Extension {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make([]Extension, len(m.extensions))
|
||||
copy(result, m.extensions)
|
||||
return result
|
||||
}
|
||||
|
||||
// Handler returns an http.Handler that processes requests through all extensions
|
||||
func (m *Manager) Handler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Wrap response writer through all extensions that support it
|
||||
// Process in reverse priority order so highest priority wrapper is outermost
|
||||
wrappedWriter := w
|
||||
var finalizers []ResponseFinalizer
|
||||
|
||||
m.mu.RLock()
|
||||
extensions := m.extensions
|
||||
m.mu.RUnlock()
|
||||
|
||||
// Wrap response writer (lowest priority first, so they wrap in correct order)
|
||||
for _, ext := range extensions {
|
||||
if !ext.Enabled() {
|
||||
continue
|
||||
}
|
||||
if wrapper, ok := ext.(ResponseWriterWrapper); ok {
|
||||
wrappedWriter = wrapper.WrapResponseWriter(wrappedWriter, r)
|
||||
// Check if the wrapped writer implements Finalizer
|
||||
if finalizer, ok := wrappedWriter.(ResponseFinalizer); ok {
|
||||
finalizers = append(finalizers, finalizer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create response wrapper to capture status code
|
||||
responseWrapper := newResponseWrapper(wrappedWriter)
|
||||
|
||||
// Process request through extensions
|
||||
handled, err := m.ProcessRequest(ctx, responseWrapper, r)
|
||||
if err != nil {
|
||||
m.logger.Error("Error processing request", "error", err)
|
||||
}
|
||||
|
||||
if handled {
|
||||
// Extension handled the request, process response
|
||||
m.ProcessResponse(ctx, responseWrapper, r)
|
||||
// Finalize all response writers
|
||||
for i := len(finalizers) - 1; i >= 0; i-- {
|
||||
finalizers[i].Finalize()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// No extension handled, pass to next handler
|
||||
next.ServeHTTP(responseWrapper, r)
|
||||
|
||||
// Process response
|
||||
m.ProcessResponse(ctx, responseWrapper, r)
|
||||
|
||||
// Finalize all response writers
|
||||
for i := len(finalizers) - 1; i >= 0; i-- {
|
||||
finalizers[i].Finalize()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// responseWrapper wraps http.ResponseWriter to allow response modification
|
||||
type responseWrapper struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
written bool
|
||||
}
|
||||
|
||||
func newResponseWrapper(w http.ResponseWriter) *responseWrapper {
|
||||
return &responseWrapper{
|
||||
ResponseWriter: w,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
}
|
||||
|
||||
func (rw *responseWrapper) WriteHeader(code int) {
|
||||
if !rw.written {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
rw.written = true
|
||||
}
|
||||
}
|
||||
|
||||
func (rw *responseWrapper) Write(b []byte) (int, error) {
|
||||
if !rw.written {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return rw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (rw *responseWrapper) StatusCode() int {
|
||||
return rw.statusCode
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying ResponseWriter (for type assertions)
|
||||
func (rw *responseWrapper) Unwrap() http.ResponseWriter {
|
||||
return rw.ResponseWriter
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package extension
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/logging"
|
||||
)
|
||||
|
||||
func newTestLogger() *logging.Logger {
|
||||
logger, _ := logging.New(logging.Config{Level: "DEBUG"})
|
||||
return logger
|
||||
}
|
||||
|
||||
func TestNewManager(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
manager := NewManager(logger)
|
||||
|
||||
if manager == nil {
|
||||
t.Fatal("Expected manager, got nil")
|
||||
}
|
||||
|
||||
// Check built-in factories are registered
|
||||
if _, ok := manager.registry["routing"]; !ok {
|
||||
t.Error("Expected routing factory to be registered")
|
||||
}
|
||||
if _, ok := manager.registry["security"]; !ok {
|
||||
t.Error("Expected security factory to be registered")
|
||||
}
|
||||
if _, ok := manager.registry["caching"]; !ok {
|
||||
t.Error("Expected caching factory to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_LoadExtension(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
manager := NewManager(logger)
|
||||
|
||||
err := manager.LoadExtension("security", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Errorf("Failed to load security extension: %v", err)
|
||||
}
|
||||
|
||||
exts := manager.Extensions()
|
||||
if len(exts) != 1 {
|
||||
t.Errorf("Expected 1 extension, got %d", len(exts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_LoadExtension_Unknown(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
manager := NewManager(logger)
|
||||
|
||||
err := manager.LoadExtension("unknown", map[string]interface{}{})
|
||||
if err == nil {
|
||||
t.Error("Expected error for unknown extension type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_GetExtension(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
manager := NewManager(logger)
|
||||
|
||||
manager.LoadExtension("security", map[string]interface{}{})
|
||||
|
||||
ext := manager.GetExtension("security")
|
||||
if ext == nil {
|
||||
t.Error("Expected to find security extension")
|
||||
}
|
||||
|
||||
ext = manager.GetExtension("nonexistent")
|
||||
if ext != nil {
|
||||
t.Error("Expected nil for nonexistent extension")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_ProcessRequest(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
manager := NewManager(logger)
|
||||
|
||||
// Load security extension with blocked IP
|
||||
manager.LoadExtension("security", map[string]interface{}{
|
||||
"blocked_ips": []interface{}{"192.168.1.1"},
|
||||
})
|
||||
|
||||
// Create test request
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.RemoteAddr = "192.168.1.1:12345"
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handled, err := manager.ProcessRequest(context.Background(), rr, req)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !handled {
|
||||
t.Error("Expected request to be handled (blocked)")
|
||||
}
|
||||
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Errorf("Expected status 403, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_Handler(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
manager := NewManager(logger)
|
||||
|
||||
// Load routing extension with a simple route
|
||||
manager.LoadExtension("routing", map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"=/health": map[string]interface{}{
|
||||
"return": "200 OK",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
baseHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
|
||||
handler := manager.Handler(baseHandler)
|
||||
|
||||
// Test health route
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_Priority(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
manager := NewManager(logger)
|
||||
|
||||
// Load extensions in any order
|
||||
manager.LoadExtension("routing", map[string]interface{}{}) // Priority 50
|
||||
manager.LoadExtension("security", map[string]interface{}{}) // Priority 10
|
||||
manager.LoadExtension("caching", map[string]interface{}{}) // Priority 20
|
||||
|
||||
exts := manager.Extensions()
|
||||
if len(exts) != 3 {
|
||||
t.Fatalf("Expected 3 extensions, got %d", len(exts))
|
||||
}
|
||||
|
||||
// Check order by priority
|
||||
if exts[0].Name() != "security" {
|
||||
t.Errorf("Expected security first, got %s", exts[0].Name())
|
||||
}
|
||||
if exts[1].Name() != "caching" {
|
||||
t.Errorf("Expected caching second, got %s", exts[1].Name())
|
||||
}
|
||||
if exts[2].Name() != "routing" {
|
||||
t.Errorf("Expected routing third, got %s", exts[2].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_Cleanup(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
manager := NewManager(logger)
|
||||
|
||||
manager.LoadExtension("security", map[string]interface{}{})
|
||||
manager.LoadExtension("routing", map[string]interface{}{})
|
||||
|
||||
manager.Cleanup()
|
||||
|
||||
exts := manager.Extensions()
|
||||
if len(exts) != 0 {
|
||||
t.Errorf("Expected 0 extensions after cleanup, got %d", len(exts))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package extension
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/logging"
|
||||
"github.com/konduktor/konduktor/internal/proxy"
|
||||
)
|
||||
|
||||
// RoutingExtension handles request routing based on patterns
|
||||
type RoutingExtension struct {
|
||||
BaseExtension
|
||||
exactRoutes map[string]RouteConfig
|
||||
regexRoutes []*regexRoute
|
||||
defaultRoute *RouteConfig
|
||||
staticDir string
|
||||
}
|
||||
|
||||
// RouteConfig holds configuration for a route
|
||||
type RouteConfig struct {
|
||||
ProxyPass string
|
||||
Root string
|
||||
IndexFile string
|
||||
Return string
|
||||
ContentType string
|
||||
Headers []string
|
||||
CacheControl string
|
||||
SPAFallback bool
|
||||
ExcludePatterns []string
|
||||
Timeout float64
|
||||
}
|
||||
|
||||
type regexRoute struct {
|
||||
pattern *regexp.Regexp
|
||||
config RouteConfig
|
||||
caseSensitive bool
|
||||
originalExpr string
|
||||
}
|
||||
|
||||
// NewRoutingExtension creates a new routing extension
|
||||
func NewRoutingExtension(config map[string]interface{}, logger *logging.Logger) (Extension, error) {
|
||||
ext := &RoutingExtension{
|
||||
BaseExtension: NewBaseExtension("routing", 50, logger), // Middle priority
|
||||
exactRoutes: make(map[string]RouteConfig),
|
||||
regexRoutes: make([]*regexRoute, 0),
|
||||
staticDir: "./static",
|
||||
}
|
||||
|
||||
logger.Debug("Routing extension config", "config", config)
|
||||
|
||||
// Parse regex_locations from config
|
||||
if locations, ok := config["regex_locations"].(map[string]interface{}); ok {
|
||||
logger.Debug("Found regex_locations", "count", len(locations))
|
||||
for pattern, routeCfg := range locations {
|
||||
logger.Debug("Adding route", "pattern", pattern)
|
||||
if rc, ok := routeCfg.(map[string]interface{}); ok {
|
||||
ext.addRoute(pattern, parseRouteConfig(rc))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.Warn("No regex_locations found in config", "config_keys", getKeys(config))
|
||||
}
|
||||
|
||||
// Parse static_dir if provided
|
||||
if staticDir, ok := config["static_dir"].(string); ok {
|
||||
ext.staticDir = staticDir
|
||||
}
|
||||
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func parseRouteConfig(cfg map[string]interface{}) RouteConfig {
|
||||
rc := RouteConfig{
|
||||
IndexFile: "index.html",
|
||||
}
|
||||
|
||||
if v, ok := cfg["proxy_pass"].(string); ok {
|
||||
rc.ProxyPass = v
|
||||
}
|
||||
if v, ok := cfg["root"].(string); ok {
|
||||
rc.Root = v
|
||||
}
|
||||
if v, ok := cfg["index_file"].(string); ok {
|
||||
rc.IndexFile = v
|
||||
}
|
||||
if v, ok := cfg["return"].(string); ok {
|
||||
rc.Return = v
|
||||
}
|
||||
if v, ok := cfg["content_type"].(string); ok {
|
||||
rc.ContentType = v
|
||||
}
|
||||
if v, ok := cfg["cache_control"].(string); ok {
|
||||
rc.CacheControl = v
|
||||
}
|
||||
if v, ok := cfg["spa_fallback"].(bool); ok {
|
||||
rc.SPAFallback = v
|
||||
}
|
||||
if v, ok := cfg["timeout"].(float64); ok {
|
||||
rc.Timeout = v
|
||||
}
|
||||
|
||||
// Parse headers
|
||||
if headers, ok := cfg["headers"].([]interface{}); ok {
|
||||
for _, h := range headers {
|
||||
if header, ok := h.(string); ok {
|
||||
rc.Headers = append(rc.Headers, header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse exclude_patterns
|
||||
if patterns, ok := cfg["exclude_patterns"].([]interface{}); ok {
|
||||
for _, p := range patterns {
|
||||
if pattern, ok := p.(string); ok {
|
||||
rc.ExcludePatterns = append(rc.ExcludePatterns, pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rc
|
||||
}
|
||||
|
||||
func (e *RoutingExtension) addRoute(pattern string, config RouteConfig) {
|
||||
switch {
|
||||
case pattern == "__default__":
|
||||
e.defaultRoute = &config
|
||||
|
||||
case strings.HasPrefix(pattern, "="):
|
||||
// Exact match
|
||||
path := strings.TrimPrefix(pattern, "=")
|
||||
e.exactRoutes[path] = config
|
||||
|
||||
case strings.HasPrefix(pattern, "~*"):
|
||||
// Case-insensitive regex
|
||||
expr := strings.TrimPrefix(pattern, "~*")
|
||||
re, err := regexp.Compile("(?i)" + expr)
|
||||
if err != nil {
|
||||
e.logger.Error("Invalid regex pattern", "pattern", pattern, "error", err)
|
||||
return
|
||||
}
|
||||
e.regexRoutes = append(e.regexRoutes, ®exRoute{
|
||||
pattern: re,
|
||||
config: config,
|
||||
caseSensitive: false,
|
||||
originalExpr: expr,
|
||||
})
|
||||
|
||||
case strings.HasPrefix(pattern, "~"):
|
||||
// Case-sensitive regex
|
||||
expr := strings.TrimPrefix(pattern, "~")
|
||||
re, err := regexp.Compile(expr)
|
||||
if err != nil {
|
||||
e.logger.Error("Invalid regex pattern", "pattern", pattern, "error", err)
|
||||
return
|
||||
}
|
||||
e.regexRoutes = append(e.regexRoutes, ®exRoute{
|
||||
pattern: re,
|
||||
config: config,
|
||||
caseSensitive: true,
|
||||
originalExpr: expr,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessRequest handles the request routing
|
||||
func (e *RoutingExtension) ProcessRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
|
||||
path := r.URL.Path
|
||||
|
||||
// 1. Check exact routes (ignore request path for proxy)
|
||||
if config, ok := e.exactRoutes[path]; ok {
|
||||
return e.handleRoute(w, r, config, nil, true)
|
||||
}
|
||||
|
||||
// 2. Check regex routes
|
||||
for _, route := range e.regexRoutes {
|
||||
match := route.pattern.FindStringSubmatch(path)
|
||||
if match != nil {
|
||||
params := make(map[string]string)
|
||||
names := route.pattern.SubexpNames()
|
||||
for i, name := range names {
|
||||
if i > 0 && name != "" && i < len(match) {
|
||||
params[name] = match[i]
|
||||
}
|
||||
}
|
||||
return e.handleRoute(w, r, route.config, params, false)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check default route
|
||||
if e.defaultRoute != nil {
|
||||
return e.handleRoute(w, r, *e.defaultRoute, nil, false)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (e *RoutingExtension) handleRoute(w http.ResponseWriter, r *http.Request, config RouteConfig, params map[string]string, exactMatch bool) (bool, error) {
|
||||
// Handle "return" directive
|
||||
if config.Return != "" {
|
||||
return e.handleReturn(w, config)
|
||||
}
|
||||
|
||||
// Handle proxy_pass
|
||||
if config.ProxyPass != "" {
|
||||
return e.handleProxy(w, r, config, params, exactMatch)
|
||||
}
|
||||
|
||||
// Handle static files with root
|
||||
if config.Root != "" {
|
||||
return e.handleStatic(w, r, config)
|
||||
}
|
||||
|
||||
// Handle SPA fallback
|
||||
if config.SPAFallback {
|
||||
return e.handleSPAFallback(w, r, config)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (e *RoutingExtension) handleReturn(w http.ResponseWriter, config RouteConfig) (bool, error) {
|
||||
parts := strings.SplitN(config.Return, " ", 2)
|
||||
statusCode := 200
|
||||
body := "OK"
|
||||
|
||||
if len(parts) >= 1 {
|
||||
switch parts[0] {
|
||||
case "200":
|
||||
statusCode = 200
|
||||
case "201":
|
||||
statusCode = 201
|
||||
case "301":
|
||||
statusCode = 301
|
||||
case "302":
|
||||
statusCode = 302
|
||||
case "400":
|
||||
statusCode = 400
|
||||
case "404":
|
||||
statusCode = 404
|
||||
case "500":
|
||||
statusCode = 500
|
||||
}
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
body = parts[1]
|
||||
}
|
||||
|
||||
contentType := "text/plain"
|
||||
if config.ContentType != "" {
|
||||
contentType = config.ContentType
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.WriteHeader(statusCode)
|
||||
w.Write([]byte(body))
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (e *RoutingExtension) handleProxy(w http.ResponseWriter, r *http.Request, config RouteConfig, params map[string]string, exactMatch bool) (bool, error) {
|
||||
target := config.ProxyPass
|
||||
|
||||
// Check if target URL contains parameter placeholders
|
||||
hasParams := strings.Contains(target, "{") && strings.Contains(target, "}")
|
||||
|
||||
// Substitute params in target URL
|
||||
for key, value := range params {
|
||||
target = strings.ReplaceAll(target, "{"+key+"}", value)
|
||||
}
|
||||
|
||||
// Create proxy config
|
||||
// IgnoreRequestPath=true when:
|
||||
// - exact match route (=/path)
|
||||
// - target URL had parameter substitutions (the target path is fully specified)
|
||||
proxyConfig := &proxy.Config{
|
||||
Target: target,
|
||||
Headers: make(map[string]string),
|
||||
IgnoreRequestPath: exactMatch || hasParams,
|
||||
}
|
||||
|
||||
// Set timeout if specified
|
||||
if config.Timeout > 0 {
|
||||
proxyConfig.Timeout = time.Duration(config.Timeout * float64(time.Second))
|
||||
}
|
||||
|
||||
// Parse headers
|
||||
clientIP := getClientIP(r)
|
||||
for _, header := range config.Headers {
|
||||
parts := strings.SplitN(header, ": ", 2)
|
||||
if len(parts) == 2 {
|
||||
value := parts[1]
|
||||
// Substitute params
|
||||
for key, pValue := range params {
|
||||
value = strings.ReplaceAll(value, "{"+key+"}", pValue)
|
||||
}
|
||||
// Substitute special variables
|
||||
value = strings.ReplaceAll(value, "$remote_addr", clientIP)
|
||||
proxyConfig.Headers[parts[0]] = value
|
||||
}
|
||||
}
|
||||
|
||||
p, err := proxy.New(proxyConfig, e.logger)
|
||||
if err != nil {
|
||||
e.logger.Error("Failed to create proxy", "target", target, "error", err)
|
||||
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
p.ProxyRequest(w, r, params)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (e *RoutingExtension) handleStatic(w http.ResponseWriter, r *http.Request, config RouteConfig) (bool, error) {
|
||||
path := r.URL.Path
|
||||
|
||||
// Handle index file for root or directory paths
|
||||
if path == "/" || strings.HasSuffix(path, "/") {
|
||||
path = "/" + config.IndexFile
|
||||
}
|
||||
|
||||
// Get absolute path for root dir
|
||||
absRoot, err := filepath.Abs(config.Root)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
filePath := filepath.Join(absRoot, filepath.Clean("/"+path))
|
||||
cleanPath := filepath.Clean(filePath)
|
||||
|
||||
// Prevent directory traversal
|
||||
if !strings.HasPrefix(cleanPath+string(filepath.Separator), absRoot+string(filepath.Separator)) {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
return false, nil // Let other handlers try
|
||||
}
|
||||
|
||||
// Set cache control header
|
||||
if config.CacheControl != "" {
|
||||
w.Header().Set("Cache-Control", config.CacheControl)
|
||||
}
|
||||
|
||||
// Set custom headers
|
||||
for _, header := range config.Headers {
|
||||
parts := strings.SplitN(header, ": ", 2)
|
||||
if len(parts) == 2 {
|
||||
w.Header().Set(parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, filePath)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (e *RoutingExtension) handleSPAFallback(w http.ResponseWriter, r *http.Request, config RouteConfig) (bool, error) {
|
||||
path := r.URL.Path
|
||||
|
||||
// Check exclude patterns
|
||||
for _, pattern := range config.ExcludePatterns {
|
||||
if strings.HasPrefix(path, pattern) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
root := config.Root
|
||||
if root == "" {
|
||||
root = e.staticDir
|
||||
}
|
||||
|
||||
indexFile := config.IndexFile
|
||||
if indexFile == "" {
|
||||
indexFile = "index.html"
|
||||
}
|
||||
|
||||
filePath := filepath.Join(root, indexFile)
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, filePath)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func getClientIP(r *http.Request) string {
|
||||
// Check X-Forwarded-For header first
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
|
||||
// Check X-Real-IP header
|
||||
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||
return xri
|
||||
}
|
||||
|
||||
// Fall back to RemoteAddr
|
||||
ip := r.RemoteAddr
|
||||
if idx := strings.LastIndex(ip, ":"); idx != -1 {
|
||||
ip = ip[:idx]
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
// GetMetrics returns routing metrics
|
||||
func (e *RoutingExtension) GetMetrics() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"exact_routes": len(e.exactRoutes),
|
||||
"regex_routes": len(e.regexRoutes),
|
||||
"has_default": e.defaultRoute != nil,
|
||||
}
|
||||
}
|
||||
|
||||
func getKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package extension
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/logging"
|
||||
)
|
||||
|
||||
// SecurityExtension provides security features like IP filtering and security headers
|
||||
type SecurityExtension struct {
|
||||
BaseExtension
|
||||
allowedIPs map[string]bool
|
||||
blockedIPs map[string]bool
|
||||
allowedCIDRs []*net.IPNet
|
||||
blockedCIDRs []*net.IPNet
|
||||
securityHeaders map[string]string
|
||||
|
||||
// Rate limiting
|
||||
rateLimitEnabled bool
|
||||
rateLimitRequests int
|
||||
rateLimitWindow time.Duration
|
||||
rateLimitByIP map[string]*rateLimitEntry
|
||||
rateLimitMu sync.RWMutex
|
||||
}
|
||||
|
||||
type rateLimitEntry struct {
|
||||
count int
|
||||
resetTime time.Time
|
||||
}
|
||||
|
||||
// SecurityConfig holds security extension configuration
|
||||
type SecurityConfig struct {
|
||||
AllowedIPs []string `yaml:"allowed_ips"`
|
||||
BlockedIPs []string `yaml:"blocked_ips"`
|
||||
SecurityHeaders map[string]string `yaml:"security_headers"`
|
||||
RateLimit *RateLimitConfig `yaml:"rate_limit"`
|
||||
}
|
||||
|
||||
// RateLimitConfig holds rate limiting configuration
|
||||
type RateLimitConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Requests int `yaml:"requests"`
|
||||
Window string `yaml:"window"` // e.g., "1m", "1h"
|
||||
}
|
||||
|
||||
// Default security headers
|
||||
var defaultSecurityHeaders = map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
}
|
||||
|
||||
// NewSecurityExtension creates a new security extension
|
||||
func NewSecurityExtension(config map[string]interface{}, logger *logging.Logger) (Extension, error) {
|
||||
ext := &SecurityExtension{
|
||||
BaseExtension: NewBaseExtension("security", 10, logger), // High priority (early execution)
|
||||
allowedIPs: make(map[string]bool),
|
||||
blockedIPs: make(map[string]bool),
|
||||
allowedCIDRs: make([]*net.IPNet, 0),
|
||||
blockedCIDRs: make([]*net.IPNet, 0),
|
||||
securityHeaders: make(map[string]string),
|
||||
rateLimitByIP: make(map[string]*rateLimitEntry),
|
||||
}
|
||||
|
||||
// Copy default security headers
|
||||
for k, v := range defaultSecurityHeaders {
|
||||
ext.securityHeaders[k] = v
|
||||
}
|
||||
|
||||
// Parse allowed_ips
|
||||
if allowedIPs, ok := config["allowed_ips"].([]interface{}); ok {
|
||||
for _, ip := range allowedIPs {
|
||||
if ipStr, ok := ip.(string); ok {
|
||||
ext.addAllowedIP(ipStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse blocked_ips
|
||||
if blockedIPs, ok := config["blocked_ips"].([]interface{}); ok {
|
||||
for _, ip := range blockedIPs {
|
||||
if ipStr, ok := ip.(string); ok {
|
||||
ext.addBlockedIP(ipStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse security_headers
|
||||
if headers, ok := config["security_headers"].(map[string]interface{}); ok {
|
||||
for k, v := range headers {
|
||||
if vStr, ok := v.(string); ok {
|
||||
ext.securityHeaders[k] = vStr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse rate_limit
|
||||
if rateLimit, ok := config["rate_limit"].(map[string]interface{}); ok {
|
||||
if enabled, ok := rateLimit["enabled"].(bool); ok && enabled {
|
||||
ext.rateLimitEnabled = true
|
||||
|
||||
if requests, ok := rateLimit["requests"].(int); ok {
|
||||
ext.rateLimitRequests = requests
|
||||
} else if requestsFloat, ok := rateLimit["requests"].(float64); ok {
|
||||
ext.rateLimitRequests = int(requestsFloat)
|
||||
} else {
|
||||
ext.rateLimitRequests = 100 // default
|
||||
}
|
||||
|
||||
if window, ok := rateLimit["window"].(string); ok {
|
||||
if duration, err := time.ParseDuration(window); err == nil {
|
||||
ext.rateLimitWindow = duration
|
||||
} else {
|
||||
ext.rateLimitWindow = time.Minute // default
|
||||
}
|
||||
} else {
|
||||
ext.rateLimitWindow = time.Minute
|
||||
}
|
||||
|
||||
logger.Info("Rate limiting enabled",
|
||||
"requests", ext.rateLimitRequests,
|
||||
"window", ext.rateLimitWindow.String())
|
||||
}
|
||||
}
|
||||
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func (e *SecurityExtension) addAllowedIP(ip string) {
|
||||
if strings.Contains(ip, "/") {
|
||||
// CIDR notation
|
||||
_, cidr, err := net.ParseCIDR(ip)
|
||||
if err == nil {
|
||||
e.allowedCIDRs = append(e.allowedCIDRs, cidr)
|
||||
}
|
||||
} else {
|
||||
e.allowedIPs[ip] = true
|
||||
}
|
||||
}
|
||||
|
||||
func (e *SecurityExtension) addBlockedIP(ip string) {
|
||||
if strings.Contains(ip, "/") {
|
||||
// CIDR notation
|
||||
_, cidr, err := net.ParseCIDR(ip)
|
||||
if err == nil {
|
||||
e.blockedCIDRs = append(e.blockedCIDRs, cidr)
|
||||
}
|
||||
} else {
|
||||
e.blockedIPs[ip] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessRequest checks security rules
|
||||
func (e *SecurityExtension) ProcessRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
|
||||
clientIP := getClientIP(r)
|
||||
parsedIP := net.ParseIP(clientIP)
|
||||
|
||||
// Check blocked IPs first
|
||||
if e.isBlocked(clientIP, parsedIP) {
|
||||
e.logger.Warn("Blocked request from IP", "ip", clientIP)
|
||||
http.Error(w, "403 Forbidden", http.StatusForbidden)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Check allowed IPs (if configured, only these IPs are allowed)
|
||||
if len(e.allowedIPs) > 0 || len(e.allowedCIDRs) > 0 {
|
||||
if !e.isAllowed(clientIP, parsedIP) {
|
||||
e.logger.Warn("Access denied for IP", "ip", clientIP)
|
||||
http.Error(w, "403 Forbidden", http.StatusForbidden)
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
if e.rateLimitEnabled {
|
||||
if !e.checkRateLimit(clientIP) {
|
||||
e.logger.Warn("Rate limit exceeded", "ip", clientIP)
|
||||
w.Header().Set("Retry-After", "60")
|
||||
http.Error(w, "429 Too Many Requests", http.StatusTooManyRequests)
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ProcessResponse adds security headers to the response
|
||||
func (e *SecurityExtension) ProcessResponse(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
for header, value := range e.securityHeaders {
|
||||
w.Header().Set(header, value)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *SecurityExtension) isBlocked(ip string, parsedIP net.IP) bool {
|
||||
// Check exact match
|
||||
if e.blockedIPs[ip] {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check CIDR ranges
|
||||
if parsedIP != nil {
|
||||
for _, cidr := range e.blockedCIDRs {
|
||||
if cidr.Contains(parsedIP) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *SecurityExtension) isAllowed(ip string, parsedIP net.IP) bool {
|
||||
// Check exact match
|
||||
if e.allowedIPs[ip] {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check CIDR ranges
|
||||
if parsedIP != nil {
|
||||
for _, cidr := range e.allowedCIDRs {
|
||||
if cidr.Contains(parsedIP) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *SecurityExtension) checkRateLimit(ip string) bool {
|
||||
e.rateLimitMu.Lock()
|
||||
defer e.rateLimitMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
entry, exists := e.rateLimitByIP[ip]
|
||||
|
||||
if !exists || now.After(entry.resetTime) {
|
||||
// Create new entry or reset expired one
|
||||
e.rateLimitByIP[ip] = &rateLimitEntry{
|
||||
count: 1,
|
||||
resetTime: now.Add(e.rateLimitWindow),
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
entry.count++
|
||||
return entry.count <= e.rateLimitRequests
|
||||
}
|
||||
|
||||
// AddBlockedIP adds an IP to the blocked list at runtime
|
||||
func (e *SecurityExtension) AddBlockedIP(ip string) {
|
||||
e.addBlockedIP(ip)
|
||||
}
|
||||
|
||||
// RemoveBlockedIP removes an IP from the blocked list
|
||||
func (e *SecurityExtension) RemoveBlockedIP(ip string) {
|
||||
delete(e.blockedIPs, ip)
|
||||
}
|
||||
|
||||
// AddAllowedIP adds an IP to the allowed list at runtime
|
||||
func (e *SecurityExtension) AddAllowedIP(ip string) {
|
||||
e.addAllowedIP(ip)
|
||||
}
|
||||
|
||||
// RemoveAllowedIP removes an IP from the allowed list
|
||||
func (e *SecurityExtension) RemoveAllowedIP(ip string) {
|
||||
delete(e.allowedIPs, ip)
|
||||
}
|
||||
|
||||
// SetSecurityHeader sets or updates a security header
|
||||
func (e *SecurityExtension) SetSecurityHeader(name, value string) {
|
||||
e.securityHeaders[name] = value
|
||||
}
|
||||
|
||||
// GetMetrics returns security metrics
|
||||
func (e *SecurityExtension) GetMetrics() map[string]interface{} {
|
||||
e.rateLimitMu.RLock()
|
||||
activeRateLimits := len(e.rateLimitByIP)
|
||||
e.rateLimitMu.RUnlock()
|
||||
|
||||
return map[string]interface{}{
|
||||
"allowed_ips": len(e.allowedIPs),
|
||||
"allowed_cidrs": len(e.allowedCIDRs),
|
||||
"blocked_ips": len(e.blockedIPs),
|
||||
"blocked_cidrs": len(e.blockedCIDRs),
|
||||
"security_headers": len(e.securityHeaders),
|
||||
"rate_limit_enabled": e.rateLimitEnabled,
|
||||
"active_rate_limits": activeRateLimits,
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup cleans up rate limit entries periodically
|
||||
func (e *SecurityExtension) Cleanup() error {
|
||||
e.rateLimitMu.Lock()
|
||||
defer e.rateLimitMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for ip, entry := range e.rateLimitByIP {
|
||||
if now.After(entry.resetTime) {
|
||||
delete(e.rateLimitByIP, ip)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package extension
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewSecurityExtension(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
|
||||
ext, err := NewSecurityExtension(map[string]interface{}{}, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create security extension: %v", err)
|
||||
}
|
||||
|
||||
if ext.Name() != "security" {
|
||||
t.Errorf("Expected name 'security', got %s", ext.Name())
|
||||
}
|
||||
|
||||
if ext.Priority() != 10 {
|
||||
t.Errorf("Expected priority 10, got %d", ext.Priority())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityExtension_BlockedIP(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
|
||||
ext, _ := NewSecurityExtension(map[string]interface{}{
|
||||
"blocked_ips": []interface{}{"192.168.1.100"},
|
||||
}, logger)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.RemoteAddr = "192.168.1.100:12345"
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handled, err := ext.ProcessRequest(context.Background(), rr, req)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !handled {
|
||||
t.Error("Expected blocked request to be handled")
|
||||
}
|
||||
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Errorf("Expected status 403, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityExtension_AllowedIP(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
|
||||
ext, _ := NewSecurityExtension(map[string]interface{}{
|
||||
"allowed_ips": []interface{}{"192.168.1.50"},
|
||||
}, logger)
|
||||
|
||||
// Allowed IP
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.RemoteAddr = "192.168.1.50:12345"
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handled, _ := ext.ProcessRequest(context.Background(), rr, req)
|
||||
if handled {
|
||||
t.Error("Expected allowed IP to pass through")
|
||||
}
|
||||
|
||||
// Not allowed IP
|
||||
req = httptest.NewRequest("GET", "/test", nil)
|
||||
req.RemoteAddr = "192.168.1.51:12345"
|
||||
rr = httptest.NewRecorder()
|
||||
|
||||
handled, _ = ext.ProcessRequest(context.Background(), rr, req)
|
||||
if !handled {
|
||||
t.Error("Expected non-allowed IP to be blocked")
|
||||
}
|
||||
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Errorf("Expected status 403, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityExtension_CIDR(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
|
||||
ext, _ := NewSecurityExtension(map[string]interface{}{
|
||||
"blocked_ips": []interface{}{"10.0.0.0/8"},
|
||||
}, logger)
|
||||
|
||||
// IP in blocked CIDR
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.RemoteAddr = "10.1.2.3:12345"
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handled, _ := ext.ProcessRequest(context.Background(), rr, req)
|
||||
if !handled {
|
||||
t.Error("Expected IP in blocked CIDR to be blocked")
|
||||
}
|
||||
|
||||
// IP not in blocked CIDR
|
||||
req = httptest.NewRequest("GET", "/test", nil)
|
||||
req.RemoteAddr = "192.168.1.1:12345"
|
||||
rr = httptest.NewRecorder()
|
||||
|
||||
handled, _ = ext.ProcessRequest(context.Background(), rr, req)
|
||||
if handled {
|
||||
t.Error("Expected IP not in blocked CIDR to pass through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityExtension_SecurityHeaders(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
|
||||
ext, _ := NewSecurityExtension(map[string]interface{}{
|
||||
"security_headers": map[string]interface{}{
|
||||
"X-Custom-Header": "custom-value",
|
||||
},
|
||||
}, logger)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
ext.ProcessResponse(context.Background(), rr, req)
|
||||
|
||||
// Check default headers
|
||||
if rr.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Error("Expected X-Content-Type-Options header")
|
||||
}
|
||||
|
||||
// Check custom header
|
||||
if rr.Header().Get("X-Custom-Header") != "custom-value" {
|
||||
t.Error("Expected custom header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityExtension_RateLimit(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
|
||||
ext, _ := NewSecurityExtension(map[string]interface{}{
|
||||
"rate_limit": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"requests": 2,
|
||||
"window": "1m",
|
||||
},
|
||||
}, logger)
|
||||
|
||||
securityExt := ext.(*SecurityExtension)
|
||||
clientIP := "192.168.1.1"
|
||||
|
||||
// First request - should pass
|
||||
if !securityExt.checkRateLimit(clientIP) {
|
||||
t.Error("First request should pass")
|
||||
}
|
||||
|
||||
// Second request - should pass
|
||||
if !securityExt.checkRateLimit(clientIP) {
|
||||
t.Error("Second request should pass")
|
||||
}
|
||||
|
||||
// Third request - should be rate limited
|
||||
if securityExt.checkRateLimit(clientIP) {
|
||||
t.Error("Third request should be rate limited")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityExtension_GetMetrics(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
|
||||
ext, _ := NewSecurityExtension(map[string]interface{}{
|
||||
"blocked_ips": []interface{}{"192.168.1.1"},
|
||||
"allowed_ips": []interface{}{"192.168.1.2"},
|
||||
}, logger)
|
||||
|
||||
securityExt := ext.(*SecurityExtension)
|
||||
metrics := securityExt.GetMetrics()
|
||||
|
||||
if metrics["blocked_ips"].(int) != 1 {
|
||||
t.Errorf("Expected 1 blocked IP, got %v", metrics["blocked_ips"])
|
||||
}
|
||||
|
||||
if metrics["allowed_ips"].(int) != 1 {
|
||||
t.Errorf("Expected 1 allowed IP, got %v", metrics["allowed_ips"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityExtension_AddRemoveIPs(t *testing.T) {
|
||||
logger := newTestLogger()
|
||||
|
||||
ext, _ := NewSecurityExtension(map[string]interface{}{}, logger)
|
||||
securityExt := ext.(*SecurityExtension)
|
||||
|
||||
// Add blocked IP
|
||||
securityExt.AddBlockedIP("192.168.1.100")
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.RemoteAddr = "192.168.1.100:12345"
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handled, _ := ext.ProcessRequest(context.Background(), rr, req)
|
||||
if !handled {
|
||||
t.Error("Expected dynamically blocked IP to be blocked")
|
||||
}
|
||||
|
||||
// Remove blocked IP
|
||||
securityExt.RemoveBlockedIP("192.168.1.100")
|
||||
|
||||
rr = httptest.NewRecorder()
|
||||
handled, _ = ext.ProcessRequest(context.Background(), rr, req)
|
||||
if handled {
|
||||
t.Error("Expected removed blocked IP to pass through")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
// Package logging provides structured logging with zap
|
||||
package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/config"
|
||||
)
|
||||
|
||||
// Config is a simple configuration for basic logger setup
|
||||
type Config struct {
|
||||
Level string
|
||||
TimestampFormat string
|
||||
}
|
||||
|
||||
// Logger wraps zap.SugaredLogger with additional functionality
|
||||
type Logger struct {
|
||||
*zap.SugaredLogger
|
||||
zap *zap.Logger
|
||||
config *config.LoggingConfig
|
||||
name string
|
||||
}
|
||||
|
||||
// New creates a new Logger with basic configuration
|
||||
func New(cfg Config) (*Logger, error) {
|
||||
level := parseLevel(cfg.Level)
|
||||
|
||||
timestampFormat := cfg.TimestampFormat
|
||||
if timestampFormat == "" {
|
||||
timestampFormat = "2006-01-02 15:04:05"
|
||||
}
|
||||
|
||||
encoderConfig := zap.NewProductionEncoderConfig()
|
||||
encoderConfig.TimeKey = "timestamp"
|
||||
encoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout(timestampFormat)
|
||||
encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||
|
||||
core := zapcore.NewCore(
|
||||
zapcore.NewConsoleEncoder(encoderConfig),
|
||||
zapcore.AddSync(os.Stdout),
|
||||
level,
|
||||
)
|
||||
|
||||
zapLogger := zap.New(core)
|
||||
return &Logger{
|
||||
SugaredLogger: zapLogger.Sugar(),
|
||||
zap: zapLogger,
|
||||
name: "konduktor",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewFromConfig creates a Logger from full LoggingConfig
|
||||
func NewFromConfig(cfg config.LoggingConfig) (*Logger, error) {
|
||||
var cores []zapcore.Core
|
||||
|
||||
// Parse main level
|
||||
mainLevel := parseLevel(cfg.Level)
|
||||
|
||||
// Add console core if enabled
|
||||
if cfg.ConsoleOutput {
|
||||
consoleLevel := mainLevel
|
||||
if cfg.Console != nil && cfg.Console.Level != "" {
|
||||
consoleLevel = parseLevel(cfg.Console.Level)
|
||||
}
|
||||
|
||||
var consoleEncoder zapcore.Encoder
|
||||
formatConfig := cfg.Format
|
||||
if cfg.Console != nil {
|
||||
formatConfig = mergeFormatConfig(cfg.Format, cfg.Console.Format)
|
||||
}
|
||||
|
||||
encoderCfg := createEncoderConfig(formatConfig)
|
||||
if formatConfig.Type == "json" {
|
||||
consoleEncoder = zapcore.NewJSONEncoder(encoderCfg)
|
||||
} else {
|
||||
if formatConfig.UseColors {
|
||||
encoderCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||
}
|
||||
consoleEncoder = zapcore.NewConsoleEncoder(encoderCfg)
|
||||
}
|
||||
|
||||
consoleSyncer := zapcore.AddSync(os.Stdout)
|
||||
cores = append(cores, zapcore.NewCore(consoleEncoder, consoleSyncer, consoleLevel))
|
||||
}
|
||||
|
||||
// Add file cores
|
||||
for _, fileConfig := range cfg.Files {
|
||||
fileCore, err := createFileCore(fileConfig, cfg.Format, mainLevel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create file logger for %s: %w", fileConfig.Path, err)
|
||||
}
|
||||
|
||||
// If specific loggers are configured, wrap with filter
|
||||
if len(fileConfig.Loggers) > 0 {
|
||||
fileCore = &filteredCore{
|
||||
Core: fileCore,
|
||||
loggers: fileConfig.Loggers,
|
||||
}
|
||||
}
|
||||
|
||||
cores = append(cores, fileCore)
|
||||
}
|
||||
|
||||
// If no cores configured, add default console
|
||||
if len(cores) == 0 {
|
||||
encoderCfg := zap.NewProductionEncoderConfig()
|
||||
encoderCfg.EncodeTime = zapcore.TimeEncoderOfLayout("2006-01-02 15:04:05")
|
||||
encoderCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||
cores = append(cores, zapcore.NewCore(
|
||||
zapcore.NewConsoleEncoder(encoderCfg),
|
||||
zapcore.AddSync(os.Stdout),
|
||||
mainLevel,
|
||||
))
|
||||
}
|
||||
|
||||
// Combine all cores
|
||||
core := zapcore.NewTee(cores...)
|
||||
zapLogger := zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1))
|
||||
|
||||
return &Logger{
|
||||
SugaredLogger: zapLogger.Sugar(),
|
||||
zap: zapLogger,
|
||||
config: &cfg,
|
||||
name: "konduktor",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Named returns a logger with a specific name (for filtering)
|
||||
func (l *Logger) Named(name string) *Logger {
|
||||
return &Logger{
|
||||
SugaredLogger: l.SugaredLogger.Named(name),
|
||||
zap: l.zap.Named(name),
|
||||
config: l.config,
|
||||
name: name,
|
||||
}
|
||||
}
|
||||
|
||||
// With returns a logger with additional fields
|
||||
func (l *Logger) With(args ...interface{}) *Logger {
|
||||
return &Logger{
|
||||
SugaredLogger: l.SugaredLogger.With(args...),
|
||||
zap: l.zap.Sugar().With(args...).Desugar(),
|
||||
config: l.config,
|
||||
name: l.name,
|
||||
}
|
||||
}
|
||||
|
||||
// Sync flushes any buffered log entries
|
||||
func (l *Logger) Sync() error {
|
||||
return l.zap.Sync()
|
||||
}
|
||||
|
||||
// GetZap returns the underlying zap.Logger
|
||||
func (l *Logger) GetZap() *zap.Logger {
|
||||
return l.zap
|
||||
}
|
||||
|
||||
// Debug logs a debug message
|
||||
func (l *Logger) Debug(msg string, keysAndValues ...interface{}) {
|
||||
l.SugaredLogger.Debugw(msg, keysAndValues...)
|
||||
}
|
||||
|
||||
// Info logs an info message
|
||||
func (l *Logger) Info(msg string, keysAndValues ...interface{}) {
|
||||
l.SugaredLogger.Infow(msg, keysAndValues...)
|
||||
}
|
||||
|
||||
// Warn logs a warning message
|
||||
func (l *Logger) Warn(msg string, keysAndValues ...interface{}) {
|
||||
l.SugaredLogger.Warnw(msg, keysAndValues...)
|
||||
}
|
||||
|
||||
// Error logs an error message
|
||||
func (l *Logger) Error(msg string, keysAndValues ...interface{}) {
|
||||
l.SugaredLogger.Errorw(msg, keysAndValues...)
|
||||
}
|
||||
|
||||
// Fatal logs a fatal message and exits
|
||||
func (l *Logger) Fatal(msg string, keysAndValues ...interface{}) {
|
||||
l.SugaredLogger.Fatalw(msg, keysAndValues...)
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
func parseLevel(level string) zapcore.Level {
|
||||
switch strings.ToUpper(level) {
|
||||
case "DEBUG":
|
||||
return zapcore.DebugLevel
|
||||
case "INFO":
|
||||
return zapcore.InfoLevel
|
||||
case "WARN", "WARNING":
|
||||
return zapcore.WarnLevel
|
||||
case "ERROR":
|
||||
return zapcore.ErrorLevel
|
||||
case "CRITICAL", "FATAL":
|
||||
return zapcore.FatalLevel
|
||||
default:
|
||||
return zapcore.InfoLevel
|
||||
}
|
||||
}
|
||||
|
||||
func createEncoderConfig(format config.LogFormatConfig) zapcore.EncoderConfig {
|
||||
timestampFormat := format.TimestampFormat
|
||||
if timestampFormat == "" {
|
||||
timestampFormat = "2006-01-02 15:04:05"
|
||||
}
|
||||
|
||||
cfg := zapcore.EncoderConfig{
|
||||
TimeKey: "timestamp",
|
||||
LevelKey: "level",
|
||||
NameKey: "logger",
|
||||
CallerKey: "caller",
|
||||
FunctionKey: zapcore.OmitKey,
|
||||
MessageKey: "msg",
|
||||
StacktraceKey: "stacktrace",
|
||||
LineEnding: zapcore.DefaultLineEnding,
|
||||
EncodeLevel: zapcore.CapitalLevelEncoder,
|
||||
EncodeTime: zapcore.TimeEncoderOfLayout(timestampFormat),
|
||||
EncodeDuration: zapcore.SecondsDurationEncoder,
|
||||
EncodeCaller: zapcore.ShortCallerEncoder,
|
||||
}
|
||||
|
||||
if !format.ShowModule {
|
||||
cfg.NameKey = zapcore.OmitKey
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func mergeFormatConfig(base, override config.LogFormatConfig) config.LogFormatConfig {
|
||||
result := base
|
||||
if override.Type != "" {
|
||||
result.Type = override.Type
|
||||
}
|
||||
if override.TimestampFormat != "" {
|
||||
result.TimestampFormat = override.TimestampFormat
|
||||
}
|
||||
// UseColors and ShowModule are bool - check if override has non-default
|
||||
result.UseColors = override.UseColors
|
||||
result.ShowModule = override.ShowModule
|
||||
return result
|
||||
}
|
||||
|
||||
func createFileCore(fileConfig config.FileLogConfig, defaultFormat config.LogFormatConfig, defaultLevel zapcore.Level) (zapcore.Core, error) {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(fileConfig.Path)
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create log directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Configure log rotation with lumberjack
|
||||
maxSize := 10 // MB
|
||||
if fileConfig.MaxBytes > 0 {
|
||||
maxSize = int(fileConfig.MaxBytes / (1024 * 1024))
|
||||
if maxSize < 1 {
|
||||
maxSize = 1
|
||||
}
|
||||
}
|
||||
|
||||
backupCount := 5
|
||||
if fileConfig.BackupCount > 0 {
|
||||
backupCount = fileConfig.BackupCount
|
||||
}
|
||||
|
||||
rotator := &lumberjack.Logger{
|
||||
Filename: fileConfig.Path,
|
||||
MaxSize: maxSize,
|
||||
MaxBackups: backupCount,
|
||||
MaxAge: 30, // days
|
||||
Compress: true,
|
||||
}
|
||||
|
||||
// Determine level
|
||||
level := defaultLevel
|
||||
if fileConfig.Level != "" {
|
||||
level = parseLevel(fileConfig.Level)
|
||||
}
|
||||
|
||||
// Create encoder
|
||||
format := defaultFormat
|
||||
if fileConfig.Format.Type != "" {
|
||||
format = mergeFormatConfig(defaultFormat, fileConfig.Format)
|
||||
}
|
||||
// Files should not use colors
|
||||
format.UseColors = false
|
||||
|
||||
encoderConfig := createEncoderConfig(format)
|
||||
var encoder zapcore.Encoder
|
||||
if format.Type == "json" {
|
||||
encoder = zapcore.NewJSONEncoder(encoderConfig)
|
||||
} else {
|
||||
encoder = zapcore.NewConsoleEncoder(encoderConfig)
|
||||
}
|
||||
|
||||
return zapcore.NewCore(encoder, zapcore.AddSync(rotator), level), nil
|
||||
}
|
||||
|
||||
// filteredCore wraps a Core to filter by logger name
|
||||
type filteredCore struct {
|
||||
zapcore.Core
|
||||
loggers []string
|
||||
}
|
||||
|
||||
func (c *filteredCore) Check(entry zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
|
||||
if !c.shouldLog(entry.LoggerName) {
|
||||
return ce
|
||||
}
|
||||
return c.Core.Check(entry, ce)
|
||||
}
|
||||
|
||||
func (c *filteredCore) shouldLog(loggerName string) bool {
|
||||
if len(c.loggers) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, allowed := range c.loggers {
|
||||
if loggerName == allowed || strings.HasPrefix(loggerName, allowed+".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *filteredCore) With(fields []zapcore.Field) zapcore.Core {
|
||||
return &filteredCore{
|
||||
Core: c.Core.With(fields),
|
||||
loggers: c.loggers,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/config"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
logger, err := New(Config{Level: "INFO"})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if logger == nil {
|
||||
t.Fatal("Expected logger, got nil")
|
||||
}
|
||||
|
||||
if logger.name != "konduktor" {
|
||||
t.Errorf("Expected name konduktor, got %s", logger.name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_DefaultTimestampFormat(t *testing.T) {
|
||||
logger, err := New(Config{Level: "DEBUG"})
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Logger should be created successfully
|
||||
if logger == nil {
|
||||
t.Fatal("Expected logger, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_CustomTimestampFormat(t *testing.T) {
|
||||
logger, err := New(Config{
|
||||
Level: "DEBUG",
|
||||
TimestampFormat: "15:04:05",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if logger == nil {
|
||||
t.Fatal("Expected logger, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFromConfig(t *testing.T) {
|
||||
cfg := config.LoggingConfig{
|
||||
Level: "DEBUG",
|
||||
ConsoleOutput: true,
|
||||
Format: config.LogFormatConfig{
|
||||
Type: "standard",
|
||||
UseColors: true,
|
||||
ShowModule: true,
|
||||
TimestampFormat: "2006-01-02 15:04:05",
|
||||
},
|
||||
}
|
||||
|
||||
logger, err := NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if logger == nil {
|
||||
t.Fatal("Expected logger, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFromConfig_WithConsole(t *testing.T) {
|
||||
cfg := config.LoggingConfig{
|
||||
Level: "INFO",
|
||||
ConsoleOutput: true,
|
||||
Format: config.LogFormatConfig{
|
||||
Type: "standard",
|
||||
UseColors: true,
|
||||
},
|
||||
Console: &config.ConsoleLogConfig{
|
||||
Level: "DEBUG",
|
||||
Format: config.LogFormatConfig{
|
||||
Type: "standard",
|
||||
UseColors: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
logger, err := NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if logger == nil {
|
||||
t.Fatal("Expected logger, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_Debug(t *testing.T) {
|
||||
logger, _ := New(Config{Level: "DEBUG"})
|
||||
|
||||
// Should not panic
|
||||
logger.Debug("test message", "key", "value")
|
||||
}
|
||||
|
||||
func TestLogger_Info(t *testing.T) {
|
||||
logger, _ := New(Config{Level: "INFO"})
|
||||
|
||||
// Should not panic
|
||||
logger.Info("test message", "key", "value")
|
||||
}
|
||||
|
||||
func TestLogger_Warn(t *testing.T) {
|
||||
logger, _ := New(Config{Level: "WARN"})
|
||||
|
||||
// Should not panic
|
||||
logger.Warn("test message", "key", "value")
|
||||
}
|
||||
|
||||
func TestLogger_Error(t *testing.T) {
|
||||
logger, _ := New(Config{Level: "ERROR"})
|
||||
|
||||
// Should not panic
|
||||
logger.Error("test message", "key", "value")
|
||||
}
|
||||
|
||||
func TestLogger_Named(t *testing.T) {
|
||||
logger, _ := New(Config{Level: "INFO"})
|
||||
named := logger.Named("test.module")
|
||||
|
||||
if named == nil {
|
||||
t.Fatal("Expected named logger, got nil")
|
||||
}
|
||||
|
||||
if named.name != "test.module" {
|
||||
t.Errorf("Expected name 'test.module', got %s", named.name)
|
||||
}
|
||||
|
||||
// Should not panic
|
||||
named.Info("test from named logger")
|
||||
}
|
||||
|
||||
func TestLogger_With(t *testing.T) {
|
||||
logger, _ := New(Config{Level: "INFO"})
|
||||
withFields := logger.With("service", "test")
|
||||
|
||||
if withFields == nil {
|
||||
t.Fatal("Expected logger with fields, got nil")
|
||||
}
|
||||
|
||||
// Should not panic
|
||||
withFields.Info("test with fields")
|
||||
}
|
||||
|
||||
func TestLogger_Sync(t *testing.T) {
|
||||
logger, _ := New(Config{Level: "INFO"})
|
||||
|
||||
// Should not panic
|
||||
err := logger.Sync()
|
||||
// Sync may return an error for stdout on some systems, ignore it
|
||||
_ = err
|
||||
}
|
||||
|
||||
func TestParseLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"DEBUG", "debug"},
|
||||
{"INFO", "info"},
|
||||
{"WARN", "warn"},
|
||||
{"WARNING", "warn"},
|
||||
{"ERROR", "error"},
|
||||
{"CRITICAL", "fatal"},
|
||||
{"FATAL", "fatal"},
|
||||
{"invalid", "info"}, // defaults to INFO
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
level := parseLevel(tt.input)
|
||||
if level.String() != tt.expected {
|
||||
t.Errorf("parseLevel(%s) = %s, want %s", tt.input, level.String(), tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Benchmarks ==============
|
||||
|
||||
func BenchmarkLogger_Info(b *testing.B) {
|
||||
logger, _ := New(Config{Level: "INFO"})
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
logger.Info("test message", "key", "value")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLogger_Debug_Filtered(b *testing.B) {
|
||||
logger, _ := New(Config{Level: "ERROR"})
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
logger.Debug("test message", "key", "value")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/logging"
|
||||
)
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
size int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.status = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
size, err := rw.ResponseWriter.Write(b)
|
||||
rw.size += size
|
||||
return size, err
|
||||
}
|
||||
|
||||
func ServerHeader(next http.Handler, version string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Server", fmt.Sprintf("konduktor/%s", version))
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func AccessLog(next http.Handler, logger *logging.Logger) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
wrapped := &responseWriter{
|
||||
ResponseWriter: w,
|
||||
status: http.StatusOK,
|
||||
}
|
||||
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
logger.Info("HTTP request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", wrapped.status,
|
||||
"duration_ms", duration.Milliseconds(),
|
||||
"client_ip", r.RemoteAddr,
|
||||
"user_agent", r.UserAgent(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func Recovery(next http.Handler, logger *logging.Logger) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
logger.Error("Panic recovered",
|
||||
"error", fmt.Sprintf("%v", err),
|
||||
"stack", string(debug.Stack()),
|
||||
)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/logging"
|
||||
)
|
||||
|
||||
// ============== ServerHeader Tests ==============
|
||||
|
||||
func TestServerHeader(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrapped := ServerHeader(handler, "1.0.0")
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
wrapped.ServeHTTP(rr, req)
|
||||
|
||||
serverHeader := rr.Header().Get("Server")
|
||||
if serverHeader != "konduktor/1.0.0" {
|
||||
t.Errorf("Expected Server header 'konduktor/1.0.0', got '%s'", serverHeader)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== AccessLog Tests ==============
|
||||
|
||||
func TestAccessLog(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Hello"))
|
||||
})
|
||||
|
||||
logger, _ := logging.New(logging.Config{Level: "INFO"})
|
||||
wrapped := AccessLog(handler, logger)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
wrapped.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessLog_CapturesStatusCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
}{
|
||||
{"OK", http.StatusOK},
|
||||
{"NotFound", http.StatusNotFound},
|
||||
{"InternalError", http.StatusInternalServerError},
|
||||
{"Redirect", http.StatusMovedPermanently},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tt.statusCode)
|
||||
})
|
||||
|
||||
logger, _ := logging.New(logging.Config{Level: "INFO"})
|
||||
wrapped := AccessLog(handler, logger)
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
wrapped.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != tt.statusCode {
|
||||
t.Errorf("Expected status %d, got %d", tt.statusCode, rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Recovery Tests ==============
|
||||
|
||||
func TestRecovery_NoPanic(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
logger, _ := logging.New(logging.Config{Level: "INFO"})
|
||||
wrapped := Recovery(handler, logger)
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
wrapped.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
if rr.Body.String() != "OK" {
|
||||
t.Errorf("Expected body 'OK', got '%s'", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecovery_WithPanic(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
panic("test panic")
|
||||
})
|
||||
|
||||
logger, _ := logging.New(logging.Config{Level: "ERROR"})
|
||||
wrapped := Recovery(handler, logger)
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
// Should not panic
|
||||
wrapped.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status 500, got %d", rr.Code)
|
||||
}
|
||||
|
||||
if !strings.Contains(rr.Body.String(), "Internal Server Error") {
|
||||
t.Errorf("Expected 'Internal Server Error' in body, got '%s'", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ============== responseWriter Tests ==============
|
||||
|
||||
func TestResponseWriter_WriteHeader(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
rw := &responseWriter{ResponseWriter: rr, status: http.StatusOK}
|
||||
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
|
||||
if rw.status != http.StatusNotFound {
|
||||
t.Errorf("Expected status 404, got %d", rw.status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseWriter_Write(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
rw := &responseWriter{ResponseWriter: rr, status: http.StatusOK}
|
||||
|
||||
n, err := rw.Write([]byte("Hello World"))
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if n != 11 {
|
||||
t.Errorf("Expected 11 bytes written, got %d", n)
|
||||
}
|
||||
|
||||
if rw.size != 11 {
|
||||
t.Errorf("Expected size 11, got %d", rw.size)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Middleware Chain Tests ==============
|
||||
|
||||
func TestMiddlewareChain(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
logger, _ := logging.New(logging.Config{Level: "INFO"})
|
||||
|
||||
// Apply middleware chain
|
||||
wrapped := Recovery(AccessLog(ServerHeader(handler, "1.0.0"), logger), logger)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
wrapped.ServeHTTP(rr, req)
|
||||
|
||||
// Check all middleware worked
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
if rr.Header().Get("Server") != "konduktor/1.0.0" {
|
||||
t.Errorf("Expected Server header")
|
||||
}
|
||||
|
||||
if rr.Body.String() != "OK" {
|
||||
t.Errorf("Expected body 'OK', got '%s'", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Benchmarks ==============
|
||||
|
||||
func BenchmarkServerHeader(b *testing.B) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrapped := ServerHeader(handler, "1.0.0")
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
rr := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rr, req)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAccessLog(b *testing.B) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
logger, _ := logging.New(logging.Config{Level: "ERROR"}) // Minimize logging overhead
|
||||
wrapped := AccessLog(handler, logger)
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
rr := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rr, req)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRecovery(b *testing.B) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
logger, _ := logging.New(logging.Config{Level: "ERROR"})
|
||||
wrapped := Recovery(handler, logger)
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
rr := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rr, req)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package pathmatcher
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type MountedPath struct {
|
||||
path string
|
||||
name string
|
||||
stripPath bool
|
||||
}
|
||||
|
||||
func NewMountedPath(path string, opts ...MountedPathOption) *MountedPath {
|
||||
// Normalize: remove trailing slash (except for root)
|
||||
normalizedPath := strings.TrimSuffix(path, "/")
|
||||
if normalizedPath == "" {
|
||||
normalizedPath = ""
|
||||
}
|
||||
|
||||
m := &MountedPath{
|
||||
path: normalizedPath,
|
||||
name: normalizedPath,
|
||||
stripPath: true,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(m)
|
||||
}
|
||||
|
||||
if m.name == "" {
|
||||
m.name = normalizedPath
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
type MountedPathOption func(*MountedPath)
|
||||
|
||||
func WithName(name string) MountedPathOption {
|
||||
return func(m *MountedPath) {
|
||||
m.name = name
|
||||
}
|
||||
}
|
||||
|
||||
func WithStripPath(strip bool) MountedPathOption {
|
||||
return func(m *MountedPath) {
|
||||
m.stripPath = strip
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MountedPath) Path() string {
|
||||
return m.path
|
||||
}
|
||||
|
||||
func (m *MountedPath) Name() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
func (m *MountedPath) StripPath() bool {
|
||||
return m.stripPath
|
||||
}
|
||||
|
||||
func (m *MountedPath) Matches(requestPath string) bool {
|
||||
// Empty or "/" mount matches everything
|
||||
if m.path == "" || m.path == "/" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Request path must be at least as long as mount path
|
||||
if len(requestPath) < len(m.path) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if request path starts with mount path
|
||||
if !strings.HasPrefix(requestPath, m.path) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If paths are equal length, it's a match
|
||||
if len(requestPath) == len(m.path) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Otherwise, next char must be '/' to prevent /api matching /api-v2
|
||||
return requestPath[len(m.path)] == '/'
|
||||
}
|
||||
|
||||
func (m *MountedPath) GetModifiedPath(requestPath string) string {
|
||||
if !m.stripPath {
|
||||
return requestPath
|
||||
}
|
||||
|
||||
// Root mount doesn't strip anything
|
||||
if m.path == "" || m.path == "/" {
|
||||
return requestPath
|
||||
}
|
||||
|
||||
// Strip the prefix
|
||||
modified := strings.TrimPrefix(requestPath, m.path)
|
||||
|
||||
// Ensure result starts with /
|
||||
if modified == "" || modified[0] != '/' {
|
||||
modified = "/" + modified
|
||||
}
|
||||
|
||||
return modified
|
||||
}
|
||||
|
||||
type MountManager struct {
|
||||
mounts []*MountedPath
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewMountManager() *MountManager {
|
||||
return &MountManager{
|
||||
mounts: make([]*MountedPath, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (mm *MountManager) AddMount(mount *MountedPath) {
|
||||
mm.mu.Lock()
|
||||
defer mm.mu.Unlock()
|
||||
|
||||
// Insert in sorted order (longer paths first)
|
||||
inserted := false
|
||||
for i, existing := range mm.mounts {
|
||||
if len(mount.path) > len(existing.path) {
|
||||
// Insert at position i
|
||||
mm.mounts = append(mm.mounts[:i], append([]*MountedPath{mount}, mm.mounts[i:]...)...)
|
||||
inserted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !inserted {
|
||||
mm.mounts = append(mm.mounts, mount)
|
||||
}
|
||||
}
|
||||
|
||||
func (mm *MountManager) RemoveMount(path string) bool {
|
||||
mm.mu.Lock()
|
||||
defer mm.mu.Unlock()
|
||||
|
||||
normalizedPath := strings.TrimSuffix(path, "/")
|
||||
|
||||
for i, mount := range mm.mounts {
|
||||
if mount.path == normalizedPath {
|
||||
mm.mounts = append(mm.mounts[:i], mm.mounts[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (mm *MountManager) GetMount(requestPath string) *MountedPath {
|
||||
mm.mu.RLock()
|
||||
defer mm.mu.RUnlock()
|
||||
|
||||
// Mounts are sorted by path length (longest first)
|
||||
// so the first match is the best match
|
||||
for _, mount := range mm.mounts {
|
||||
if mount.Matches(requestPath) {
|
||||
return mount
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mm *MountManager) MountCount() int {
|
||||
mm.mu.RLock()
|
||||
defer mm.mu.RUnlock()
|
||||
return len(mm.mounts)
|
||||
}
|
||||
|
||||
func (mm *MountManager) Mounts() []*MountedPath {
|
||||
mm.mu.RLock()
|
||||
defer mm.mu.RUnlock()
|
||||
|
||||
result := make([]*MountedPath, len(mm.mounts))
|
||||
copy(result, mm.mounts)
|
||||
return result
|
||||
}
|
||||
|
||||
func (mm *MountManager) ListMounts() []map[string]interface{} {
|
||||
mm.mu.RLock()
|
||||
defer mm.mu.RUnlock()
|
||||
|
||||
result := make([]map[string]interface{}, len(mm.mounts))
|
||||
for i, mount := range mm.mounts {
|
||||
result[i] = map[string]interface{}{
|
||||
"path": mount.path,
|
||||
"name": mount.name,
|
||||
"strip_path": mount.stripPath,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
|
||||
func PathMatchesPrefix(requestPath, prefix string) bool {
|
||||
// Normalize prefix
|
||||
prefix = strings.TrimSuffix(prefix, "/")
|
||||
|
||||
// Empty or "/" prefix matches everything
|
||||
if prefix == "" || prefix == "/" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Request path must be at least as long as prefix
|
||||
if len(requestPath) < len(prefix) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if request path starts with prefix
|
||||
if !strings.HasPrefix(requestPath, prefix) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If paths are equal length, it's a match
|
||||
if len(requestPath) == len(prefix) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Otherwise, next char must be '/'
|
||||
return requestPath[len(prefix)] == '/'
|
||||
}
|
||||
|
||||
func StripPathPrefix(requestPath, prefix string) string {
|
||||
// Normalize prefix
|
||||
prefix = strings.TrimSuffix(prefix, "/")
|
||||
|
||||
// Empty or "/" prefix doesn't strip anything
|
||||
if prefix == "" || prefix == "/" {
|
||||
return requestPath
|
||||
}
|
||||
|
||||
// Strip the prefix
|
||||
modified := strings.TrimPrefix(requestPath, prefix)
|
||||
|
||||
// Ensure result starts with /
|
||||
if modified == "" || modified[0] != '/' {
|
||||
modified = "/" + modified
|
||||
}
|
||||
|
||||
return modified
|
||||
}
|
||||
|
||||
func MatchAndModifyPath(requestPath, prefix string, stripPath bool) (matches bool, modifiedPath string) {
|
||||
if !PathMatchesPrefix(requestPath, prefix) {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
if stripPath {
|
||||
return true, StripPathPrefix(requestPath, prefix)
|
||||
}
|
||||
|
||||
return true, requestPath
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package pathmatcher
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ============== MountedPath Tests ==============
|
||||
|
||||
func TestMountedPath_RootMountMatchesEverything(t *testing.T) {
|
||||
mount := NewMountedPath("")
|
||||
|
||||
tests := []string{"/", "/api", "/api/users", "/anything/at/all"}
|
||||
|
||||
for _, path := range tests {
|
||||
if !mount.Matches(path) {
|
||||
t.Errorf("Root mount should match %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountedPath_SlashRootMountMatchesEverything(t *testing.T) {
|
||||
mount := NewMountedPath("/")
|
||||
|
||||
tests := []string{"/", "/api", "/api/users"}
|
||||
|
||||
for _, path := range tests {
|
||||
if !mount.Matches(path) {
|
||||
t.Errorf("'/' mount should match %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountedPath_ExactPathMatch(t *testing.T) {
|
||||
mount := NewMountedPath("/api")
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
expected bool
|
||||
}{
|
||||
{"/api", true},
|
||||
{"/api/", true},
|
||||
{"/api/users", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := mount.Matches(tt.path); got != tt.expected {
|
||||
t.Errorf("Matches(%s) = %v, want %v", tt.path, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountedPath_NoFalsePrefixMatch(t *testing.T) {
|
||||
mount := NewMountedPath("/api")
|
||||
|
||||
tests := []string{"/api-v2", "/api2", "/apiv2"}
|
||||
|
||||
for _, path := range tests {
|
||||
if mount.Matches(path) {
|
||||
t.Errorf("/api should not match %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountedPath_ShorterPathNoMatch(t *testing.T) {
|
||||
mount := NewMountedPath("/api/v1")
|
||||
|
||||
tests := []string{"/api", "/ap", "/"}
|
||||
|
||||
for _, path := range tests {
|
||||
if mount.Matches(path) {
|
||||
t.Errorf("/api/v1 should not match shorter path %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountedPath_TrailingSlashNormalized(t *testing.T) {
|
||||
mount1 := NewMountedPath("/api/")
|
||||
mount2 := NewMountedPath("/api")
|
||||
|
||||
if mount1.Path() != "/api" {
|
||||
t.Errorf("Expected path /api, got %s", mount1.Path())
|
||||
}
|
||||
|
||||
if mount2.Path() != "/api" {
|
||||
t.Errorf("Expected path /api, got %s", mount2.Path())
|
||||
}
|
||||
|
||||
if !mount1.Matches("/api/users") {
|
||||
t.Error("mount1 should match /api/users")
|
||||
}
|
||||
|
||||
if !mount2.Matches("/api/users") {
|
||||
t.Error("mount2 should match /api/users")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountedPath_GetModifiedPathStripsPrefix(t *testing.T) {
|
||||
mount := NewMountedPath("/api")
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"/api", "/"},
|
||||
{"/api/", "/"},
|
||||
{"/api/users", "/users"},
|
||||
{"/api/users/123", "/users/123"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := mount.GetModifiedPath(tt.input); got != tt.expected {
|
||||
t.Errorf("GetModifiedPath(%s) = %s, want %s", tt.input, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountedPath_GetModifiedPathNoStrip(t *testing.T) {
|
||||
mount := NewMountedPath("/api", WithStripPath(false))
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"/api/users", "/api/users"},
|
||||
{"/api", "/api"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := mount.GetModifiedPath(tt.input); got != tt.expected {
|
||||
t.Errorf("GetModifiedPath(%s) = %s, want %s", tt.input, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountedPath_RootMountModifiedPath(t *testing.T) {
|
||||
mount := NewMountedPath("")
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"/api/users", "/api/users"},
|
||||
{"/", "/"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := mount.GetModifiedPath(tt.input); got != tt.expected {
|
||||
t.Errorf("GetModifiedPath(%s) = %s, want %s", tt.input, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountedPath_NameProperty(t *testing.T) {
|
||||
mount1 := NewMountedPath("/api")
|
||||
mount2 := NewMountedPath("/api", WithName("API Mount"))
|
||||
|
||||
if mount1.Name() != "/api" {
|
||||
t.Errorf("Expected name /api, got %s", mount1.Name())
|
||||
}
|
||||
|
||||
if mount2.Name() != "API Mount" {
|
||||
t.Errorf("Expected name 'API Mount', got %s", mount2.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// ============== MountManager Tests ==============
|
||||
|
||||
func TestMountManager_EmptyManager(t *testing.T) {
|
||||
manager := NewMountManager()
|
||||
|
||||
if got := manager.GetMount("/api"); got != nil {
|
||||
t.Error("Empty manager should return nil")
|
||||
}
|
||||
|
||||
if got := manager.MountCount(); got != 0 {
|
||||
t.Errorf("Expected mount count 0, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountManager_AddMount(t *testing.T) {
|
||||
manager := NewMountManager()
|
||||
mount := NewMountedPath("/api")
|
||||
|
||||
manager.AddMount(mount)
|
||||
|
||||
if manager.MountCount() != 1 {
|
||||
t.Errorf("Expected mount count 1, got %d", manager.MountCount())
|
||||
}
|
||||
|
||||
if got := manager.GetMount("/api/users"); got != mount {
|
||||
t.Error("GetMount should return the added mount")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountManager_LongestPrefixMatching(t *testing.T) {
|
||||
manager := NewMountManager()
|
||||
|
||||
apiMount := NewMountedPath("/api", WithName("api"))
|
||||
apiV1Mount := NewMountedPath("/api/v1", WithName("api_v1"))
|
||||
apiV2Mount := NewMountedPath("/api/v2", WithName("api_v2"))
|
||||
|
||||
manager.AddMount(apiMount)
|
||||
manager.AddMount(apiV2Mount)
|
||||
manager.AddMount(apiV1Mount)
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
expectedName string
|
||||
}{
|
||||
{"/api/v1/users", "api_v1"},
|
||||
{"/api/v2/items", "api_v2"},
|
||||
{"/api/v3/other", "api"},
|
||||
{"/api", "api"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := manager.GetMount(tt.path)
|
||||
if got == nil {
|
||||
t.Errorf("GetMount(%s) returned nil, want mount with name %s", tt.path, tt.expectedName)
|
||||
continue
|
||||
}
|
||||
if got.Name() != tt.expectedName {
|
||||
t.Errorf("GetMount(%s).Name() = %s, want %s", tt.path, got.Name(), tt.expectedName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountManager_RemoveMount(t *testing.T) {
|
||||
manager := NewMountManager()
|
||||
|
||||
manager.AddMount(NewMountedPath("/api"))
|
||||
manager.AddMount(NewMountedPath("/admin"))
|
||||
|
||||
if manager.MountCount() != 2 {
|
||||
t.Errorf("Expected mount count 2, got %d", manager.MountCount())
|
||||
}
|
||||
|
||||
result := manager.RemoveMount("/api")
|
||||
|
||||
if !result {
|
||||
t.Error("RemoveMount should return true")
|
||||
}
|
||||
|
||||
if manager.MountCount() != 1 {
|
||||
t.Errorf("Expected mount count 1, got %d", manager.MountCount())
|
||||
}
|
||||
|
||||
if manager.GetMount("/api/users") != nil {
|
||||
t.Error("GetMount(/api/users) should return nil after removal")
|
||||
}
|
||||
|
||||
if manager.GetMount("/admin/users") == nil {
|
||||
t.Error("GetMount(/admin/users) should still work")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountManager_RemoveNonexistentMount(t *testing.T) {
|
||||
manager := NewMountManager()
|
||||
|
||||
result := manager.RemoveMount("/api")
|
||||
|
||||
if result {
|
||||
t.Error("RemoveMount should return false for nonexistent mount")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountManager_ListMounts(t *testing.T) {
|
||||
manager := NewMountManager()
|
||||
|
||||
manager.AddMount(NewMountedPath("/api", WithName("API")))
|
||||
manager.AddMount(NewMountedPath("/admin", WithName("Admin")))
|
||||
|
||||
mounts := manager.ListMounts()
|
||||
|
||||
if len(mounts) != 2 {
|
||||
t.Errorf("Expected 2 mounts, got %d", len(mounts))
|
||||
}
|
||||
|
||||
for _, m := range mounts {
|
||||
if _, ok := m["path"]; !ok {
|
||||
t.Error("Mount should have 'path' key")
|
||||
}
|
||||
if _, ok := m["name"]; !ok {
|
||||
t.Error("Mount should have 'name' key")
|
||||
}
|
||||
if _, ok := m["strip_path"]; !ok {
|
||||
t.Error("Mount should have 'strip_path' key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountManager_MountsReturnsCopy(t *testing.T) {
|
||||
manager := NewMountManager()
|
||||
manager.AddMount(NewMountedPath("/api"))
|
||||
|
||||
mounts1 := manager.Mounts()
|
||||
mounts2 := manager.Mounts()
|
||||
|
||||
if &mounts1[0] == &mounts2[0] {
|
||||
t.Error("Mounts() should return different slices")
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Utility Functions Tests ==============
|
||||
|
||||
func TestPathMatchesPrefix_Basic(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
prefix string
|
||||
expected bool
|
||||
}{
|
||||
{"/api/users", "/api", true},
|
||||
{"/api", "/api", true},
|
||||
{"/api-v2", "/api", false},
|
||||
{"/ap", "/api", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := PathMatchesPrefix(tt.path, tt.prefix); got != tt.expected {
|
||||
t.Errorf("PathMatchesPrefix(%s, %s) = %v, want %v", tt.path, tt.prefix, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathMatchesPrefix_Root(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
prefix string
|
||||
expected bool
|
||||
}{
|
||||
{"/anything", "", true},
|
||||
{"/anything", "/", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := PathMatchesPrefix(tt.path, tt.prefix); got != tt.expected {
|
||||
t.Errorf("PathMatchesPrefix(%s, %s) = %v, want %v", tt.path, tt.prefix, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripPathPrefix_Basic(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
prefix string
|
||||
expected string
|
||||
}{
|
||||
{"/api/users", "/api", "/users"},
|
||||
{"/api", "/api", "/"},
|
||||
{"/api/", "/api", "/"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := StripPathPrefix(tt.path, tt.prefix); got != tt.expected {
|
||||
t.Errorf("StripPathPrefix(%s, %s) = %s, want %s", tt.path, tt.prefix, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripPathPrefix_Root(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
prefix string
|
||||
expected string
|
||||
}{
|
||||
{"/api/users", "", "/api/users"},
|
||||
{"/api/users", "/", "/api/users"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := StripPathPrefix(tt.path, tt.prefix); got != tt.expected {
|
||||
t.Errorf("StripPathPrefix(%s, %s) = %s, want %s", tt.path, tt.prefix, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAndModifyPath_Combined(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
prefix string
|
||||
stripPath bool
|
||||
wantMatches bool
|
||||
wantModified string
|
||||
}{
|
||||
{"/api/users", "/api", true, true, "/users"},
|
||||
{"/api", "/api", true, true, "/"},
|
||||
{"/other", "/api", true, false, ""},
|
||||
{"/api/users", "/api", false, true, "/api/users"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
matches, modified := MatchAndModifyPath(tt.path, tt.prefix, tt.stripPath)
|
||||
if matches != tt.wantMatches {
|
||||
t.Errorf("MatchAndModifyPath(%s, %s, %v) matches = %v, want %v",
|
||||
tt.path, tt.prefix, tt.stripPath, matches, tt.wantMatches)
|
||||
}
|
||||
if modified != tt.wantModified {
|
||||
t.Errorf("MatchAndModifyPath(%s, %s, %v) modified = %s, want %s",
|
||||
tt.path, tt.prefix, tt.stripPath, modified, tt.wantModified)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Performance Tests ==============
|
||||
|
||||
func TestPerformance_ManyMatches(t *testing.T) {
|
||||
mount := NewMountedPath("/api/v1/users")
|
||||
|
||||
for i := 0; i < 10000; i++ {
|
||||
if !mount.Matches("/api/v1/users/123/posts") {
|
||||
t.Fatal("Should match")
|
||||
}
|
||||
if mount.Matches("/other/path") {
|
||||
t.Fatal("Should not match")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformance_ManyMounts(t *testing.T) {
|
||||
manager := NewMountManager()
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
manager.AddMount(NewMountedPath("/api/v" + string(rune('0'+i%10)) + string(rune('0'+i/10))))
|
||||
}
|
||||
|
||||
if manager.MountCount() != 100 {
|
||||
t.Errorf("Expected 100 mounts, got %d", manager.MountCount())
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Benchmarks ==============
|
||||
|
||||
func BenchmarkMountedPath_Matches(b *testing.B) {
|
||||
mount := NewMountedPath("/api/v1")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
mount.Matches("/api/v1/users/123")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMountManager_GetMount(b *testing.B) {
|
||||
manager := NewMountManager()
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
manager.AddMount(NewMountedPath("/api/v" + string(rune('0'+i%10))))
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
manager.GetMount("/api/v5/users/123")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPathMatchesPrefix(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
PathMatchesPrefix("/api/v1/users/123", "/api/v1")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
// Package proxy provides reverse proxy functionality for Konduktor
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/logging"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// Target is the backend server URL
|
||||
Target string
|
||||
|
||||
// Timeout is the request timeout (default: 30s)
|
||||
Timeout time.Duration
|
||||
|
||||
// Headers are additional headers to add to requests
|
||||
Headers map[string]string
|
||||
|
||||
// StripPrefix removes this prefix from the request path
|
||||
StripPrefix string
|
||||
|
||||
// PreserveHost keeps the original Host header
|
||||
PreserveHost bool
|
||||
|
||||
// IgnoreRequestPath ignores the request path and uses only the target path
|
||||
// This is useful for exact match routes where target URL should be used as-is
|
||||
IgnoreRequestPath bool
|
||||
}
|
||||
|
||||
type ReverseProxy struct {
|
||||
config *Config
|
||||
targetURL *url.URL
|
||||
httpClient *http.Client
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
func New(cfg *Config, logger *logging.Logger) (*ReverseProxy, error) {
|
||||
if cfg.Target == "" {
|
||||
return nil, fmt.Errorf("proxy target is required")
|
||||
}
|
||||
|
||||
targetURL, err := url.Parse(cfg.Target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid proxy target URL: %w", err)
|
||||
}
|
||||
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
}
|
||||
|
||||
return &ReverseProxy{
|
||||
config: cfg,
|
||||
targetURL: targetURL,
|
||||
httpClient: &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse // Don't follow redirects
|
||||
},
|
||||
},
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (rp *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rp.ProxyRequest(w, r, nil)
|
||||
}
|
||||
|
||||
func (rp *ReverseProxy) ProxyRequest(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Build target URL
|
||||
targetURL := rp.buildTargetURL(r)
|
||||
|
||||
// Create proxy request
|
||||
proxyReq, err := rp.createProxyRequest(ctx, r, targetURL)
|
||||
if err != nil {
|
||||
rp.handleError(w, http.StatusInternalServerError, "Failed to create proxy request", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Add custom headers with parameter substitution
|
||||
rp.addCustomHeaders(proxyReq, r, params)
|
||||
|
||||
// Execute request
|
||||
resp, err := rp.httpClient.Do(proxyReq)
|
||||
if err != nil {
|
||||
rp.handleProxyError(w, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Copy response
|
||||
rp.copyResponse(w, resp)
|
||||
}
|
||||
|
||||
func (rp *ReverseProxy) buildTargetURL(r *http.Request) *url.URL {
|
||||
targetURL := *rp.targetURL
|
||||
|
||||
// If ignoring request path, use target URL path as-is
|
||||
if rp.config.IgnoreRequestPath {
|
||||
// Preserve query string only
|
||||
targetURL.RawQuery = r.URL.RawQuery
|
||||
return &targetURL
|
||||
}
|
||||
|
||||
// Strip prefix if configured
|
||||
path := r.URL.Path
|
||||
if rp.config.StripPrefix != "" {
|
||||
path = strings.TrimPrefix(path, rp.config.StripPrefix)
|
||||
if path == "" || path[0] != '/' {
|
||||
path = "/" + path
|
||||
}
|
||||
}
|
||||
|
||||
// If target URL has a non-empty path, combine it with the request path
|
||||
if rp.targetURL.Path != "" && rp.targetURL.Path != "/" {
|
||||
// Combine target path with request path
|
||||
targetURL.Path = strings.TrimSuffix(rp.targetURL.Path, "/") + path
|
||||
} else {
|
||||
// No path in target, use request path as-is
|
||||
targetURL.Path = path
|
||||
}
|
||||
|
||||
// Preserve query string
|
||||
targetURL.RawQuery = r.URL.RawQuery
|
||||
|
||||
return &targetURL
|
||||
}
|
||||
|
||||
func (rp *ReverseProxy) createProxyRequest(ctx context.Context, r *http.Request, targetURL *url.URL) (*http.Request, error) {
|
||||
proxyReq, err := http.NewRequestWithContext(ctx, r.Method, targetURL.String(), r.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Copy ContentLength
|
||||
proxyReq.ContentLength = r.ContentLength
|
||||
|
||||
// Copy headers
|
||||
for key, values := range r.Header {
|
||||
for _, value := range values {
|
||||
proxyReq.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Set/update Host header
|
||||
if rp.config.PreserveHost {
|
||||
proxyReq.Host = r.Host
|
||||
} else {
|
||||
proxyReq.Host = targetURL.Host
|
||||
}
|
||||
|
||||
// Remove hop-by-hop headers
|
||||
removeHopByHopHeaders(proxyReq.Header)
|
||||
|
||||
return proxyReq, nil
|
||||
}
|
||||
|
||||
func (rp *ReverseProxy) addCustomHeaders(proxyReq *http.Request, originalReq *http.Request, params map[string]string) {
|
||||
// Add X-Forwarded headers
|
||||
clientIP := getClientIP(originalReq)
|
||||
if prior := originalReq.Header.Get("X-Forwarded-For"); prior != "" {
|
||||
clientIP = prior + ", " + clientIP
|
||||
}
|
||||
proxyReq.Header.Set("X-Forwarded-For", clientIP)
|
||||
proxyReq.Header.Set("X-Forwarded-Proto", getScheme(originalReq))
|
||||
proxyReq.Header.Set("X-Forwarded-Host", originalReq.Host)
|
||||
|
||||
// Add custom headers from config
|
||||
for key, value := range rp.config.Headers {
|
||||
// Substitute parameters like {version}
|
||||
substituted := value
|
||||
for paramKey, paramValue := range params {
|
||||
substituted = strings.ReplaceAll(substituted, "{"+paramKey+"}", paramValue)
|
||||
}
|
||||
// Substitute $remote_addr
|
||||
substituted = strings.ReplaceAll(substituted, "$remote_addr", clientIP)
|
||||
proxyReq.Header.Set(key, substituted)
|
||||
}
|
||||
}
|
||||
|
||||
func (rp *ReverseProxy) copyResponse(w http.ResponseWriter, resp *http.Response) {
|
||||
// Copy headers
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove hop-by-hop headers from response
|
||||
removeHopByHopHeaders(w.Header())
|
||||
|
||||
// Write status code
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
// Copy body
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
func (rp *ReverseProxy) handleError(w http.ResponseWriter, status int, message string, err error) {
|
||||
if rp.logger != nil {
|
||||
rp.logger.Error(message, "error", err)
|
||||
}
|
||||
http.Error(w, message, status)
|
||||
}
|
||||
|
||||
func (rp *ReverseProxy) handleProxyError(w http.ResponseWriter, err error) {
|
||||
if rp.logger != nil {
|
||||
rp.logger.Error("Proxy request failed", "error", err)
|
||||
}
|
||||
|
||||
// Check for timeout
|
||||
if err, ok := err.(net.Error); ok && err.Timeout() {
|
||||
http.Error(w, "504 Gateway Timeout", http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for connection errors
|
||||
if isConnectionError(err) {
|
||||
http.Error(w, "502 Bad Gateway", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
// Context cancelled (client disconnected)
|
||||
if err == context.Canceled {
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "502 Bad Gateway", http.StatusBadGateway)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func singleJoiningSlash(a, b string) string {
|
||||
aslash := strings.HasSuffix(a, "/")
|
||||
bslash := strings.HasPrefix(b, "/")
|
||||
switch {
|
||||
case aslash && bslash:
|
||||
return a + b[1:]
|
||||
case !aslash && !bslash:
|
||||
return a + "/" + b
|
||||
}
|
||||
return a + b
|
||||
}
|
||||
|
||||
func removeHopByHopHeaders(h http.Header) {
|
||||
hopByHopHeaders := []string{
|
||||
"Connection",
|
||||
"Proxy-Connection",
|
||||
"Keep-Alive",
|
||||
"Proxy-Authenticate",
|
||||
"Proxy-Authorization",
|
||||
"Te",
|
||||
"Trailer",
|
||||
"Transfer-Encoding",
|
||||
"Upgrade",
|
||||
}
|
||||
|
||||
for _, header := range hopByHopHeaders {
|
||||
h.Del(header)
|
||||
}
|
||||
}
|
||||
|
||||
func getClientIP(r *http.Request) string {
|
||||
// Check X-Real-IP first
|
||||
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
||||
return ip
|
||||
}
|
||||
|
||||
// Get from RemoteAddr
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func getScheme(r *http.Request) string {
|
||||
if r.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
if scheme := r.Header.Get("X-Forwarded-Proto"); scheme != "" {
|
||||
return scheme
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
func isConnectionError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
connectionErrors := []string{
|
||||
"connection refused",
|
||||
"no such host",
|
||||
"network is unreachable",
|
||||
"connection reset",
|
||||
"broken pipe",
|
||||
}
|
||||
|
||||
for _, connErr := range connectionErrors {
|
||||
if strings.Contains(strings.ToLower(errStr), connErr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============== Test Backend Server ==============
|
||||
|
||||
type testBackend struct {
|
||||
server *httptest.Server
|
||||
requestLog []requestLogEntry
|
||||
mu sync.Mutex
|
||||
requestCount int64
|
||||
}
|
||||
|
||||
type requestLogEntry struct {
|
||||
Method string
|
||||
Path string
|
||||
Query string
|
||||
Headers http.Header
|
||||
Body string
|
||||
}
|
||||
|
||||
func newTestBackend(handler http.HandlerFunc) *testBackend {
|
||||
tb := &testBackend{
|
||||
requestLog: make([]requestLogEntry, 0),
|
||||
}
|
||||
|
||||
if handler == nil {
|
||||
handler = tb.defaultHandler
|
||||
}
|
||||
|
||||
tb.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tb.logRequest(r)
|
||||
handler(w, r)
|
||||
}))
|
||||
|
||||
return tb
|
||||
}
|
||||
|
||||
func (tb *testBackend) logRequest(r *http.Request) {
|
||||
tb.mu.Lock()
|
||||
defer tb.mu.Unlock()
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
// Restore the body for the handler
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
tb.requestLog = append(tb.requestLog, requestLogEntry{
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
Query: r.URL.RawQuery,
|
||||
Headers: r.Header.Clone(),
|
||||
Body: string(body),
|
||||
})
|
||||
atomic.AddInt64(&tb.requestCount, 1)
|
||||
}
|
||||
|
||||
func (tb *testBackend) defaultHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": "Backend response",
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
})
|
||||
}
|
||||
|
||||
func (tb *testBackend) close() {
|
||||
tb.server.Close()
|
||||
}
|
||||
|
||||
func (tb *testBackend) URL() string {
|
||||
return tb.server.URL
|
||||
}
|
||||
|
||||
func (tb *testBackend) getRequestCount() int64 {
|
||||
return atomic.LoadInt64(&tb.requestCount)
|
||||
}
|
||||
|
||||
func (tb *testBackend) getLastRequest() *requestLogEntry {
|
||||
tb.mu.Lock()
|
||||
defer tb.mu.Unlock()
|
||||
if len(tb.requestLog) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &tb.requestLog[len(tb.requestLog)-1]
|
||||
}
|
||||
|
||||
// ============== Proxy Creation Tests ==============
|
||||
|
||||
func TestNew_ValidConfig(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Target: "http://localhost:8080",
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
proxy, err := New(cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create proxy: %v", err)
|
||||
}
|
||||
|
||||
if proxy == nil {
|
||||
t.Fatal("Expected proxy instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_EmptyTarget(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Target: "",
|
||||
}
|
||||
|
||||
_, err := New(cfg, nil)
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_InvalidTargetURL(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Target: "://invalid-url",
|
||||
}
|
||||
|
||||
_, err := New(cfg, nil)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_DefaultTimeout(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Target: "http://localhost:8080",
|
||||
}
|
||||
|
||||
proxy, err := New(cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create proxy: %v", err)
|
||||
}
|
||||
|
||||
if proxy.httpClient.Timeout != 30*time.Second {
|
||||
t.Errorf("Expected default timeout 30s, got %v", proxy.httpClient.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Basic Proxy Tests ==============
|
||||
|
||||
func TestProxy_BasicGET(t *testing.T) {
|
||||
backend := newTestBackend(nil)
|
||||
defer backend.close()
|
||||
|
||||
proxy, _ := New(&Config{Target: backend.URL()}, nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
proxy.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(rr.Body).Decode(&response)
|
||||
|
||||
if response["path"] != "/test" {
|
||||
t.Errorf("Expected path /test, got %v", response["path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_BasicPOST(t *testing.T) {
|
||||
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"received": string(body),
|
||||
"method": r.Method,
|
||||
})
|
||||
})
|
||||
defer backend.close()
|
||||
|
||||
proxy, _ := New(&Config{Target: backend.URL()}, nil)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/data", strings.NewReader(`{"key":"value"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
proxy.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(rr.Body).Decode(&response)
|
||||
|
||||
if response["method"] != "POST" {
|
||||
t.Errorf("Expected method POST, got %v", response["method"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_PUT(t *testing.T) {
|
||||
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"method": r.Method})
|
||||
})
|
||||
defer backend.close()
|
||||
|
||||
proxy, _ := New(&Config{Target: backend.URL()}, nil)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/resource/123", strings.NewReader(`{"name":"updated"}`))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
proxy.ServeHTTP(rr, req)
|
||||
|
||||
var response map[string]string
|
||||
json.NewDecoder(rr.Body).Decode(&response)
|
||||
|
||||
if response["method"] != "PUT" {
|
||||
t.Errorf("Expected method PUT, got %v", response["method"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_DELETE(t *testing.T) {
|
||||
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"method": r.Method})
|
||||
})
|
||||
defer backend.close()
|
||||
|
||||
proxy, _ := New(&Config{Target: backend.URL()}, nil)
|
||||
|
||||
req := httptest.NewRequest("DELETE", "/resource/123", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
proxy.ServeHTTP(rr, req)
|
||||
|
||||
var response map[string]string
|
||||
json.NewDecoder(rr.Body).Decode(&response)
|
||||
|
||||
if response["method"] != "DELETE" {
|
||||
t.Errorf("Expected method DELETE, got %v", response["method"])
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Header Tests ==============
|
||||
|
||||
func TestProxy_HeadersForwarding(t *testing.T) {
|
||||
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"custom_header": r.Header.Get("X-Custom-Header"),
|
||||
"forwarded_for": r.Header.Get("X-Forwarded-For"),
|
||||
"forwarded_host": r.Header.Get("X-Forwarded-Host"),
|
||||
})
|
||||
})
|
||||
defer backend.close()
|
||||
|
||||
proxy, _ := New(&Config{Target: backend.URL()}, nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/headers", nil)
|
||||
req.Header.Set("X-Custom-Header", "test-value")
|
||||
req.RemoteAddr = "192.168.1.100:12345"
|
||||
req.Host = "example.com"
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
proxy.ServeHTTP(rr, req)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(rr.Body).Decode(&response)
|
||||
|
||||
if response["custom_header"] != "test-value" {
|
||||
t.Errorf("Expected custom header, got %v", response["custom_header"])
|
||||
}
|
||||
|
||||
if response["forwarded_for"] != "192.168.1.100" {
|
||||
t.Errorf("Expected X-Forwarded-For, got %v", response["forwarded_for"])
|
||||
}
|
||||
|
||||
if response["forwarded_host"] != "example.com" {
|
||||
t.Errorf("Expected X-Forwarded-Host, got %v", response["forwarded_host"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_CustomHeaders(t *testing.T) {
|
||||
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"api_version": r.Header.Get("X-API-Version"),
|
||||
})
|
||||
})
|
||||
defer backend.close()
|
||||
|
||||
proxy, _ := New(&Config{
|
||||
Target: backend.URL(),
|
||||
Headers: map[string]string{
|
||||
"X-API-Version": "{version}",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
// Simulate parameter substitution
|
||||
proxy.ProxyRequest(rr, req, map[string]string{"version": "2"})
|
||||
|
||||
var response map[string]string
|
||||
json.NewDecoder(rr.Body).Decode(&response)
|
||||
|
||||
if response["api_version"] != "2" {
|
||||
t.Errorf("Expected API version 2, got %v", response["api_version"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_RemoteAddrSubstitution(t *testing.T) {
|
||||
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"client_ip": r.Header.Get("X-Client-IP"),
|
||||
})
|
||||
})
|
||||
defer backend.close()
|
||||
|
||||
proxy, _ := New(&Config{
|
||||
Target: backend.URL(),
|
||||
Headers: map[string]string{
|
||||
"X-Client-IP": "$remote_addr",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api", nil)
|
||||
req.RemoteAddr = "10.0.0.1:54321"
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
proxy.ServeHTTP(rr, req)
|
||||
|
||||
var response map[string]string
|
||||
json.NewDecoder(rr.Body).Decode(&response)
|
||||
|
||||
if response["client_ip"] != "10.0.0.1" {
|
||||
t.Errorf("Expected client IP 10.0.0.1, got %v", response["client_ip"])
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Query String Tests ==============
|
||||
|
||||
func TestProxy_QueryStringPreservation(t *testing.T) {
|
||||
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"query": r.URL.RawQuery,
|
||||
"param": r.URL.Query().Get("key"),
|
||||
})
|
||||
})
|
||||
defer backend.close()
|
||||
|
||||
proxy, _ := New(&Config{Target: backend.URL()}, nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/search?key=value&page=2", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
proxy.ServeHTTP(rr, req)
|
||||
|
||||
var response map[string]string
|
||||
json.NewDecoder(rr.Body).Decode(&response)
|
||||
|
||||
if response["param"] != "value" {
|
||||
t.Errorf("Expected query param 'value', got %v", response["param"])
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Status Code Tests ==============
|
||||
|
||||
func TestProxy_StatusCodePreservation(t *testing.T) {
|
||||
statusCodes := []int{200, 201, 400, 404, 500}
|
||||
|
||||
for _, code := range statusCodes {
|
||||
code := code // capture range variable
|
||||
t.Run(fmt.Sprintf("Status_%d", code), func(t *testing.T) {
|
||||
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(code)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
defer backend.close()
|
||||
|
||||
proxy, _ := New(&Config{Target: backend.URL()}, nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/status", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
proxy.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != code {
|
||||
t.Errorf("Expected status %d, got %d", code, rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Error Handling Tests ==============
|
||||
|
||||
func TestProxy_BackendUnavailable(t *testing.T) {
|
||||
// Use a port that's definitely not listening
|
||||
proxy, _ := New(&Config{
|
||||
Target: "http://127.0.0.1:59999",
|
||||
Timeout: 1 * time.Second,
|
||||
}, nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
proxy.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadGateway {
|
||||
t.Errorf("Expected status 502 Bad Gateway, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_Timeout(t *testing.T) {
|
||||
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(2 * time.Second)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
defer backend.close()
|
||||
|
||||
proxy, _ := New(&Config{
|
||||
Target: backend.URL(),
|
||||
Timeout: 100 * time.Millisecond,
|
||||
}, nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/slow", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
proxy.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusGatewayTimeout {
|
||||
t.Errorf("Expected status 504 Gateway Timeout, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Path Handling Tests ==============
|
||||
|
||||
func TestProxy_StripPrefix(t *testing.T) {
|
||||
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"path": r.URL.Path,
|
||||
})
|
||||
})
|
||||
defer backend.close()
|
||||
|
||||
proxy, _ := New(&Config{
|
||||
Target: backend.URL(),
|
||||
StripPrefix: "/api/v1",
|
||||
}, nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/users", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
proxy.ServeHTTP(rr, req)
|
||||
|
||||
var response map[string]string
|
||||
json.NewDecoder(rr.Body).Decode(&response)
|
||||
|
||||
if response["path"] != "/users" {
|
||||
t.Errorf("Expected stripped path /users, got %v", response["path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_TargetWithPath(t *testing.T) {
|
||||
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"path": r.URL.Path,
|
||||
})
|
||||
})
|
||||
defer backend.close()
|
||||
|
||||
proxy, _ := New(&Config{
|
||||
Target: backend.URL() + "/backend",
|
||||
}, nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/resource", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
proxy.ServeHTTP(rr, req)
|
||||
|
||||
var response map[string]string
|
||||
json.NewDecoder(rr.Body).Decode(&response)
|
||||
|
||||
if response["path"] != "/backend/resource" {
|
||||
t.Errorf("Expected path /backend/resource, got %v", response["path"])
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Large Body Tests ==============
|
||||
|
||||
func TestProxy_LargeRequestBody(t *testing.T) {
|
||||
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
json.NewEncoder(w).Encode(map[string]int{
|
||||
"received_bytes": len(body),
|
||||
})
|
||||
})
|
||||
defer backend.close()
|
||||
|
||||
proxy, _ := New(&Config{Target: backend.URL()}, nil)
|
||||
|
||||
// 100KB body
|
||||
largeBody := strings.Repeat("x", 100000)
|
||||
req := httptest.NewRequest("POST", "/upload", strings.NewReader(largeBody))
|
||||
req.ContentLength = int64(len(largeBody))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
proxy.ServeHTTP(rr, req)
|
||||
|
||||
var response map[string]int
|
||||
json.NewDecoder(rr.Body).Decode(&response)
|
||||
|
||||
if response["received_bytes"] != 100000 {
|
||||
t.Errorf("Expected 100000 bytes, got %d", response["received_bytes"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_LargeResponseBody(t *testing.T) {
|
||||
largeResponse := strings.Repeat("y", 100000)
|
||||
|
||||
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(largeResponse))
|
||||
})
|
||||
defer backend.close()
|
||||
|
||||
proxy, _ := New(&Config{Target: backend.URL()}, nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/large", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
proxy.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Body.Len() != 100000 {
|
||||
t.Errorf("Expected 100000 bytes in response, got %d", rr.Body.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Concurrent Requests Tests ==============
|
||||
|
||||
func TestProxy_ConcurrentRequests(t *testing.T) {
|
||||
backend := newTestBackend(nil)
|
||||
defer backend.close()
|
||||
|
||||
proxy, _ := New(&Config{Target: backend.URL()}, nil)
|
||||
|
||||
const numRequests = 50
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, numRequests)
|
||||
|
||||
for i := 0; i < numRequests; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
|
||||
req := httptest.NewRequest("GET", "/concurrent", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
proxy.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
errors <- &net.OpError{Op: "test", Err: context.DeadlineExceeded}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
errorCount := 0
|
||||
for range errors {
|
||||
errorCount++
|
||||
}
|
||||
|
||||
if errorCount > 0 {
|
||||
t.Errorf("Got %d errors in concurrent requests", errorCount)
|
||||
}
|
||||
|
||||
if backend.getRequestCount() != numRequests {
|
||||
t.Errorf("Expected %d requests at backend, got %d", numRequests, backend.getRequestCount())
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Echo Tests ==============
|
||||
|
||||
func TestProxy_Echo(t *testing.T) {
|
||||
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", r.Header.Get("Content-Type"))
|
||||
w.Write(body)
|
||||
})
|
||||
defer backend.close()
|
||||
|
||||
proxy, _ := New(&Config{Target: backend.URL()}, nil)
|
||||
|
||||
testData := "Hello, Proxy!"
|
||||
req := httptest.NewRequest("POST", "/echo", strings.NewReader(testData))
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
proxy.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Body.String() != testData {
|
||||
t.Errorf("Expected echo of '%s', got '%s'", testData, rr.Body.String())
|
||||
}
|
||||
|
||||
if rr.Header().Get("Content-Type") != "text/plain" {
|
||||
t.Errorf("Expected Content-Type text/plain, got %s", rr.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Helper Function Tests ==============
|
||||
|
||||
func TestSingleJoiningSlash(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b, expected string
|
||||
}{
|
||||
{"/api", "/users", "/api/users"},
|
||||
{"/api/", "/users", "/api/users"},
|
||||
{"/api", "users", "/api/users"},
|
||||
{"/api/", "users", "/api/users"},
|
||||
{"", "/users", "/users"},
|
||||
{"/api", "", "/api/"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := singleJoiningSlash(tt.a, tt.b)
|
||||
if result != tt.expected {
|
||||
t.Errorf("singleJoiningSlash(%q, %q) = %q, want %q", tt.a, tt.b, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClientIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
remoteAddr string
|
||||
xRealIP string
|
||||
expected string
|
||||
}{
|
||||
{"192.168.1.1:1234", "", "192.168.1.1"},
|
||||
{"192.168.1.1:1234", "10.0.0.1", "10.0.0.1"},
|
||||
{"invalid", "", "invalid"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = tt.remoteAddr
|
||||
if tt.xRealIP != "" {
|
||||
req.Header.Set("X-Real-IP", tt.xRealIP)
|
||||
}
|
||||
|
||||
result := getClientIP(req)
|
||||
if result != tt.expected {
|
||||
t.Errorf("getClientIP() = %q, want %q", result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetScheme(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tls bool
|
||||
header string
|
||||
expected string
|
||||
}{
|
||||
{"HTTP", false, "", "http"},
|
||||
{"HTTPS from TLS", true, "", "https"},
|
||||
{"HTTPS from header", false, "https", "https"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
if tt.header != "" {
|
||||
req.Header.Set("X-Forwarded-Proto", tt.header)
|
||||
}
|
||||
// Note: httptest doesn't set TLS, so we can only test non-TLS cases fully
|
||||
|
||||
result := getScheme(req)
|
||||
if !tt.tls && result != tt.expected {
|
||||
t.Errorf("getScheme() = %q, want %q", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsConnectionError(t *testing.T) {
|
||||
tests := []struct {
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{nil, false},
|
||||
{&net.OpError{Op: "dial", Err: &net.DNSError{Err: "no such host"}}, true},
|
||||
{context.DeadlineExceeded, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := isConnectionError(tt.err)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isConnectionError(%v) = %v, want %v", tt.err, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Benchmarks ==============
|
||||
|
||||
func BenchmarkProxy_SimpleGET(b *testing.B) {
|
||||
backend := newTestBackend(nil)
|
||||
defer backend.close()
|
||||
|
||||
proxy, _ := New(&Config{Target: backend.URL()}, nil)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
req := httptest.NewRequest("GET", "/bench", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(rr, req)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkProxy_POSTWithBody(b *testing.B) {
|
||||
backend := newTestBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.Copy(io.Discard, r.Body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
defer backend.close()
|
||||
|
||||
proxy, _ := New(&Config{Target: backend.URL()}, nil)
|
||||
body := strings.Repeat("x", 1024) // 1KB body
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
req := httptest.NewRequest("POST", "/bench", strings.NewReader(body))
|
||||
rr := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(rr, req)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
// Package routing provides HTTP routing with regex support
|
||||
package routing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/config"
|
||||
"github.com/konduktor/konduktor/internal/logging"
|
||||
"github.com/konduktor/konduktor/internal/proxy"
|
||||
)
|
||||
|
||||
// RouteMatch represents a matched route with captured parameters
|
||||
type RouteMatch struct {
|
||||
Config map[string]interface{}
|
||||
Params map[string]string
|
||||
}
|
||||
|
||||
// RegexRoute represents a compiled regex route
|
||||
type RegexRoute struct {
|
||||
Pattern *regexp.Regexp
|
||||
Config map[string]interface{}
|
||||
CaseSensitive bool
|
||||
OriginalExpr string
|
||||
}
|
||||
|
||||
// Router handles HTTP routing with exact, regex, and default routes
|
||||
type Router struct {
|
||||
config *config.Config
|
||||
logger *logging.Logger
|
||||
mux *http.ServeMux
|
||||
staticDir string
|
||||
exactRoutes map[string]map[string]interface{}
|
||||
regexRoutes []*RegexRoute
|
||||
defaultRoute map[string]interface{}
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// New creates a new router from config
|
||||
func New(cfg *config.Config, logger *logging.Logger) *Router {
|
||||
staticDir := "./static"
|
||||
if cfg != nil && cfg.HTTP.StaticDir != "" {
|
||||
staticDir = cfg.HTTP.StaticDir
|
||||
}
|
||||
|
||||
r := &Router{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
mux: http.NewServeMux(),
|
||||
staticDir: staticDir,
|
||||
exactRoutes: make(map[string]map[string]interface{}),
|
||||
regexRoutes: make([]*RegexRoute, 0),
|
||||
}
|
||||
|
||||
// Load routes from extensions
|
||||
if cfg != nil {
|
||||
for _, ext := range cfg.Extensions {
|
||||
if ext.Type == "routing" && ext.Config != nil {
|
||||
if locations, ok := ext.Config["regex_locations"].(map[string]interface{}); ok {
|
||||
for pattern, routeCfg := range locations {
|
||||
if rc, ok := routeCfg.(map[string]interface{}); ok {
|
||||
r.AddRoute(pattern, rc)
|
||||
if logger != nil {
|
||||
logger.Debug("Added route", "pattern", pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.setupRoutes()
|
||||
return r
|
||||
}
|
||||
|
||||
// NewRouter creates a router without config (for testing)
|
||||
func NewRouter(opts ...RouterOption) *Router {
|
||||
r := &Router{
|
||||
mux: http.NewServeMux(),
|
||||
staticDir: "./static",
|
||||
exactRoutes: make(map[string]map[string]interface{}),
|
||||
regexRoutes: make([]*RegexRoute, 0),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(r)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// RouterOption is a functional option for Router
|
||||
type RouterOption func(*Router)
|
||||
|
||||
// WithStaticDir sets the static directory
|
||||
func WithStaticDir(dir string) RouterOption {
|
||||
return func(r *Router) {
|
||||
r.staticDir = dir
|
||||
}
|
||||
}
|
||||
|
||||
// StaticDir returns the static directory path
|
||||
func (r *Router) StaticDir() string {
|
||||
return r.staticDir
|
||||
}
|
||||
|
||||
// Routes returns the regex routes (for testing)
|
||||
func (r *Router) Routes() []*RegexRoute {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.regexRoutes
|
||||
}
|
||||
|
||||
// ExactRoutes returns the exact routes (for testing)
|
||||
func (r *Router) ExactRoutes() map[string]map[string]interface{} {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.exactRoutes
|
||||
}
|
||||
|
||||
// DefaultRoute returns the default route (for testing)
|
||||
func (r *Router) DefaultRoute() map[string]interface{} {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.defaultRoute
|
||||
}
|
||||
|
||||
// AddRoute adds a route with the given pattern and config
|
||||
// Pattern formats:
|
||||
// - "=/path" - exact match
|
||||
// - "~regex" - case-sensitive regex
|
||||
// - "~*regex" - case-insensitive regex
|
||||
// - "__default__" - default/fallback route
|
||||
func (r *Router) AddRoute(pattern string, routeConfig map[string]interface{}) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
switch {
|
||||
case pattern == "__default__":
|
||||
r.defaultRoute = routeConfig
|
||||
|
||||
case strings.HasPrefix(pattern, "="):
|
||||
// Exact match route
|
||||
path := strings.TrimPrefix(pattern, "=")
|
||||
r.exactRoutes[path] = routeConfig
|
||||
|
||||
case strings.HasPrefix(pattern, "~*"):
|
||||
// Case-insensitive regex
|
||||
expr := strings.TrimPrefix(pattern, "~*")
|
||||
re, err := regexp.Compile("(?i)" + expr)
|
||||
if err != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Error("Invalid regex pattern", "pattern", pattern, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
r.regexRoutes = append(r.regexRoutes, &RegexRoute{
|
||||
Pattern: re,
|
||||
Config: routeConfig,
|
||||
CaseSensitive: false,
|
||||
OriginalExpr: expr,
|
||||
})
|
||||
|
||||
case strings.HasPrefix(pattern, "~"):
|
||||
// Case-sensitive regex
|
||||
expr := strings.TrimPrefix(pattern, "~")
|
||||
re, err := regexp.Compile(expr)
|
||||
if err != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Error("Invalid regex pattern", "pattern", pattern, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
r.regexRoutes = append(r.regexRoutes, &RegexRoute{
|
||||
Pattern: re,
|
||||
Config: routeConfig,
|
||||
CaseSensitive: true,
|
||||
OriginalExpr: expr,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Match finds the best matching route for a path
|
||||
// Priority: exact match > regex match > default
|
||||
func (r *Router) Match(path string) *RouteMatch {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
// 1. Check exact routes
|
||||
if cfg, ok := r.exactRoutes[path]; ok {
|
||||
return &RouteMatch{
|
||||
Config: cfg,
|
||||
Params: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check regex routes
|
||||
for _, route := range r.regexRoutes {
|
||||
match := route.Pattern.FindStringSubmatch(path)
|
||||
if match != nil {
|
||||
params := make(map[string]string)
|
||||
|
||||
// Extract named groups
|
||||
names := route.Pattern.SubexpNames()
|
||||
for i, name := range names {
|
||||
if i > 0 && name != "" && i < len(match) {
|
||||
params[name] = match[i]
|
||||
}
|
||||
}
|
||||
|
||||
return &RouteMatch{
|
||||
Config: route.Config,
|
||||
Params: params,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check default route
|
||||
if r.defaultRoute != nil {
|
||||
return &RouteMatch{
|
||||
Config: r.defaultRoute,
|
||||
Params: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupRoutes configures the routes from config
|
||||
func (r *Router) setupRoutes() {
|
||||
// Health check endpoint
|
||||
r.mux.HandleFunc("/health", r.healthHandler)
|
||||
|
||||
// Setup redirect instructions from config
|
||||
if r.config != nil {
|
||||
for from, to := range r.config.Server.RedirectInstructions {
|
||||
fromPath := from
|
||||
toPath := to
|
||||
r.mux.HandleFunc(fromPath, func(w http.ResponseWriter, req *http.Request) {
|
||||
http.Redirect(w, req, toPath, http.StatusMovedPermanently)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Default handler for all other routes
|
||||
r.mux.HandleFunc("/", r.defaultHandler)
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler
|
||||
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
r.mux.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
// healthHandler handles health check requests
|
||||
func (r *Router) healthHandler(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
// defaultHandler handles requests that don't match other routes
|
||||
func (r *Router) defaultHandler(w http.ResponseWriter, req *http.Request) {
|
||||
path := req.URL.Path
|
||||
|
||||
// Try to match against configured routes
|
||||
match := r.Match(path)
|
||||
fmt.Printf("DEBUG defaultHandler: path=%q match=%v defaultRoute=%v\n", path, match != nil, r.defaultRoute != nil)
|
||||
if match != nil {
|
||||
fmt.Printf("DEBUG: matched config: %v\n", match.Config)
|
||||
r.handleRouteMatch(w, req, match)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to serve static file
|
||||
if r.staticDir != "" {
|
||||
// Get absolute path for static dir
|
||||
absStaticDir, err := filepath.Abs(r.staticDir)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
filePath := filepath.Join(absStaticDir, filepath.Clean("/"+path))
|
||||
cleanPath := filepath.Clean(filePath)
|
||||
|
||||
// Prevent directory traversal - ensure path is within static dir
|
||||
if !strings.HasPrefix(cleanPath+string(filepath.Separator), absStaticDir+string(filepath.Separator)) {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
info, err := os.Stat(filePath)
|
||||
if err == nil {
|
||||
if info.IsDir() {
|
||||
// Try index.html
|
||||
indexPath := filepath.Join(filePath, "index.html")
|
||||
if _, err := os.Stat(indexPath); err == nil {
|
||||
http.ServeFile(w, req, indexPath)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
http.ServeFile(w, req, filePath)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 404 Not Found
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
|
||||
// handleRouteMatch handles a matched route
|
||||
func (r *Router) handleRouteMatch(w http.ResponseWriter, req *http.Request, match *RouteMatch) {
|
||||
cfg := match.Config
|
||||
|
||||
// Handle proxy_pass directive
|
||||
if proxyTarget, ok := cfg["proxy_pass"].(string); ok {
|
||||
r.handleProxyPass(w, req, proxyTarget, cfg, match.Params)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle "return" directive
|
||||
if ret, ok := cfg["return"].(string); ok {
|
||||
parts := strings.SplitN(ret, " ", 2)
|
||||
statusCode := 200
|
||||
body := "OK"
|
||||
if len(parts) >= 1 {
|
||||
switch parts[0] {
|
||||
case "200":
|
||||
statusCode = 200
|
||||
case "201":
|
||||
statusCode = 201
|
||||
case "301":
|
||||
statusCode = 301
|
||||
case "302":
|
||||
statusCode = 302
|
||||
case "400":
|
||||
statusCode = 400
|
||||
case "404":
|
||||
statusCode = 404
|
||||
case "500":
|
||||
statusCode = 500
|
||||
}
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
body = parts[1]
|
||||
}
|
||||
|
||||
if ct, ok := cfg["content_type"].(string); ok {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
}
|
||||
|
||||
w.WriteHeader(statusCode)
|
||||
w.Write([]byte(body))
|
||||
return
|
||||
}
|
||||
|
||||
// Handle static files with root
|
||||
if root, ok := cfg["root"].(string); ok {
|
||||
path := req.URL.Path
|
||||
|
||||
if indexFile, ok := cfg["index_file"].(string); ok {
|
||||
if path == "/" || strings.HasSuffix(path, "/") {
|
||||
path = "/" + indexFile
|
||||
}
|
||||
}
|
||||
|
||||
// Get absolute path for root dir
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
filePath := filepath.Join(absRoot, filepath.Clean("/"+path))
|
||||
cleanPath := filepath.Clean(filePath)
|
||||
|
||||
// DEBUG
|
||||
fmt.Printf("DEBUG: path=%q absRoot=%q filePath=%q cleanPath=%q\n", path, absRoot, filePath, cleanPath)
|
||||
fmt.Printf("DEBUG: check1=%q check2=%q\n", cleanPath+string(filepath.Separator), absRoot+string(filepath.Separator))
|
||||
|
||||
// Prevent directory traversal
|
||||
if !strings.HasPrefix(cleanPath+string(filepath.Separator), absRoot+string(filepath.Separator)) {
|
||||
fmt.Printf("DEBUG: FORBIDDEN - path not within root\n")
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if cacheControl, ok := cfg["cache_control"].(string); ok {
|
||||
w.Header().Set("Cache-Control", cacheControl)
|
||||
}
|
||||
|
||||
if headers, ok := cfg["headers"].([]interface{}); ok {
|
||||
for _, h := range headers {
|
||||
if header, ok := h.(string); ok {
|
||||
parts := strings.SplitN(header, ": ", 2)
|
||||
if len(parts) == 2 {
|
||||
w.Header().Set(parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http.ServeFile(w, req, filePath)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle SPA fallback
|
||||
if spaFallback, ok := cfg["spa_fallback"].(bool); ok && spaFallback {
|
||||
root := r.staticDir
|
||||
if rt, ok := cfg["root"].(string); ok {
|
||||
root = rt
|
||||
}
|
||||
|
||||
indexFile := "index.html"
|
||||
if idx, ok := cfg["index_file"].(string); ok {
|
||||
indexFile = idx
|
||||
}
|
||||
|
||||
filePath := filepath.Join(root, indexFile)
|
||||
http.ServeFile(w, req, filePath)
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
|
||||
// handleProxyPass proxies the request to the target backend
|
||||
func (r *Router) handleProxyPass(w http.ResponseWriter, req *http.Request, target string, cfg map[string]interface{}, params map[string]string) {
|
||||
// Substitute params in target URL (e.g., {version} -> actual version)
|
||||
for key, value := range params {
|
||||
target = strings.ReplaceAll(target, "{"+key+"}", value)
|
||||
}
|
||||
|
||||
// Create proxy
|
||||
proxyConfig := &proxy.Config{
|
||||
Target: target,
|
||||
Headers: make(map[string]string),
|
||||
}
|
||||
|
||||
// Parse headers from config
|
||||
if headers, ok := cfg["headers"].([]interface{}); ok {
|
||||
for _, h := range headers {
|
||||
if header, ok := h.(string); ok {
|
||||
parts := strings.SplitN(header, ": ", 2)
|
||||
if len(parts) == 2 {
|
||||
// Substitute params in header values
|
||||
headerValue := parts[1]
|
||||
for key, value := range params {
|
||||
headerValue = strings.ReplaceAll(headerValue, "{"+key+"}", value)
|
||||
}
|
||||
proxyConfig.Headers[parts[0]] = headerValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p, err := proxy.New(proxyConfig, r.logger)
|
||||
if err != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Error("Failed to create proxy", "target", target, "error", err)
|
||||
}
|
||||
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
p.ProxyRequest(w, req, params)
|
||||
}
|
||||
|
||||
// CreateRouterFromConfig creates a router from extension config
|
||||
func CreateRouterFromConfig(cfg map[string]interface{}) *Router {
|
||||
router := NewRouter()
|
||||
|
||||
if locations, ok := cfg["regex_locations"].(map[string]interface{}); ok {
|
||||
for pattern, routeCfg := range locations {
|
||||
if rc, ok := routeCfg.(map[string]interface{}); ok {
|
||||
router.AddRoute(pattern, rc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package routing
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ============== Router Initialization Tests ==============
|
||||
|
||||
func TestRouter_Initialization(t *testing.T) {
|
||||
router := NewRouter()
|
||||
|
||||
if router.StaticDir() != "./static" {
|
||||
t.Errorf("Expected static dir ./static, got %s", router.StaticDir())
|
||||
}
|
||||
|
||||
if len(router.Routes()) != 0 {
|
||||
t.Error("Expected empty routes")
|
||||
}
|
||||
|
||||
if len(router.ExactRoutes()) != 0 {
|
||||
t.Error("Expected empty exact routes")
|
||||
}
|
||||
|
||||
if router.DefaultRoute() != nil {
|
||||
t.Error("Expected nil default route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_CustomStaticDir(t *testing.T) {
|
||||
router := NewRouter(WithStaticDir("/custom/path"))
|
||||
|
||||
if router.StaticDir() != "/custom/path" {
|
||||
t.Errorf("Expected static dir /custom/path, got %s", router.StaticDir())
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Route Adding Tests ==============
|
||||
|
||||
func TestRouter_AddExactRoute(t *testing.T) {
|
||||
router := NewRouter()
|
||||
config := map[string]interface{}{"return": "200 OK"}
|
||||
|
||||
router.AddRoute("=/health", config)
|
||||
|
||||
exactRoutes := router.ExactRoutes()
|
||||
if _, ok := exactRoutes["/health"]; !ok {
|
||||
t.Error("Expected /health in exact routes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_AddDefaultRoute(t *testing.T) {
|
||||
router := NewRouter()
|
||||
config := map[string]interface{}{"spa_fallback": true, "root": "./static"}
|
||||
|
||||
router.AddRoute("__default__", config)
|
||||
|
||||
if router.DefaultRoute() == nil {
|
||||
t.Error("Expected default route to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_AddRegexRoute(t *testing.T) {
|
||||
router := NewRouter()
|
||||
config := map[string]interface{}{"root": "./static"}
|
||||
|
||||
router.AddRoute("~^/api/", config)
|
||||
|
||||
if len(router.Routes()) != 1 {
|
||||
t.Errorf("Expected 1 regex route, got %d", len(router.Routes()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_AddCaseInsensitiveRegexRoute(t *testing.T) {
|
||||
router := NewRouter()
|
||||
config := map[string]interface{}{"root": "./static", "cache_control": "public, max-age=3600"}
|
||||
|
||||
router.AddRoute("~*\\.(css|js)$", config)
|
||||
|
||||
if len(router.Routes()) != 1 {
|
||||
t.Errorf("Expected 1 regex route, got %d", len(router.Routes()))
|
||||
}
|
||||
|
||||
if router.Routes()[0].CaseSensitive {
|
||||
t.Error("Expected case-insensitive route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_InvalidRegexPattern(t *testing.T) {
|
||||
router := NewRouter()
|
||||
config := map[string]interface{}{"root": "./static"}
|
||||
|
||||
// Invalid regex - unmatched bracket
|
||||
router.AddRoute("~^/api/[invalid", config)
|
||||
|
||||
// Should not add invalid pattern
|
||||
if len(router.Routes()) != 0 {
|
||||
t.Error("Should not add invalid regex pattern")
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Route Matching Tests ==============
|
||||
|
||||
func TestRouter_MatchExactRoute(t *testing.T) {
|
||||
router := NewRouter()
|
||||
config := map[string]interface{}{"return": "200 OK"}
|
||||
router.AddRoute("=/health", config)
|
||||
|
||||
match := router.Match("/health")
|
||||
|
||||
if match == nil {
|
||||
t.Fatal("Expected match for /health")
|
||||
}
|
||||
|
||||
if match.Config["return"] != "200 OK" {
|
||||
t.Error("Expected return config")
|
||||
}
|
||||
|
||||
if len(match.Params) != 0 {
|
||||
t.Error("Expected empty params for exact match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_MatchExactRouteNoMatch(t *testing.T) {
|
||||
router := NewRouter()
|
||||
config := map[string]interface{}{"return": "200 OK"}
|
||||
router.AddRoute("=/health", config)
|
||||
|
||||
match := router.Match("/healthcheck")
|
||||
|
||||
if match != nil {
|
||||
t.Error("Exact route should not match /healthcheck")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_MatchRegexRoute(t *testing.T) {
|
||||
router := NewRouter()
|
||||
config := map[string]interface{}{"proxy_pass": "http://localhost:9001"}
|
||||
router.AddRoute("~^/api/v\\d+/", config)
|
||||
|
||||
match := router.Match("/api/v1/users")
|
||||
|
||||
if match == nil {
|
||||
t.Fatal("Expected match for /api/v1/users")
|
||||
}
|
||||
|
||||
if match.Config["proxy_pass"] != "http://localhost:9001" {
|
||||
t.Error("Expected proxy_pass config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_MatchRegexRouteWithGroups(t *testing.T) {
|
||||
router := NewRouter()
|
||||
config := map[string]interface{}{"proxy_pass": "http://localhost:9001"}
|
||||
router.AddRoute("~^/api/v(?P<version>\\d+)/", config)
|
||||
|
||||
match := router.Match("/api/v2/data")
|
||||
|
||||
if match == nil {
|
||||
t.Fatal("Expected match for /api/v2/data")
|
||||
}
|
||||
|
||||
if match.Params["version"] != "2" {
|
||||
t.Errorf("Expected version=2, got %s", match.Params["version"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_MatchCaseInsensitiveRegex(t *testing.T) {
|
||||
router := NewRouter()
|
||||
config := map[string]interface{}{"root": "./static", "cache_control": "public, max-age=3600"}
|
||||
router.AddRoute("~*\\.(CSS|JS)$", config)
|
||||
|
||||
// Should match lowercase
|
||||
match1 := router.Match("/styles/main.css")
|
||||
if match1 == nil {
|
||||
t.Error("Should match lowercase .css")
|
||||
}
|
||||
|
||||
// Should match uppercase
|
||||
match2 := router.Match("/scripts/app.JS")
|
||||
if match2 == nil {
|
||||
t.Error("Should match uppercase .JS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_MatchCaseSensitiveRegex(t *testing.T) {
|
||||
router := NewRouter()
|
||||
config := map[string]interface{}{"root": "./static"}
|
||||
router.AddRoute("~\\.(css)$", config)
|
||||
|
||||
// Should match lowercase
|
||||
match1 := router.Match("/styles/main.css")
|
||||
if match1 == nil {
|
||||
t.Error("Should match lowercase .css")
|
||||
}
|
||||
|
||||
// Should NOT match uppercase
|
||||
match2 := router.Match("/styles/main.CSS")
|
||||
if match2 != nil {
|
||||
t.Error("Should not match uppercase .CSS for case-sensitive regex")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_MatchDefaultRoute(t *testing.T) {
|
||||
router := NewRouter()
|
||||
router.AddRoute("=/health", map[string]interface{}{"return": "200 OK"})
|
||||
router.AddRoute("__default__", map[string]interface{}{"spa_fallback": true})
|
||||
|
||||
match := router.Match("/unknown/path")
|
||||
|
||||
if match == nil {
|
||||
t.Fatal("Expected default route match")
|
||||
}
|
||||
|
||||
if match.Config["spa_fallback"] != true {
|
||||
t.Error("Expected spa_fallback config from default route")
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Priority Tests ==============
|
||||
|
||||
func TestRouter_PriorityExactOverRegex(t *testing.T) {
|
||||
router := NewRouter()
|
||||
router.AddRoute("=/api/status", map[string]interface{}{"return": "200 Exact"})
|
||||
router.AddRoute("~^/api/", map[string]interface{}{"proxy_pass": "http://localhost:9001"})
|
||||
|
||||
match := router.Match("/api/status")
|
||||
|
||||
if match == nil {
|
||||
t.Fatal("Expected match")
|
||||
}
|
||||
|
||||
if match.Config["return"] != "200 Exact" {
|
||||
t.Error("Exact match should have priority over regex")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouter_PriorityRegexOverDefault(t *testing.T) {
|
||||
router := NewRouter()
|
||||
router.AddRoute("~^/api/", map[string]interface{}{"proxy_pass": "http://localhost:9001"})
|
||||
router.AddRoute("__default__", map[string]interface{}{"spa_fallback": true})
|
||||
|
||||
match := router.Match("/api/v1/users")
|
||||
|
||||
if match == nil {
|
||||
t.Fatal("Expected match")
|
||||
}
|
||||
|
||||
if match.Config["proxy_pass"] != "http://localhost:9001" {
|
||||
t.Error("Regex match should have priority over default")
|
||||
}
|
||||
}
|
||||
|
||||
// ============== CreateRouterFromConfig Tests ==============
|
||||
|
||||
func TestCreateRouterFromConfig(t *testing.T) {
|
||||
config := map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"=/health": map[string]interface{}{
|
||||
"return": "200 OK",
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
"~^/api/": map[string]interface{}{
|
||||
"proxy_pass": "http://localhost:9001",
|
||||
},
|
||||
"__default__": map[string]interface{}{
|
||||
"spa_fallback": true,
|
||||
"root": "./static",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
router := CreateRouterFromConfig(config)
|
||||
|
||||
// Check exact route
|
||||
if _, ok := router.ExactRoutes()["/health"]; !ok {
|
||||
t.Error("Expected /health exact route")
|
||||
}
|
||||
|
||||
// Check regex route
|
||||
if len(router.Routes()) != 1 {
|
||||
t.Errorf("Expected 1 regex route, got %d", len(router.Routes()))
|
||||
}
|
||||
|
||||
// Check default route
|
||||
if router.DefaultRoute() == nil {
|
||||
t.Error("Expected default route")
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Static Dir Path Tests ==============
|
||||
|
||||
func TestRouter_StaticDirPath(t *testing.T) {
|
||||
router := NewRouter(WithStaticDir("/var/www/html"))
|
||||
|
||||
expected, _ := filepath.Abs("/var/www/html")
|
||||
actual, _ := filepath.Abs(router.StaticDir())
|
||||
|
||||
if actual != expected {
|
||||
t.Errorf("Expected static dir %s, got %s", expected, actual)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Concurrent Access Tests ==============
|
||||
|
||||
func TestRouter_ConcurrentAccess(t *testing.T) {
|
||||
router := NewRouter()
|
||||
|
||||
// Add routes concurrently
|
||||
done := make(chan bool, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(n int) {
|
||||
router.AddRoute("~^/api/v"+string(rune('0'+n))+"/", map[string]interface{}{
|
||||
"proxy_pass": "http://localhost:900" + string(rune('0'+n)),
|
||||
})
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Match routes concurrently
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(n int) {
|
||||
router.Match("/api/v" + string(rune('0'+n)) + "/users")
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Benchmarks ==============
|
||||
|
||||
func BenchmarkRouter_MatchExact(b *testing.B) {
|
||||
router := NewRouter()
|
||||
router.AddRoute("=/health", map[string]interface{}{"return": "200 OK"})
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
router.Match("/health")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRouter_MatchRegex(b *testing.B) {
|
||||
router := NewRouter()
|
||||
router.AddRoute("~^/api/v(?P<version>\\d+)/", map[string]interface{}{"proxy_pass": "http://localhost:9001"})
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
router.Match("/api/v1/users/123")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRouter_MatchWithManyRoutes(b *testing.B) {
|
||||
router := NewRouter()
|
||||
|
||||
// Add many routes
|
||||
for i := 0; i < 50; i++ {
|
||||
router.AddRoute("~^/api/v"+string(rune('0'+i%10))+"/service"+string(rune('0'+i/10))+"/",
|
||||
map[string]interface{}{"proxy_pass": "http://localhost:9001"})
|
||||
}
|
||||
router.AddRoute("__default__", map[string]interface{}{"spa_fallback": true})
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
router.Match("/api/v5/service3/users/123")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Package server provides the HTTP server implementation
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/config"
|
||||
"github.com/konduktor/konduktor/internal/extension"
|
||||
"github.com/konduktor/konduktor/internal/logging"
|
||||
"github.com/konduktor/konduktor/internal/middleware"
|
||||
)
|
||||
|
||||
const Version = "0.2.0"
|
||||
|
||||
// Server represents the Konduktor HTTP server
|
||||
type Server struct {
|
||||
config *config.Config
|
||||
httpServer *http.Server
|
||||
extensionManager *extension.Manager
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// New creates a new server instance
|
||||
func New(cfg *config.Config) (*Server, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid configuration: %w", err)
|
||||
}
|
||||
|
||||
logger, err := logging.NewFromConfig(cfg.Logging)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create logger: %w", err)
|
||||
}
|
||||
|
||||
// Create extension manager
|
||||
extManager := extension.NewManager(logger)
|
||||
|
||||
// Load extensions from config
|
||||
for _, extCfg := range cfg.Extensions {
|
||||
// Add static_dir to routing config if not present
|
||||
if extCfg.Type == "routing" {
|
||||
if extCfg.Config == nil {
|
||||
extCfg.Config = make(map[string]interface{})
|
||||
}
|
||||
if _, ok := extCfg.Config["static_dir"]; !ok {
|
||||
extCfg.Config["static_dir"] = cfg.HTTP.StaticDir
|
||||
}
|
||||
}
|
||||
|
||||
if err := extManager.LoadExtension(extCfg.Type, extCfg.Config); err != nil {
|
||||
logger.Error("Failed to load extension", "type", extCfg.Type, "error", err)
|
||||
// Continue loading other extensions
|
||||
}
|
||||
}
|
||||
|
||||
srv := &Server{
|
||||
config: cfg,
|
||||
extensionManager: extManager,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// Run starts the server and blocks until shutdown
|
||||
func (s *Server) Run() error {
|
||||
// Build handler chain with middleware
|
||||
handler := s.buildHandler()
|
||||
|
||||
// Create HTTP server
|
||||
addr := fmt.Sprintf("%s:%d", s.config.Server.Host, s.config.Server.Port)
|
||||
s.httpServer = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
s.logger.Info("Server starting", "addr", addr, "version", Version)
|
||||
|
||||
var err error
|
||||
if s.config.SSL.Enabled {
|
||||
err = s.httpServer.ListenAndServeTLS(s.config.SSL.CertFile, s.config.SSL.KeyFile)
|
||||
} else {
|
||||
err = s.httpServer.ListenAndServe()
|
||||
}
|
||||
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
errChan <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown signal
|
||||
return s.waitForShutdown(errChan)
|
||||
}
|
||||
|
||||
// buildHandler builds the HTTP handler chain
|
||||
func (s *Server) buildHandler() http.Handler {
|
||||
// Create base handler that returns 404
|
||||
baseHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
// Wrap with extension manager
|
||||
var handler http.Handler = s.extensionManager.Handler(baseHandler)
|
||||
|
||||
// Add middleware (applied in reverse order)
|
||||
handler = middleware.AccessLog(handler, s.logger)
|
||||
handler = middleware.ServerHeader(handler, Version)
|
||||
handler = middleware.Recovery(handler, s.logger)
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
// waitForShutdown waits for shutdown signal and gracefully stops the server
|
||||
func (s *Server) waitForShutdown(errChan <-chan error) error {
|
||||
// Listen for shutdown signals
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case err := <-errChan:
|
||||
return err
|
||||
case sig := <-sigChan:
|
||||
s.logger.Info("Shutdown signal received", "signal", sig.String())
|
||||
}
|
||||
|
||||
// Graceful shutdown with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
s.logger.Info("Shutting down server...")
|
||||
|
||||
// Cleanup extensions
|
||||
s.extensionManager.Cleanup()
|
||||
|
||||
if err := s.httpServer.Shutdown(ctx); err != nil {
|
||||
s.logger.Error("Error during shutdown", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("Server stopped gracefully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the server
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
return s.httpServer.Shutdown(ctx)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
# Integration Tests
|
||||
|
||||
Интеграционные тесты для Konduktor — полноценное тестирование сервера с реальными HTTP запросами.
|
||||
|
||||
## Отличие от unit-тестов
|
||||
|
||||
| Аспект | Unit-тесты | Интеграционные тесты |
|
||||
|--------|------------|---------------------|
|
||||
| Scope | Отдельный модуль в изоляции | Весь сервер целиком |
|
||||
| Backend | Mock (httptest.Server) | Реальные HTTP серверы |
|
||||
| Config | Программный | YAML конфигурация |
|
||||
| Extensions | Не тестируются | Полная цепочка обработки |
|
||||
|
||||
## Структура тестов
|
||||
|
||||
```
|
||||
tests/integration/
|
||||
├── README.md # Эта документация
|
||||
├── helpers_test.go # Общие хелперы и утилиты
|
||||
├── reverse_proxy_test.go # Тесты reverse proxy
|
||||
├── routing_test.go # Тесты маршрутизации (TODO)
|
||||
├── security_test.go # Тесты security extension (TODO)
|
||||
├── caching_test.go # Тесты caching extension (TODO)
|
||||
└── static_files_test.go # Тесты статических файлов (TODO)
|
||||
```
|
||||
|
||||
## Что тестируют интеграционные тесты
|
||||
|
||||
### 1. Reverse Proxy (`reverse_proxy_test.go`)
|
||||
|
||||
- [x] Базовое проксирование GET/POST/PUT/DELETE
|
||||
- [x] Exact match routes (`=/api/version`)
|
||||
- [x] Regex routes с параметрами (`~^/api/resource/(?P<id>\d+)$`)
|
||||
- [x] Подстановка параметров в target URL (`{id}`, `{tag}`)
|
||||
- [x] Подстановка переменных в заголовки (`$remote_addr`)
|
||||
- [x] Передача заголовков X-Forwarded-For, X-Real-IP
|
||||
- [x] Сохранение query string
|
||||
- [x] Обработка ошибок backend (502, 504)
|
||||
- [x] Таймауты соединения
|
||||
|
||||
### 2. Routing Extension (`routing_test.go`)
|
||||
|
||||
- [x] Приоритет маршрутов (exact > regex > default)
|
||||
- [x] Case-sensitive regex (`~`)
|
||||
- [x] Case-insensitive regex (`~*`)
|
||||
- [x] Default route (`__default__`)
|
||||
- [x] Return directive (`return 200 "OK"`)
|
||||
- [x] Regex с именованными группами
|
||||
- [x] Множественные regex маршруты
|
||||
- [x] Кастомные заголовки в маршрутах
|
||||
- [x] Обработка отсутствия маршрута
|
||||
|
||||
### 3. Security Extension (`security_test.go`)
|
||||
|
||||
- [ ] IP whitelist
|
||||
- [ ] IP blacklist
|
||||
- [ ] CIDR нотация (10.0.0.0/8)
|
||||
- [ ] Security headers (X-Frame-Options, X-Content-Type-Options)
|
||||
- [ ] Rate limiting
|
||||
- [ ] Комбинация с другими extensions
|
||||
|
||||
### 4. Caching Extension (`caching_test.go`)
|
||||
|
||||
- [x] Cache hit/miss
|
||||
- [x] TTL expiration
|
||||
- [x] Pattern-based caching
|
||||
- [x] Cache-Control headers (X-Cache header)
|
||||
- [x] Кэширование только GET запросов
|
||||
- [x] Разные пути = разные ключи кэша
|
||||
- [x] Query string влияет на ключ кэша
|
||||
- [x] Ошибки не кэшируются
|
||||
- [x] Конкурентный доступ к кэшу
|
||||
- [x] Множественные паттерны кэширования
|
||||
|
||||
### 5. Static Files (`static_files_test.go`)
|
||||
|
||||
- [ ] Serving статических файлов
|
||||
- [ ] Index file (index.html)
|
||||
- [ ] MIME types
|
||||
- [ ] Cache-Control для static
|
||||
- [ ] SPA fallback
|
||||
- [ ] Directory traversal protection
|
||||
- [ ] 404 для несуществующих файлов
|
||||
|
||||
### 6. Extension Chain (`extension_chain_test.go`)
|
||||
|
||||
- [ ] Порядок выполнения extensions (security → caching → routing)
|
||||
- [ ] Прерывание цепочки при ошибке
|
||||
- [ ] Совместная работа extensions
|
||||
|
||||
## Запуск тестов
|
||||
|
||||
```bash
|
||||
# Все интеграционные тесты
|
||||
go test ./tests/integration/... -v
|
||||
|
||||
# Конкретный файл
|
||||
go test ./tests/integration/... -v -run TestReverseProxy
|
||||
|
||||
# С таймаутом (интеграционные тесты медленнее)
|
||||
go test ./tests/integration/... -v -timeout 60s
|
||||
|
||||
# С покрытием
|
||||
go test ./tests/integration/... -v -coverprofile=coverage.out
|
||||
```
|
||||
|
||||
## Требования
|
||||
|
||||
- Свободные порты: тесты используют случайные порты (`:0`)
|
||||
- Сетевой доступ: для localhost соединений
|
||||
- Время: интеграционные тесты занимают больше времени (~5-10 сек)
|
||||
|
||||
## Добавление новых тестов
|
||||
|
||||
1. Создайте файл `*_test.go` в `tests/integration/`
|
||||
2. Используйте хелперы из `helpers_test.go`:
|
||||
- `startTestServer()` — запуск Konduktor сервера
|
||||
- `startBackend()` — запуск mock backend
|
||||
- `makeRequest()` — отправка HTTP запроса
|
||||
3. Добавьте описание в этот README
|
||||
|
||||
## CI/CD
|
||||
|
||||
Интеграционные тесты запускаются отдельно от unit-тестов:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
jobs:
|
||||
unit-tests:
|
||||
run: go test ./internal/...
|
||||
|
||||
integration-tests:
|
||||
run: go test ./tests/integration/... -timeout 120s
|
||||
```
|
||||
@@ -0,0 +1,666 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/extension"
|
||||
)
|
||||
|
||||
// ============== Basic Cache Hit/Miss Tests ==============
|
||||
|
||||
func TestCaching_BasicHitMiss(t *testing.T) {
|
||||
var requestCount int64
|
||||
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
count := atomic.AddInt64(&requestCount, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"request_number": count,
|
||||
"timestamp": time.Now().UnixNano(),
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
|
||||
// Create caching extension
|
||||
cachingExt, err := extension.NewCachingExtension(map[string]interface{}{
|
||||
"default_ttl": "1m",
|
||||
"cache_patterns": []interface{}{
|
||||
map[string]interface{}{
|
||||
"pattern": "^/api/.*",
|
||||
"ttl": "30s",
|
||||
"methods": []interface{}{"GET"},
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create caching extension: %v", err)
|
||||
}
|
||||
|
||||
// Create routing extension
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{cachingExt, routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
// First request - should be MISS
|
||||
resp1, err := client.Get("/api/data", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request 1 failed: %v", err)
|
||||
}
|
||||
|
||||
cacheHeader1 := resp1.Header.Get("X-Cache")
|
||||
var result1 map[string]interface{}
|
||||
json.NewDecoder(resp1.Body).Decode(&result1)
|
||||
resp1.Body.Close()
|
||||
|
||||
if cacheHeader1 != "MISS" {
|
||||
t.Errorf("Expected X-Cache: MISS for first request, got %q", cacheHeader1)
|
||||
}
|
||||
|
||||
// Second request - should be HIT (same response)
|
||||
resp2, err := client.Get("/api/data", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request 2 failed: %v", err)
|
||||
}
|
||||
|
||||
cacheHeader2 := resp2.Header.Get("X-Cache")
|
||||
var result2 map[string]interface{}
|
||||
json.NewDecoder(resp2.Body).Decode(&result2)
|
||||
resp2.Body.Close()
|
||||
|
||||
if cacheHeader2 != "HIT" {
|
||||
t.Errorf("Expected X-Cache: HIT for second request, got %q", cacheHeader2)
|
||||
}
|
||||
|
||||
// Verify same response (from cache)
|
||||
if result1["request_number"] != result2["request_number"] {
|
||||
t.Errorf("Expected same request_number from cache, got %v and %v",
|
||||
result1["request_number"], result2["request_number"])
|
||||
}
|
||||
|
||||
// Backend should only receive 1 request
|
||||
if atomic.LoadInt64(&requestCount) != 1 {
|
||||
t.Errorf("Expected 1 backend request, got %d", requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== TTL Expiration Tests ==============
|
||||
|
||||
func TestCaching_TTLExpiration(t *testing.T) {
|
||||
var requestCount int64
|
||||
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
count := atomic.AddInt64(&requestCount, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"request_number": count,
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
|
||||
// Create caching extension with short TTL
|
||||
cachingExt, _ := extension.NewCachingExtension(map[string]interface{}{
|
||||
"default_ttl": "100ms", // Very short TTL for testing
|
||||
"cache_patterns": []interface{}{
|
||||
map[string]interface{}{
|
||||
"pattern": "^/api/.*",
|
||||
"ttl": "100ms",
|
||||
"methods": []interface{}{"GET"},
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{cachingExt, routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
// First request
|
||||
resp1, _ := client.Get("/api/data", nil)
|
||||
var result1 map[string]interface{}
|
||||
json.NewDecoder(resp1.Body).Decode(&result1)
|
||||
resp1.Body.Close()
|
||||
|
||||
// Second request (within TTL) - should be HIT
|
||||
resp2, _ := client.Get("/api/data", nil)
|
||||
cacheHeader2 := resp2.Header.Get("X-Cache")
|
||||
resp2.Body.Close()
|
||||
|
||||
if cacheHeader2 != "HIT" {
|
||||
t.Errorf("Expected X-Cache: HIT before TTL expires, got %q", cacheHeader2)
|
||||
}
|
||||
|
||||
// Wait for TTL to expire
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Third request (after TTL) - should be MISS
|
||||
resp3, _ := client.Get("/api/data", nil)
|
||||
cacheHeader3 := resp3.Header.Get("X-Cache")
|
||||
var result3 map[string]interface{}
|
||||
json.NewDecoder(resp3.Body).Decode(&result3)
|
||||
resp3.Body.Close()
|
||||
|
||||
if cacheHeader3 != "MISS" {
|
||||
t.Errorf("Expected X-Cache: MISS after TTL expires, got %q", cacheHeader3)
|
||||
}
|
||||
|
||||
// Verify new request was made (different request_number)
|
||||
if result1["request_number"] == result3["request_number"] {
|
||||
t.Error("Expected different request_number after TTL expiration")
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Pattern-Based Caching Tests ==============
|
||||
|
||||
func TestCaching_PatternBasedCaching(t *testing.T) {
|
||||
var apiCount, staticCount int64
|
||||
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path[:5] == "/api/" {
|
||||
atomic.AddInt64(&apiCount, 1)
|
||||
} else {
|
||||
atomic.AddInt64(&staticCount, 1)
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
|
||||
// Only cache /api/* paths
|
||||
cachingExt, _ := extension.NewCachingExtension(map[string]interface{}{
|
||||
"default_ttl": "1m",
|
||||
"cache_patterns": []interface{}{
|
||||
map[string]interface{}{
|
||||
"pattern": "^/api/.*",
|
||||
"ttl": "1m",
|
||||
"methods": []interface{}{"GET"},
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{cachingExt, routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
// Multiple requests to /api/ - should be cached
|
||||
for i := 0; i < 3; i++ {
|
||||
resp, _ := client.Get("/api/users", nil)
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// Multiple requests to /static/ - should NOT be cached (not matching pattern)
|
||||
for i := 0; i < 3; i++ {
|
||||
resp, _ := client.Get("/static/file.js", nil)
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// API should have only 1 request (cached)
|
||||
if atomic.LoadInt64(&apiCount) != 1 {
|
||||
t.Errorf("Expected 1 API request (cached), got %d", apiCount)
|
||||
}
|
||||
|
||||
// Static should have 3 requests (not cached)
|
||||
if atomic.LoadInt64(&staticCount) != 3 {
|
||||
t.Errorf("Expected 3 static requests (not cached), got %d", staticCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Method-Specific Caching Tests ==============
|
||||
|
||||
func TestCaching_OnlyGETMethodCached(t *testing.T) {
|
||||
var getCount, postCount int64
|
||||
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.Method == "GET" {
|
||||
atomic.AddInt64(&getCount, 1)
|
||||
} else if r.Method == "POST" {
|
||||
atomic.AddInt64(&postCount, 1)
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"method": r.Method,
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
|
||||
cachingExt, _ := extension.NewCachingExtension(map[string]interface{}{
|
||||
"default_ttl": "1m",
|
||||
"cache_patterns": []interface{}{
|
||||
map[string]interface{}{
|
||||
"pattern": "^/api/.*",
|
||||
"ttl": "1m",
|
||||
"methods": []interface{}{"GET"}, // Only GET
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{cachingExt, routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
// Multiple GET requests - should be cached
|
||||
for i := 0; i < 3; i++ {
|
||||
resp, _ := client.Get("/api/data", nil)
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// Multiple POST requests - should NOT be cached
|
||||
for i := 0; i < 3; i++ {
|
||||
resp, _ := client.Post("/api/data", []byte(`{}`), map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
if atomic.LoadInt64(&getCount) != 1 {
|
||||
t.Errorf("Expected 1 GET request (cached), got %d", getCount)
|
||||
}
|
||||
|
||||
if atomic.LoadInt64(&postCount) != 3 {
|
||||
t.Errorf("Expected 3 POST requests (not cached), got %d", postCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Different Paths Different Cache Keys ==============
|
||||
|
||||
func TestCaching_DifferentPathsDifferentCacheKeys(t *testing.T) {
|
||||
var requestCount int64
|
||||
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
count := atomic.AddInt64(&requestCount, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"path": r.URL.Path,
|
||||
"request_number": count,
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
|
||||
cachingExt, _ := extension.NewCachingExtension(map[string]interface{}{
|
||||
"default_ttl": "1m",
|
||||
"cache_patterns": []interface{}{
|
||||
map[string]interface{}{
|
||||
"pattern": "^/api/.*",
|
||||
"ttl": "1m",
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{cachingExt, routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
// Request different paths
|
||||
paths := []string{"/api/users", "/api/posts", "/api/comments"}
|
||||
|
||||
for _, path := range paths {
|
||||
resp, _ := client.Get(path, nil)
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// Each path should result in a separate backend request
|
||||
if atomic.LoadInt64(&requestCount) != 3 {
|
||||
t.Errorf("Expected 3 backend requests (one per path), got %d", requestCount)
|
||||
}
|
||||
|
||||
// Request same paths again - all should be cached
|
||||
for _, path := range paths {
|
||||
resp, _ := client.Get(path, nil)
|
||||
cacheHeader := resp.Header.Get("X-Cache")
|
||||
resp.Body.Close()
|
||||
|
||||
if cacheHeader != "HIT" {
|
||||
t.Errorf("Expected X-Cache: HIT for %s, got %q", path, cacheHeader)
|
||||
}
|
||||
}
|
||||
|
||||
// No additional backend requests
|
||||
if atomic.LoadInt64(&requestCount) != 3 {
|
||||
t.Errorf("Expected still 3 backend requests after cache hits, got %d", requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Query String Affects Cache Key ==============
|
||||
|
||||
func TestCaching_QueryStringAffectsCacheKey(t *testing.T) {
|
||||
var requestCount int64
|
||||
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
count := atomic.AddInt64(&requestCount, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"query": r.URL.RawQuery,
|
||||
"request_number": count,
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
|
||||
cachingExt, _ := extension.NewCachingExtension(map[string]interface{}{
|
||||
"default_ttl": "1m",
|
||||
"cache_patterns": []interface{}{
|
||||
map[string]interface{}{
|
||||
"pattern": "^/api/.*",
|
||||
"ttl": "1m",
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{cachingExt, routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
// Different query strings = different cache keys
|
||||
queries := []string{
|
||||
"/api/search?q=hello",
|
||||
"/api/search?q=world",
|
||||
"/api/search?q=test",
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
resp, _ := client.Get(query, nil)
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// Each unique query should result in a separate backend request
|
||||
if atomic.LoadInt64(&requestCount) != 3 {
|
||||
t.Errorf("Expected 3 backend requests (one per query), got %d", requestCount)
|
||||
}
|
||||
|
||||
// Same query again should be cached
|
||||
resp, _ := client.Get("/api/search?q=hello", nil)
|
||||
cacheHeader := resp.Header.Get("X-Cache")
|
||||
resp.Body.Close()
|
||||
|
||||
if cacheHeader != "HIT" {
|
||||
t.Errorf("Expected X-Cache: HIT for repeated query, got %q", cacheHeader)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Cache Does Not Store Error Responses ==============
|
||||
|
||||
func TestCaching_DoesNotCacheErrors(t *testing.T) {
|
||||
var requestCount int64
|
||||
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(&requestCount, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "internal error"})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
|
||||
cachingExt, _ := extension.NewCachingExtension(map[string]interface{}{
|
||||
"default_ttl": "1m",
|
||||
"cache_patterns": []interface{}{
|
||||
map[string]interface{}{
|
||||
"pattern": "^/api/.*",
|
||||
"ttl": "1m",
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{cachingExt, routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
// Multiple requests to error endpoint
|
||||
for i := 0; i < 3; i++ {
|
||||
resp, _ := client.Get("/api/error", nil)
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// All requests should reach backend (errors not cached)
|
||||
if atomic.LoadInt64(&requestCount) != 3 {
|
||||
t.Errorf("Expected 3 backend requests (errors not cached), got %d", requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Concurrent Cache Access ==============
|
||||
|
||||
func TestCaching_ConcurrentAccess(t *testing.T) {
|
||||
var requestCount int64
|
||||
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Small delay to increase chance of race conditions
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
count := atomic.AddInt64(&requestCount, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"request_number": count,
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
|
||||
cachingExt, _ := extension.NewCachingExtension(map[string]interface{}{
|
||||
"default_ttl": "1m",
|
||||
"cache_patterns": []interface{}{
|
||||
map[string]interface{}{
|
||||
"pattern": "^/api/.*",
|
||||
"ttl": "1m",
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{cachingExt, routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
const numRequests = 20
|
||||
results := make(chan error, numRequests)
|
||||
|
||||
// Make first request to populate cache
|
||||
client := NewHTTPClient(server.URL)
|
||||
resp, _ := client.Get("/api/concurrent", nil)
|
||||
resp.Body.Close()
|
||||
|
||||
// Now many concurrent requests should all hit cache
|
||||
for i := 0; i < numRequests; i++ {
|
||||
go func(n int) {
|
||||
client := NewHTTPClient(server.URL)
|
||||
resp, err := client.Get("/api/concurrent", nil)
|
||||
if err != nil {
|
||||
results <- err
|
||||
return
|
||||
}
|
||||
|
||||
cacheHeader := resp.Header.Get("X-Cache")
|
||||
resp.Body.Close()
|
||||
|
||||
if cacheHeader != "HIT" {
|
||||
results <- fmt.Errorf("request %d: expected HIT, got %s", n, cacheHeader)
|
||||
return
|
||||
}
|
||||
results <- nil
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Collect results
|
||||
var errors []error
|
||||
for i := 0; i < numRequests; i++ {
|
||||
if err := <-results; err != nil {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
t.Errorf("Got %d errors in concurrent cache access: %v", len(errors), errors[:min(5, len(errors))])
|
||||
}
|
||||
|
||||
// Only 1 request should reach backend (the initial one)
|
||||
if atomic.LoadInt64(&requestCount) != 1 {
|
||||
t.Errorf("Expected 1 backend request, got %d", requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Multiple Cache Patterns ==============
|
||||
|
||||
func TestCaching_MultipleCachePatterns(t *testing.T) {
|
||||
var apiCount, staticCount int64
|
||||
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if len(r.URL.Path) >= 5 && r.URL.Path[:5] == "/api/" {
|
||||
atomic.AddInt64(&apiCount, 1)
|
||||
} else if len(r.URL.Path) >= 8 && r.URL.Path[:8] == "/static/" {
|
||||
atomic.AddInt64(&staticCount, 1)
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
|
||||
cachingExt, _ := extension.NewCachingExtension(map[string]interface{}{
|
||||
"default_ttl": "1m",
|
||||
"cache_patterns": []interface{}{
|
||||
map[string]interface{}{
|
||||
"pattern": "^/api/.*",
|
||||
"ttl": "30s",
|
||||
"methods": []interface{}{"GET"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"pattern": "^/static/.*",
|
||||
"ttl": "1h", // Static files cached longer
|
||||
"methods": []interface{}{"GET"},
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{cachingExt, routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
// Multiple requests to both patterns
|
||||
for i := 0; i < 3; i++ {
|
||||
resp1, _ := client.Get("/api/data", nil)
|
||||
resp1.Body.Close()
|
||||
|
||||
resp2, _ := client.Get("/static/app.js", nil)
|
||||
resp2.Body.Close()
|
||||
}
|
||||
|
||||
// Both should be cached (1 request each)
|
||||
if atomic.LoadInt64(&apiCount) != 1 {
|
||||
t.Errorf("Expected 1 API request, got %d", apiCount)
|
||||
}
|
||||
|
||||
if atomic.LoadInt64(&staticCount) != 1 {
|
||||
t.Errorf("Expected 1 static request, got %d", staticCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/extension"
|
||||
"github.com/konduktor/konduktor/internal/logging"
|
||||
"github.com/konduktor/konduktor/internal/middleware"
|
||||
)
|
||||
|
||||
// TestServer represents a running Konduktor server for testing
|
||||
type TestServer struct {
|
||||
Server *http.Server
|
||||
URL string
|
||||
Port int
|
||||
listener net.Listener
|
||||
handler http.Handler
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
// TestBackend represents a mock backend server
|
||||
type TestBackend struct {
|
||||
server *httptest.Server
|
||||
requestLog []RequestLogEntry
|
||||
mu sync.Mutex
|
||||
requestCount int64
|
||||
handler http.HandlerFunc
|
||||
}
|
||||
|
||||
// RequestLogEntry stores information about a received request
|
||||
type RequestLogEntry struct {
|
||||
Method string
|
||||
Path string
|
||||
Query string
|
||||
Headers http.Header
|
||||
Body string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// ServerConfig holds configuration for starting a test server
|
||||
type ServerConfig struct {
|
||||
Extensions []extension.Extension
|
||||
StaticDir string
|
||||
Middleware []func(http.Handler) http.Handler
|
||||
}
|
||||
|
||||
// ============== Test Server ==============
|
||||
|
||||
// StartTestServer creates and starts a Konduktor server for testing
|
||||
func StartTestServer(t *testing.T, cfg *ServerConfig) *TestServer {
|
||||
t.Helper()
|
||||
|
||||
logger, err := logging.New(logging.Config{
|
||||
Level: "DEBUG",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create logger: %v", err)
|
||||
}
|
||||
|
||||
// Create extension manager
|
||||
extManager := extension.NewManager(logger)
|
||||
|
||||
// Add extensions if provided
|
||||
if cfg != nil && len(cfg.Extensions) > 0 {
|
||||
for _, ext := range cfg.Extensions {
|
||||
if err := extManager.AddExtension(ext); err != nil {
|
||||
t.Fatalf("Failed to add extension: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a fallback handler for when no extension handles the request
|
||||
fallback := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
// Create handler chain
|
||||
var handler http.Handler = extManager.Handler(fallback)
|
||||
|
||||
// Add middleware
|
||||
handler = middleware.AccessLog(handler, logger)
|
||||
handler = middleware.Recovery(handler, logger)
|
||||
|
||||
// Add custom middleware if provided
|
||||
if cfg != nil {
|
||||
for _, mw := range cfg.Middleware {
|
||||
handler = mw(handler)
|
||||
}
|
||||
}
|
||||
|
||||
// Find available port
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to find available port: %v", err)
|
||||
}
|
||||
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
server := &http.Server{
|
||||
Handler: handler,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
ts := &TestServer{
|
||||
Server: server,
|
||||
URL: fmt.Sprintf("http://127.0.0.1:%d", port),
|
||||
Port: port,
|
||||
listener: listener,
|
||||
handler: handler,
|
||||
t: t,
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
// Don't fail test here as server might be intentionally closed
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for server to be ready
|
||||
ts.waitReady()
|
||||
|
||||
return ts
|
||||
}
|
||||
|
||||
// waitReady waits for the server to be ready to accept connections
|
||||
func (ts *TestServer) waitReady() {
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", ts.Port), 100*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
ts.t.Fatal("Server failed to start within timeout")
|
||||
}
|
||||
|
||||
// Close shuts down the test server
|
||||
func (ts *TestServer) Close() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
ts.Server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// ============== Test Backend ==============
|
||||
|
||||
// StartBackend creates and starts a mock backend server
|
||||
func StartBackend(handler http.HandlerFunc) *TestBackend {
|
||||
tb := &TestBackend{
|
||||
requestLog: make([]RequestLogEntry, 0),
|
||||
handler: handler,
|
||||
}
|
||||
|
||||
if handler == nil {
|
||||
handler = tb.defaultHandler
|
||||
}
|
||||
|
||||
tb.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tb.logRequest(r)
|
||||
handler(w, r)
|
||||
}))
|
||||
|
||||
return tb
|
||||
}
|
||||
|
||||
func (tb *TestBackend) logRequest(r *http.Request) {
|
||||
tb.mu.Lock()
|
||||
defer tb.mu.Unlock()
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
tb.requestLog = append(tb.requestLog, RequestLogEntry{
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
Query: r.URL.RawQuery,
|
||||
Headers: r.Header.Clone(),
|
||||
Body: string(body),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
atomic.AddInt64(&tb.requestCount, 1)
|
||||
}
|
||||
|
||||
func (tb *TestBackend) defaultHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"backend": "default",
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
"query": r.URL.RawQuery,
|
||||
"received": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// URL returns the backend server URL
|
||||
func (tb *TestBackend) URL() string {
|
||||
return tb.server.URL
|
||||
}
|
||||
|
||||
// Close shuts down the backend server
|
||||
func (tb *TestBackend) Close() {
|
||||
tb.server.Close()
|
||||
}
|
||||
|
||||
// RequestCount returns the number of requests received
|
||||
func (tb *TestBackend) RequestCount() int64 {
|
||||
return atomic.LoadInt64(&tb.requestCount)
|
||||
}
|
||||
|
||||
// LastRequest returns the most recent request
|
||||
func (tb *TestBackend) LastRequest() *RequestLogEntry {
|
||||
tb.mu.Lock()
|
||||
defer tb.mu.Unlock()
|
||||
if len(tb.requestLog) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &tb.requestLog[len(tb.requestLog)-1]
|
||||
}
|
||||
|
||||
// AllRequests returns all logged requests
|
||||
func (tb *TestBackend) AllRequests() []RequestLogEntry {
|
||||
tb.mu.Lock()
|
||||
defer tb.mu.Unlock()
|
||||
result := make([]RequestLogEntry, len(tb.requestLog))
|
||||
copy(result, tb.requestLog)
|
||||
return result
|
||||
}
|
||||
|
||||
// ============== HTTP Client Helpers ==============
|
||||
|
||||
// HTTPClient is a configured HTTP client for testing
|
||||
type HTTPClient struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewHTTPClient creates a new test HTTP client
|
||||
func NewHTTPClient(baseURL string) *HTTPClient {
|
||||
return &HTTPClient{
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse // Don't follow redirects
|
||||
},
|
||||
},
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// Get performs a GET request
|
||||
func (c *HTTPClient) Get(path string, headers map[string]string) (*http.Response, error) {
|
||||
return c.Do("GET", path, nil, headers)
|
||||
}
|
||||
|
||||
// Post performs a POST request
|
||||
func (c *HTTPClient) Post(path string, body []byte, headers map[string]string) (*http.Response, error) {
|
||||
return c.Do("POST", path, body, headers)
|
||||
}
|
||||
|
||||
// Do performs an HTTP request
|
||||
func (c *HTTPClient) Do(method, path string, body []byte, headers map[string]string) (*http.Response, error) {
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, c.baseURL+path, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
return c.client.Do(req)
|
||||
}
|
||||
|
||||
// GetJSON performs GET and decodes JSON response
|
||||
func (c *HTTPClient) GetJSON(path string, result interface{}) (*http.Response, error) {
|
||||
resp, err := c.Get(path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ============== File System Helpers ==============
|
||||
|
||||
// CreateTempDir creates a temporary directory for static files
|
||||
func CreateTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp("", "konduktor-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||
return dir
|
||||
}
|
||||
|
||||
// CreateTempFile creates a temporary file with given content
|
||||
func CreateTempFile(t *testing.T, dir, name, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
|
||||
// Create parent directories if needed
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatalf("Failed to create directories: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to write file: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// ============== Assertion Helpers ==============
|
||||
|
||||
// AssertStatus checks if response has expected status code
|
||||
func AssertStatus(t *testing.T, resp *http.Response, expected int) {
|
||||
t.Helper()
|
||||
if resp.StatusCode != expected {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Errorf("Expected status %d, got %d. Body: %s", expected, resp.StatusCode, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// AssertHeader checks if response has expected header value
|
||||
func AssertHeader(t *testing.T, resp *http.Response, header, expected string) {
|
||||
t.Helper()
|
||||
actual := resp.Header.Get(header)
|
||||
if actual != expected {
|
||||
t.Errorf("Expected header %s=%q, got %q", header, expected, actual)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertHeaderContains checks if header contains substring
|
||||
func AssertHeaderContains(t *testing.T, resp *http.Response, header, substring string) {
|
||||
t.Helper()
|
||||
actual := resp.Header.Get(header)
|
||||
if actual == "" || !contains(actual, substring) {
|
||||
t.Errorf("Expected header %s to contain %q, got %q", header, substring, actual)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertJSONField checks if JSON response has expected field value
|
||||
func AssertJSONField(t *testing.T, body []byte, field string, expected interface{}) {
|
||||
t.Helper()
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
t.Fatalf("Failed to parse JSON: %v", err)
|
||||
}
|
||||
|
||||
actual, ok := data[field]
|
||||
if !ok {
|
||||
t.Errorf("Field %q not found in JSON", field)
|
||||
return
|
||||
}
|
||||
|
||||
if actual != expected {
|
||||
t.Errorf("Expected %s=%v, got %v", field, expected, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr, 0))
|
||||
}
|
||||
|
||||
func containsAt(s, substr string, start int) bool {
|
||||
for i := start; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ReadBody reads and returns response body
|
||||
func ReadBody(t *testing.T, resp *http.Response) []byte {
|
||||
t.Helper()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read body: %v", err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/extension"
|
||||
"github.com/konduktor/konduktor/internal/logging"
|
||||
)
|
||||
|
||||
// createTestLogger creates a logger for tests
|
||||
func createTestLogger(t *testing.T) *logging.Logger {
|
||||
t.Helper()
|
||||
logger, err := logging.New(logging.Config{Level: "DEBUG"})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create logger: %v", err)
|
||||
}
|
||||
return logger
|
||||
}
|
||||
|
||||
// ============== Basic Reverse Proxy Tests ==============
|
||||
|
||||
func TestReverseProxy_BasicGET(t *testing.T) {
|
||||
// Start backend server
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": "Hello from backend",
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
// Create routing extension with proxy to backend
|
||||
logger := createTestLogger(t)
|
||||
routingExt, err := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create routing extension: %v", err)
|
||||
}
|
||||
|
||||
// Start Konduktor server
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
// Make request through Konduktor
|
||||
client := NewHTTPClient(server.URL)
|
||||
resp, err := client.Get("/api/test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Verify response
|
||||
AssertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if result["message"] != "Hello from backend" {
|
||||
t.Errorf("Unexpected message: %v", result["message"])
|
||||
}
|
||||
|
||||
if result["path"] != "/api/test" {
|
||||
t.Errorf("Expected path /api/test, got %v", result["path"])
|
||||
}
|
||||
|
||||
// Verify backend received request
|
||||
if backend.RequestCount() != 1 {
|
||||
t.Errorf("Expected 1 backend request, got %d", backend.RequestCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseProxy_POST(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"received": body,
|
||||
"method": r.Method,
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
body := []byte(`{"name":"test","value":123}`)
|
||||
resp, err := client.Post("/api/data", body, map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
AssertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if result["method"] != "POST" {
|
||||
t.Errorf("Expected method POST, got %v", result["method"])
|
||||
}
|
||||
|
||||
received := result["received"].(map[string]interface{})
|
||||
if received["name"] != "test" {
|
||||
t.Errorf("Expected name 'test', got %v", received["name"])
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Exact Match Routes ==============
|
||||
|
||||
func TestReverseProxy_ExactMatchRoute(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"endpoint": "version",
|
||||
"path": r.URL.Path,
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
// Exact match - should use backend URL as-is
|
||||
"=/api/version": map[string]interface{}{
|
||||
"proxy_pass": backend.URL() + "/releases/latest",
|
||||
},
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
// Test exact match route
|
||||
resp, err := client.Get("/api/version", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
AssertStatus(t, resp, http.StatusOK)
|
||||
|
||||
lastReq := backend.LastRequest()
|
||||
if lastReq == nil {
|
||||
t.Fatal("No request received by backend")
|
||||
}
|
||||
|
||||
// For exact match, the target path should be used as-is (IgnoreRequestPath=true)
|
||||
if lastReq.Path != "/releases/latest" {
|
||||
t.Errorf("Expected backend path /releases/latest, got %s", lastReq.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Regex Routes with Parameters ==============
|
||||
|
||||
func TestReverseProxy_RegexRouteWithParams(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"path": r.URL.Path,
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
// Regex with named group
|
||||
"~^/api/users/(?P<id>\\d+)$": map[string]interface{}{
|
||||
"proxy_pass": backend.URL() + "/v2/users/{id}",
|
||||
},
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
// Test regex route with parameter
|
||||
resp, err := client.Get("/api/users/42", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
AssertStatus(t, resp, http.StatusOK)
|
||||
|
||||
lastReq := backend.LastRequest()
|
||||
if lastReq == nil {
|
||||
t.Fatal("No request received by backend")
|
||||
}
|
||||
|
||||
// Parameter {id} should be substituted
|
||||
if lastReq.Path != "/v2/users/42" {
|
||||
t.Errorf("Expected backend path /v2/users/42, got %s", lastReq.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Header Forwarding ==============
|
||||
|
||||
func TestReverseProxy_HeaderForwarding(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"x-forwarded-for": r.Header.Get("X-Forwarded-For"),
|
||||
"x-real-ip": r.Header.Get("X-Real-IP"),
|
||||
"x-custom": r.Header.Get("X-Custom"),
|
||||
"x-forwarded-host": r.Header.Get("X-Forwarded-Host"),
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
"headers": []interface{}{
|
||||
"X-Forwarded-For: $remote_addr",
|
||||
"X-Real-IP: $remote_addr",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
resp, err := client.Get("/test", map[string]string{
|
||||
"X-Custom": "custom-value",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
// X-Custom should be forwarded
|
||||
if result["x-custom"] != "custom-value" {
|
||||
t.Errorf("Expected X-Custom header to be forwarded, got %v", result["x-custom"])
|
||||
}
|
||||
|
||||
// X-Forwarded-For should be set (will contain 127.0.0.1)
|
||||
if result["x-forwarded-for"] == "" {
|
||||
t.Error("Expected X-Forwarded-For header to be set")
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Query String ==============
|
||||
|
||||
func TestReverseProxy_QueryStringPreservation(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"query": r.URL.RawQuery,
|
||||
"foo": r.URL.Query().Get("foo"),
|
||||
"bar": r.URL.Query().Get("bar"),
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
resp, err := client.Get("/search?foo=hello&bar=world", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if result["foo"] != "hello" {
|
||||
t.Errorf("Expected foo=hello, got %v", result["foo"])
|
||||
}
|
||||
|
||||
if result["bar"] != "world" {
|
||||
t.Errorf("Expected bar=world, got %v", result["bar"])
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Error Handling ==============
|
||||
|
||||
func TestReverseProxy_BackendUnavailable(t *testing.T) {
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
// Non-existent backend
|
||||
"proxy_pass": "http://127.0.0.1:59999",
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
resp, err := client.Get("/test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should return 502 Bad Gateway
|
||||
AssertStatus(t, resp, http.StatusBadGateway)
|
||||
}
|
||||
|
||||
func TestReverseProxy_BackendTimeout(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Simulate slow backend
|
||||
time.Sleep(3 * time.Second)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
"timeout": 0.5, // 500ms timeout
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
resp, err := client.Get("/slow", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should return 504 Gateway Timeout
|
||||
AssertStatus(t, resp, http.StatusGatewayTimeout)
|
||||
}
|
||||
|
||||
// ============== HTTP Methods ==============
|
||||
|
||||
func TestReverseProxy_AllMethods(t *testing.T) {
|
||||
methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"}
|
||||
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"method": r.Method,
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
for _, method := range methods {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
resp, err := client.Do(method, "/resource", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
AssertStatus(t, resp, http.StatusOK)
|
||||
|
||||
if method != "HEAD" {
|
||||
var result map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if result["method"] != method {
|
||||
t.Errorf("Expected method %s, got %v", method, result["method"])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Large Bodies ==============
|
||||
|
||||
func TestReverseProxy_LargeRequestBody(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
json.NewEncoder(w).Encode(map[string]int{
|
||||
"received": len(body),
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
// 1MB body
|
||||
largeBody := []byte(strings.Repeat("x", 1024*1024))
|
||||
resp, err := client.Post("/upload", largeBody, map[string]string{
|
||||
"Content-Type": "application/octet-stream",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
AssertStatus(t, resp, http.StatusOK)
|
||||
}
|
||||
|
||||
// ============== Concurrent Requests ==============
|
||||
|
||||
func TestReverseProxy_ConcurrentRequests(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Small delay to simulate work
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
const numRequests = 50
|
||||
results := make(chan error, numRequests)
|
||||
|
||||
for i := 0; i < numRequests; i++ {
|
||||
go func(n int) {
|
||||
client := NewHTTPClient(server.URL)
|
||||
resp, err := client.Get(fmt.Sprintf("/concurrent/%d", n), nil)
|
||||
if err != nil {
|
||||
results <- err
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
results <- fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
results <- nil
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Collect results
|
||||
var errors []error
|
||||
for i := 0; i < numRequests; i++ {
|
||||
if err := <-results; err != nil {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
t.Errorf("Got %d errors in concurrent requests: %v", len(errors), errors[:min(5, len(errors))])
|
||||
}
|
||||
|
||||
// Verify all requests reached backend
|
||||
if backend.RequestCount() != numRequests {
|
||||
t.Errorf("Expected %d backend requests, got %d", numRequests, backend.RequestCount())
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/extension"
|
||||
)
|
||||
|
||||
// ============== Route Priority Tests ==============
|
||||
|
||||
func TestRouting_ExactMatchPriority(t *testing.T) {
|
||||
// Exact match should have highest priority
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"path": r.URL.Path,
|
||||
"source": "default",
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, err := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
// Exact match - highest priority
|
||||
"=/api/status": map[string]interface{}{
|
||||
"return": "200 exact-match",
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
// Regex that also matches /api/status
|
||||
"~^/api/.*": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create routing extension: %v", err)
|
||||
}
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
// Test exact match route - should return static response
|
||||
resp, err := client.Get("/api/status", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
AssertStatus(t, resp, http.StatusOK)
|
||||
|
||||
body := ReadBody(t, resp)
|
||||
if string(body) != "exact-match" {
|
||||
t.Errorf("Expected 'exact-match', got %q", string(body))
|
||||
}
|
||||
|
||||
// Regex route should be used for other /api/* paths
|
||||
resp2, err := client.Get("/api/other", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
AssertStatus(t, resp2, http.StatusOK)
|
||||
|
||||
// Verify it went to backend
|
||||
if backend.RequestCount() != 1 {
|
||||
t.Errorf("Expected 1 backend request, got %d", backend.RequestCount())
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Case Sensitivity Tests ==============
|
||||
|
||||
func TestRouting_CaseSensitiveRegex(t *testing.T) {
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
// Case-sensitive regex (~)
|
||||
"~^/API/test$": map[string]interface{}{
|
||||
"return": "200 case-sensitive",
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
"__default__": map[string]interface{}{
|
||||
"return": "200 default",
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
// Exact case match should work
|
||||
resp, err := client.Get("/API/test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body := ReadBody(t, resp)
|
||||
if string(body) != "case-sensitive" {
|
||||
t.Errorf("Expected 'case-sensitive' for /API/test, got %q", string(body))
|
||||
}
|
||||
|
||||
// Different case should NOT match
|
||||
resp2, err := client.Get("/api/test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
body2 := ReadBody(t, resp2)
|
||||
if string(body2) != "default" {
|
||||
t.Errorf("Expected 'default' for /api/test (case mismatch), got %q", string(body2))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouting_CaseInsensitiveRegex(t *testing.T) {
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
// Case-insensitive regex (~*)
|
||||
"~*^/api/test$": map[string]interface{}{
|
||||
"return": "200 case-insensitive",
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
"__default__": map[string]interface{}{
|
||||
"return": "200 default",
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
testCases := []struct {
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{"/api/test", "case-insensitive"},
|
||||
{"/API/test", "case-insensitive"},
|
||||
{"/Api/Test", "case-insensitive"},
|
||||
{"/API/TEST", "case-insensitive"},
|
||||
{"/api/other", "default"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
resp, err := client.Get(tc.path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body := ReadBody(t, resp)
|
||||
if string(body) != tc.expected {
|
||||
t.Errorf("Expected %q for %s, got %q", tc.expected, tc.path, string(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Default Route Tests ==============
|
||||
|
||||
func TestRouting_DefaultRoute(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"handler": "default",
|
||||
"path": r.URL.Path,
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"=/specific": map[string]interface{}{
|
||||
"return": "200 specific",
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
// Non-matching paths should go to default
|
||||
paths := []string{"/", "/random", "/path/to/resource", "/api/v1/users"}
|
||||
|
||||
for _, path := range paths {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
resp, err := client.Get(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
AssertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var result map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if result["handler"] != "default" {
|
||||
t.Errorf("Expected default handler, got %v", result["handler"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Return Directive Tests ==============
|
||||
|
||||
func TestRouting_ReturnDirective(t *testing.T) {
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"=/health": map[string]interface{}{
|
||||
"return": "200 OK",
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
"=/status": map[string]interface{}{
|
||||
"return": "200 {\"status\": \"healthy\"}",
|
||||
"content_type": "application/json",
|
||||
},
|
||||
"=/forbidden": map[string]interface{}{
|
||||
"return": "404 Not Found",
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
"__default__": map[string]interface{}{
|
||||
"return": "200 default",
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
testCases := []struct {
|
||||
path string
|
||||
expectedStatus int
|
||||
expectedBody string
|
||||
contentType string
|
||||
}{
|
||||
{"/health", 200, "OK", "text/plain"},
|
||||
{"/status", 200, `{"status": "healthy"}`, "application/json"},
|
||||
{"/forbidden", 404, "Not Found", "text/plain"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
resp, err := client.Get(tc.path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
AssertStatus(t, resp, tc.expectedStatus)
|
||||
AssertHeaderContains(t, resp, "Content-Type", tc.contentType)
|
||||
|
||||
body := ReadBody(t, resp)
|
||||
if string(body) != tc.expectedBody {
|
||||
t.Errorf("Expected body %q, got %q", tc.expectedBody, string(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Multiple Regex Routes Tests ==============
|
||||
|
||||
func TestRouting_MultipleRegexRoutes(t *testing.T) {
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"~^/api/v1/.*": map[string]interface{}{
|
||||
"return": "200 v1",
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
"~^/api/v2/.*": map[string]interface{}{
|
||||
"return": "200 v2",
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
"~^/api/.*": map[string]interface{}{
|
||||
"return": "200 api-generic",
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
"__default__": map[string]interface{}{
|
||||
"return": "200 default",
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
testCases := []struct {
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{"/api/v1/users", "v1"},
|
||||
{"/api/v2/users", "v2"},
|
||||
{"/api/v3/users", "api-generic"},
|
||||
{"/other", "default"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
resp, err := client.Get(tc.path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body := ReadBody(t, resp)
|
||||
if string(body) != tc.expected {
|
||||
t.Errorf("Expected %q for %s, got %q", tc.expected, tc.path, string(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Regex with Named Groups ==============
|
||||
|
||||
func TestRouting_RegexNamedGroups(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"path": r.URL.Path,
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"~^/users/(?P<userId>\\d+)/posts/(?P<postId>\\d+)$": map[string]interface{}{
|
||||
"proxy_pass": backend.URL() + "/api/v2/users/{userId}/posts/{postId}",
|
||||
},
|
||||
"~^/items/(?P<category>[a-z]+)/(?P<id>\\d+)$": map[string]interface{}{
|
||||
"proxy_pass": backend.URL() + "/catalog/{category}/item/{id}",
|
||||
},
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
testCases := []struct {
|
||||
requestPath string
|
||||
expectedPath string
|
||||
}{
|
||||
{"/users/123/posts/456", "/api/v2/users/123/posts/456"},
|
||||
{"/items/electronics/789", "/catalog/electronics/item/789"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.requestPath, func(t *testing.T) {
|
||||
resp, err := client.Get(tc.requestPath, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
AssertStatus(t, resp, http.StatusOK)
|
||||
|
||||
lastReq := backend.LastRequest()
|
||||
if lastReq == nil {
|
||||
t.Fatal("No request received by backend")
|
||||
}
|
||||
|
||||
if lastReq.Path != tc.expectedPath {
|
||||
t.Errorf("Expected backend path %s, got %s", tc.expectedPath, lastReq.Path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============== No Matching Route Tests ==============
|
||||
|
||||
func TestRouting_NoMatchingRoute(t *testing.T) {
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"=/specific": map[string]interface{}{
|
||||
"return": "200 specific",
|
||||
"content_type": "text/plain",
|
||||
},
|
||||
// No default route
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
// Request to non-matching path should return 404
|
||||
resp, err := client.Get("/other", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
AssertStatus(t, resp, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// ============== Headers in Return Tests ==============
|
||||
|
||||
func TestRouting_CustomHeaders(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"x-custom-header": r.Header.Get("X-Custom-Header"),
|
||||
"x-api-version": r.Header.Get("X-API-Version"),
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
"headers": []interface{}{
|
||||
"X-Custom-Header: custom-value",
|
||||
"X-API-Version: v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
resp, err := client.Get("/test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if result["x-custom-header"] != "custom-value" {
|
||||
t.Errorf("Expected X-Custom-Header=custom-value, got %v", result["x-custom-header"])
|
||||
}
|
||||
|
||||
if result["x-api-version"] != "v1" {
|
||||
t.Errorf("Expected X-API-Version=v1, got %v", result["x-api-version"])
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,22 @@ warn_unused_ignores = True
|
||||
warn_no_return = True
|
||||
warn_unreachable = True
|
||||
strict_equality = True
|
||||
exclude = (?x)(
|
||||
^pyserve/_path_matcher\.pyx$
|
||||
)
|
||||
|
||||
[mypy-tests.*]
|
||||
disallow_untyped_defs = False
|
||||
|
||||
[mypy-pyserve._path_matcher]
|
||||
ignore_missing_imports = True
|
||||
follow_imports = skip
|
||||
|
||||
[mypy-django.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-a2wsgi]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-asgiref.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
Generated
+673
-4
@@ -1,5 +1,44 @@
|
||||
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "a2wsgi"
|
||||
version = "1.10.10"
|
||||
description = "Convert WSGI app to ASGI app or ASGI app to WSGI app."
|
||||
optional = true
|
||||
python-versions = ">=3.8.0"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"wsgi\" or extra == \"flask\" or extra == \"all-frameworks\""
|
||||
files = [
|
||||
{file = "a2wsgi-1.10.10-py3-none-any.whl", hash = "sha256:d2b21379479718539dc15fce53b876251a0efe7615352dfe49f6ad1bc507848d"},
|
||||
{file = "a2wsgi-1.10.10.tar.gz", hash = "sha256:a5bcffb52081ba39df0d5e9a884fc6f819d92e3a42389343ba77cbf809fe1f45"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
description = "Document parameters, class attributes, return types, and variables inline, with Annotated."
|
||||
optional = true
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"fastapi\" or extra == \"all-frameworks\""
|
||||
files = [
|
||||
{file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"},
|
||||
{file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
description = "Reusable constraint types to use with typing.Annotated"
|
||||
optional = true
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"fastapi\" or extra == \"all-frameworks\""
|
||||
files = [
|
||||
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
|
||||
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.10.0"
|
||||
@@ -20,6 +59,22 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
|
||||
[package.extras]
|
||||
trio = ["trio (>=0.26.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
version = "3.11.0"
|
||||
description = "ASGI specs, helper code, and adapters"
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"django\" or extra == \"all-frameworks\""
|
||||
files = [
|
||||
{file = "asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d"},
|
||||
{file = "asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"]
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "25.1.0"
|
||||
@@ -65,6 +120,19 @@ d = ["aiohttp (>=3.10)"]
|
||||
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
|
||||
uvloop = ["uvloop (>=0.15.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
version = "1.9.0"
|
||||
description = "Fast, simple object-to-object and broadcast signaling"
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"flask\" or extra == \"all-frameworks\""
|
||||
files = [
|
||||
{file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"},
|
||||
{file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.11.12"
|
||||
@@ -79,14 +147,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.2.1"
|
||||
version = "8.3.1"
|
||||
description = "Composable command line interface toolkit"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"},
|
||||
{file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"},
|
||||
{file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"},
|
||||
{file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -206,6 +274,101 @@ files = [
|
||||
[package.extras]
|
||||
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
|
||||
|
||||
[[package]]
|
||||
name = "cython"
|
||||
version = "3.2.2"
|
||||
description = "The Cython compiler for writing C extensions in the Python language."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "cython-3.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b5afac4e77e71a9010dc7fd3191ced00f9b12b494dd7525c140781054ce63a73"},
|
||||
{file = "cython-3.2.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd2ede6af225499ad22888dbfb13b92d71fc1016f401ee637559a5831b177c2"},
|
||||
{file = "cython-3.2.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c9265b3e84ae2d999b7c3165c683e366bbbbbe4346468055ca2366fe013f2df"},
|
||||
{file = "cython-3.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:d7b3447b2005dffc5f276d420a480d2b57d15091242652d410b6a46fb00ed251"},
|
||||
{file = "cython-3.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d140c2701cbb8cf960300cf1b67f3b4fa9d294d32e51b85f329bff56936a82fd"},
|
||||
{file = "cython-3.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50bbaabee733fd2780985e459fc20f655e02def83e8eff10220ad88455a34622"},
|
||||
{file = "cython-3.2.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9509f1e9c41c86b790cff745bb31927bbc861662a3b462596d71d3d2a578abb"},
|
||||
{file = "cython-3.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:034ab96cb8bc8e7432bc27491f8d66f51e435b1eb21ddc03aa844be8f21ad847"},
|
||||
{file = "cython-3.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:692a41c8fe06fb2dc55ca2c8d71c80c469fd16fe69486ed99f3b3cbb2d3af83f"},
|
||||
{file = "cython-3.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:098590c1dc309f8a0406ade031963a95a87714296b425539f9920aebf924560d"},
|
||||
{file = "cython-3.2.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3898c076e9c458bcb3e4936187919fda5f5365fe4c567d35d2b003444b6f3fe"},
|
||||
{file = "cython-3.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:2b910b89a2a71004064c5e890b9512a251eda63fae252caa0feb9835057035f9"},
|
||||
{file = "cython-3.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa24cd0bdab27ca099b2467806c684404add597c1108e07ddf7b6471653c85d7"},
|
||||
{file = "cython-3.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60f4aa425e1ff98abf8d965ae7020f06dd2cbc01dbd945137d2f9cca4ff0524a"},
|
||||
{file = "cython-3.2.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a473df474ba89e9fee81ee82b31062a267f9e598096b222783477e56d02ad12c"},
|
||||
{file = "cython-3.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:b4df52101209817fde7284cf779156f79142fb639b1d7840f11680ff4bb30604"},
|
||||
{file = "cython-3.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:177faf4d61e9f2d4d2db61194ac9ec16d3fe3041c1b6830f871a01935319eeb3"},
|
||||
{file = "cython-3.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8db28aef793c81dc69383b619ca508668998aaf099cd839d3cbae85184cce744"},
|
||||
{file = "cython-3.2.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3de43a5786033a27fae1c882feb5ff0d023c38b83356e6800c1be0bcd6cf9f11"},
|
||||
{file = "cython-3.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:fed44d0ab2d36f1b0301c770b0dafec23bcb9700d58e7769cd6d9136b3304c11"},
|
||||
{file = "cython-3.2.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e7200309b81f4066cf36a96efeec646716ca74afd73d159045169263db891133"},
|
||||
{file = "cython-3.2.2-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e72ee88a9a5381d30a6da116a3c8352730b9b038a49ed9bc5c3d0ed6d69b06c"},
|
||||
{file = "cython-3.2.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e35ff0f1bb3a7a5c40afb8fb540e4178b6551909f10748bf39d323f8140ccf3"},
|
||||
{file = "cython-3.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:b223c1f84c3420c24f6a4858e979524bd35a79437a5839e29d41201c87ed119d"},
|
||||
{file = "cython-3.2.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:a6387e3ad31342443916db9a419509935fddd8d4cbac34aab9c895ae55326a56"},
|
||||
{file = "cython-3.2.2-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:436eb562d0affbc0b959f62f3f9c1ed251b9499e4f29c1d19514ae859894b6bf"},
|
||||
{file = "cython-3.2.2-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f560ff3aea5b5df93853ec7bf1a1e9623d6d511f4192f197559aca18fca43392"},
|
||||
{file = "cython-3.2.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d8c93fe128b58942832b1fcac96e48f93c2c69b569eff0d38d30fb5995fecfa0"},
|
||||
{file = "cython-3.2.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b4fe499eed7cd70b2aa4e096b9ce2588f5e6fdf049b46d40a5e55efcde6e4904"},
|
||||
{file = "cython-3.2.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:14432d7f207245a3c35556155873f494784169297b28978a6204f1c60d31553e"},
|
||||
{file = "cython-3.2.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:820c4a99dbf6b3e6c0300be42b4040b501eff0e1feeb80cfa52c48a346fb0df2"},
|
||||
{file = "cython-3.2.2-cp39-abi3-win32.whl", hash = "sha256:826cad0ad43ab05a26e873b5d625f64d458dc739ec6fdeecab848b60a91c4252"},
|
||||
{file = "cython-3.2.2-cp39-abi3-win_arm64.whl", hash = "sha256:5f818d40bbcf17e2089e2de7840f0de1c0ca527acf9b044aba79d5f5d8a5bdba"},
|
||||
{file = "cython-3.2.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ff07e784ea748225bbdea07fec0ac451379e9e41a0a84cb57b36db19dd01ae71"},
|
||||
{file = "cython-3.2.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff11412ed5fc78bd8b148621f4d1034fcad6cfcba468c20cd9f327b4f61ec3e"},
|
||||
{file = "cython-3.2.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca18d9d53c0e2f0c9347478b37532b46e0dc34c704e052ab1b0d8b21a290fc0f"},
|
||||
{file = "cython-3.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:86b1d39a1ea974dd16fe3bcef0df7b64dadd0bd38d05a339f287b48d37cb109f"},
|
||||
{file = "cython-3.2.2-py3-none-any.whl", hash = "sha256:13b99ecb9482aff6a6c12d1ca6feef6940c507af909914b49f568de74fa965fb"},
|
||||
{file = "cython-3.2.2.tar.gz", hash = "sha256:c3add3d483acc73129a61d105389344d792c17e7c1cee24863f16416bd071634"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.2.9"
|
||||
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
|
||||
optional = true
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"django\" or extra == \"all-frameworks\""
|
||||
files = [
|
||||
{file = "django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a"},
|
||||
{file = "django-5.2.9.tar.gz", hash = "sha256:16b5ccfc5e8c27e6c0561af551d2ea32852d7352c67d452ae3e76b4f6b2ca495"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
asgiref = ">=3.8.1"
|
||||
sqlparse = ">=0.3.1"
|
||||
tzdata = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
|
||||
[package.extras]
|
||||
argon2 = ["argon2-cffi (>=19.1.0)"]
|
||||
bcrypt = ["bcrypt"]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.123.5"
|
||||
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
|
||||
optional = true
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"fastapi\" or extra == \"all-frameworks\""
|
||||
files = [
|
||||
{file = "fastapi-0.123.5-py3-none-any.whl", hash = "sha256:a9c708e47c0fa424139cddb8601d0f92d3111b77843c22e9c8d0164d65fe3c97"},
|
||||
{file = "fastapi-0.123.5.tar.gz", hash = "sha256:54bbb660ca231d3985474498b51c621ddcf8888d9a4c1ecb10aa40ec217e4965"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
annotated-doc = ">=0.0.2"
|
||||
pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
|
||||
starlette = ">=0.40.0,<0.51.0"
|
||||
typing-extensions = ">=4.8.0"
|
||||
|
||||
[package.extras]
|
||||
all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
|
||||
standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
|
||||
standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "flake8"
|
||||
version = "7.3.0"
|
||||
@@ -223,6 +386,31 @@ mccabe = ">=0.7.0,<0.8.0"
|
||||
pycodestyle = ">=2.14.0,<2.15.0"
|
||||
pyflakes = ">=3.4.0,<3.5.0"
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.1.2"
|
||||
description = "A simple framework for building complex web applications."
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"flask\" or extra == \"all-frameworks\""
|
||||
files = [
|
||||
{file = "flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"},
|
||||
{file = "flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
blinker = ">=1.9.0"
|
||||
click = ">=8.1.3"
|
||||
itsdangerous = ">=2.2.0"
|
||||
jinja2 = ">=3.1.2"
|
||||
markupsafe = ">=2.1.1"
|
||||
werkzeug = ">=3.1.0"
|
||||
|
||||
[package.extras]
|
||||
async = ["asgiref (>=3.2)"]
|
||||
dotenv = ["python-dotenv"]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -382,6 +570,162 @@ files = [
|
||||
colors = ["colorama"]
|
||||
plugins = ["setuptools"]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
description = "Safely pass data to untrusted environments and back."
|
||||
optional = true
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"flask\" or extra == \"all-frameworks\""
|
||||
files = [
|
||||
{file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"},
|
||||
{file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
description = "A very fast and expressive template engine."
|
||||
optional = true
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"flask\" or extra == \"all-frameworks\""
|
||||
files = [
|
||||
{file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
|
||||
{file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
MarkupSafe = ">=2.0"
|
||||
|
||||
[package.extras]
|
||||
i18n = ["Babel (>=2.7)"]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.0.0"
|
||||
description = "Python port of markdown-it. Markdown parsing, done right!"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"},
|
||||
{file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
mdurl = ">=0.1,<1.0"
|
||||
|
||||
[package.extras]
|
||||
benchmarking = ["psutil", "pytest", "pytest-benchmark"]
|
||||
compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"]
|
||||
linkify = ["linkify-it-py (>=1,<3)"]
|
||||
plugins = ["mdit-py-plugins (>=0.5.0)"]
|
||||
profiling = ["gprof2dot"]
|
||||
rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"]
|
||||
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
description = "Safely add untrusted strings to HTML/XML markup."
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"flask\" or extra == \"all-frameworks\""
|
||||
files = [
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"},
|
||||
{file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"},
|
||||
{file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"},
|
||||
{file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"},
|
||||
{file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"},
|
||||
{file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"},
|
||||
{file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"},
|
||||
{file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mccabe"
|
||||
version = "0.7.0"
|
||||
@@ -394,6 +738,18 @@ files = [
|
||||
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
description = "Markdown URL utilities"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
|
||||
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.17.1"
|
||||
@@ -523,6 +879,39 @@ files = [
|
||||
dev = ["pre-commit", "tox"]
|
||||
testing = ["coverage", "pytest", "pytest-benchmark"]
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
version = "7.1.3"
|
||||
description = "Cross-platform lib for process and system monitoring."
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc"},
|
||||
{file = "psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0"},
|
||||
{file = "psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7"},
|
||||
{file = "psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251"},
|
||||
{file = "psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa"},
|
||||
{file = "psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee"},
|
||||
{file = "psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353"},
|
||||
{file = "psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b"},
|
||||
{file = "psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9"},
|
||||
{file = "psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f"},
|
||||
{file = "psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7"},
|
||||
{file = "psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264"},
|
||||
{file = "psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab"},
|
||||
{file = "psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880"},
|
||||
{file = "psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3"},
|
||||
{file = "psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b"},
|
||||
{file = "psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd"},
|
||||
{file = "psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1"},
|
||||
{file = "psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pyreadline ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-subtests", "pytest-xdist", "pywin32 ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "wmi ; os_name == \"nt\" and platform_python_implementation != \"PyPy\""]
|
||||
test = ["pytest", "pytest-instafail", "pytest-subtests", "pytest-xdist", "pywin32 ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "setuptools", "wheel ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "wmi ; os_name == \"nt\" and platform_python_implementation != \"PyPy\""]
|
||||
|
||||
[[package]]
|
||||
name = "pycodestyle"
|
||||
version = "2.14.0"
|
||||
@@ -535,6 +924,164 @@ files = [
|
||||
{file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
description = "Data validation using Python type hints"
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"fastapi\" or extra == \"all-frameworks\""
|
||||
files = [
|
||||
{file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"},
|
||||
{file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
annotated-types = ">=0.6.0"
|
||||
pydantic-core = "2.41.5"
|
||||
typing-extensions = ">=4.14.1"
|
||||
typing-inspection = ">=0.4.2"
|
||||
|
||||
[package.extras]
|
||||
email = ["email-validator (>=2.0.0)"]
|
||||
timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.41.5"
|
||||
description = "Core functionality for Pydantic validation and serialization"
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"fastapi\" or extra == \"all-frameworks\""
|
||||
files = [
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"},
|
||||
{file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = ">=4.14.1"
|
||||
|
||||
[[package]]
|
||||
name = "pyflakes"
|
||||
version = "3.4.0"
|
||||
@@ -702,6 +1249,46 @@ files = [
|
||||
{file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.2.0"
|
||||
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
|
||||
optional = false
|
||||
python-versions = ">=3.8.0"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"},
|
||||
{file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
markdown-it-py = ">=2.2.0"
|
||||
pygments = ">=2.13.0,<3.0.0"
|
||||
|
||||
[package.extras]
|
||||
jupyter = ["ipywidgets (>=7.5.1,<9)"]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "80.9.0"
|
||||
description = "Easily download, build, install, upgrade, and uninstall Python packages"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"},
|
||||
{file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""]
|
||||
core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"]
|
||||
cover = ["pytest-cov"]
|
||||
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
|
||||
enabler = ["pytest-enabler (>=2.2)"]
|
||||
test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
|
||||
type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
@@ -714,6 +1301,23 @@ files = [
|
||||
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.5.4"
|
||||
description = "A non-validating SQL parser."
|
||||
optional = true
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"django\" or extra == \"all-frameworks\""
|
||||
files = [
|
||||
{file = "sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb"},
|
||||
{file = "sqlparse-0.5.4.tar.gz", hash = "sha256:4396a7d3cf1cd679c1be976cf3dc6e0a51d0111e87787e7a8d780e7d5a998f9e"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["build"]
|
||||
doc = ["sphinx"]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.47.3"
|
||||
@@ -745,6 +1349,18 @@ files = [
|
||||
{file = "structlog-25.4.0.tar.gz", hash = "sha256:186cd1b0a8ae762e29417095664adf1d6a31702160a46dacb7796ea82f7409e4"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-psutil"
|
||||
version = "7.1.3.20251202"
|
||||
description = "Typing stubs for psutil"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "types_psutil-7.1.3.20251202-py3-none-any.whl", hash = "sha256:39bfc44780de7ab686c65169e36a7969db09e7f39d92de643b55789292953400"},
|
||||
{file = "types_psutil-7.1.3.20251202.tar.gz", hash = "sha256:5cfecaced7c486fb3995bb290eab45043d697a261718aca01b9b340d1ab7968a"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-pyyaml"
|
||||
version = "6.0.12.20250822"
|
||||
@@ -769,6 +1385,35 @@ files = [
|
||||
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
description = "Runtime typing introspection tools"
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"fastapi\" or extra == \"all-frameworks\""
|
||||
files = [
|
||||
{file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
|
||||
{file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = ">=4.12.0"
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2025.2"
|
||||
description = "Provider of IANA time zone data"
|
||||
optional = true
|
||||
python-versions = ">=2"
|
||||
groups = ["main"]
|
||||
markers = "(extra == \"django\" or extra == \"all-frameworks\") and sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"},
|
||||
{file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.35.0"
|
||||
@@ -1046,10 +1691,34 @@ files = [
|
||||
{file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.4"
|
||||
description = "The comprehensive WSGI web application library."
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "extra == \"flask\" or extra == \"all-frameworks\""
|
||||
files = [
|
||||
{file = "werkzeug-3.1.4-py3-none-any.whl", hash = "sha256:2ad50fb9ed09cc3af22c54698351027ace879a0b60a3b5edf5730b2f7d876905"},
|
||||
{file = "werkzeug-3.1.4.tar.gz", hash = "sha256:cd3cd98b1b92dc3b7b3995038826c68097dcb16f9baa63abe35f20eafeb9fe5e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
markupsafe = ">=2.1.1"
|
||||
|
||||
[package.extras]
|
||||
watchdog = ["watchdog (>=2.3)"]
|
||||
|
||||
[extras]
|
||||
all-frameworks = ["a2wsgi", "django", "fastapi", "flask"]
|
||||
dev = ["black", "flake8", "isort", "mypy", "pytest", "pytest-asyncio", "pytest-cov"]
|
||||
django = ["django"]
|
||||
fastapi = ["fastapi"]
|
||||
flask = ["a2wsgi", "flask"]
|
||||
wsgi = ["a2wsgi"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.12"
|
||||
content-hash = "e68108657ddfdc07ac0c4f5dbd9c5d2950e78b8b0053e4487ebf2327bbf4e020"
|
||||
content-hash = "653d7b992e2bb133abde2e8b1c44265e948ed90487ab3f2670429510a8aa0683"
|
||||
|
||||
+29
-3
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "pyserve"
|
||||
version = "0.7.1"
|
||||
description = "Simple HTTP Web server written in Python"
|
||||
version = "0.9.10"
|
||||
description = "Python Application Orchestrator & HTTP Server - unified gateway for multiple Python web apps"
|
||||
authors = [
|
||||
{name = "Илья Глазунов",email = "i.glazunov@sapiens.solutions"}
|
||||
]
|
||||
@@ -15,10 +15,14 @@ dependencies = [
|
||||
"types-pyyaml (>=6.0.12.20250822,<7.0.0.0)",
|
||||
"structlog (>=25.4.0,<26.0.0)",
|
||||
"httpx (>=0.27.0,<0.28.0)",
|
||||
"click (>=8.0)",
|
||||
"rich (>=13.0)",
|
||||
"psutil (>=5.9)",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
pyserve = "pyserve.cli:main"
|
||||
pyservectl = "pyserve.ctl:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
@@ -30,10 +34,29 @@ dev = [
|
||||
"mypy",
|
||||
"flake8"
|
||||
]
|
||||
wsgi = [
|
||||
"a2wsgi>=1.10.0",
|
||||
]
|
||||
flask = [
|
||||
"flask>=3.0.0",
|
||||
"a2wsgi>=1.10.0",
|
||||
]
|
||||
fastapi = [
|
||||
"fastapi>=0.115.0",
|
||||
]
|
||||
django = [
|
||||
"django>=5.0",
|
||||
]
|
||||
all-frameworks = [
|
||||
"fastapi>=0.115.0",
|
||||
"flask>=3.0.0",
|
||||
"django>=5.0",
|
||||
"a2wsgi>=1.10.0",
|
||||
]
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0", "setuptools", "cython>=3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.black]
|
||||
@@ -76,4 +99,7 @@ isort = "^6.0.1"
|
||||
mypy = "^1.17.1"
|
||||
flake8 = "^7.3.0"
|
||||
pytest-asyncio = "^1.3.0"
|
||||
cython = "^3.0.0"
|
||||
setuptools = "^80.0.0"
|
||||
types-psutil = "^7.1.3.20251202"
|
||||
|
||||
|
||||
+41
-3
@@ -2,10 +2,48 @@
|
||||
PyServe - HTTP web server written on Python
|
||||
"""
|
||||
|
||||
__version__ = "0.7.0"
|
||||
__version__ = "0.10.0"
|
||||
__author__ = "Ilya Glazunov"
|
||||
|
||||
from .server import PyServeServer
|
||||
from .asgi_mount import (
|
||||
ASGIAppLoader,
|
||||
ASGIMountManager,
|
||||
MountedApp,
|
||||
create_django_app,
|
||||
create_fastapi_app,
|
||||
create_flask_app,
|
||||
create_starlette_app,
|
||||
)
|
||||
from .config import Config
|
||||
from .process_manager import (
|
||||
ProcessConfig,
|
||||
ProcessInfo,
|
||||
ProcessManager,
|
||||
ProcessState,
|
||||
get_process_manager,
|
||||
init_process_manager,
|
||||
shutdown_process_manager,
|
||||
)
|
||||
from .server import PyServeServer
|
||||
|
||||
__all__ = ["PyServeServer", "Config", "__version__"]
|
||||
__all__ = [
|
||||
"PyServeServer",
|
||||
"Config",
|
||||
"__version__",
|
||||
# ASGI mounting (in-process)
|
||||
"ASGIAppLoader",
|
||||
"ASGIMountManager",
|
||||
"MountedApp",
|
||||
"create_fastapi_app",
|
||||
"create_flask_app",
|
||||
"create_django_app",
|
||||
"create_starlette_app",
|
||||
# Process orchestration (multi-process)
|
||||
"ProcessManager",
|
||||
"ProcessConfig",
|
||||
"ProcessInfo",
|
||||
"ProcessState",
|
||||
"get_process_manager",
|
||||
"init_process_manager",
|
||||
"shutdown_process_manager",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
# cython: language_level=3
|
||||
# cython: boundscheck=False
|
||||
# cython: wraparound=False
|
||||
# cython: cdivision=True
|
||||
"""
|
||||
Fast path matching module for PyServe.
|
||||
|
||||
This Cython module provides optimized path matching operations
|
||||
for ASGI mount routing, significantly reducing overhead on hot paths.
|
||||
"""
|
||||
|
||||
from cpython.object cimport PyObject
|
||||
|
||||
|
||||
cdef class FastMountedPath:
|
||||
cdef:
|
||||
str _path
|
||||
str _path_with_slash
|
||||
Py_ssize_t _path_len
|
||||
bint _is_root
|
||||
public str name
|
||||
public bint strip_path
|
||||
|
||||
def __cinit__(self):
|
||||
self._path = ""
|
||||
self._path_with_slash = "/"
|
||||
self._path_len = 0
|
||||
self._is_root = 1
|
||||
self.name = ""
|
||||
self.strip_path = 1
|
||||
|
||||
def __init__(self, str path, str name="", bint strip_path=True):
|
||||
cdef Py_ssize_t path_len
|
||||
|
||||
path_len = len(path)
|
||||
if path_len > 1 and path[path_len - 1] == '/':
|
||||
path = path[:path_len - 1]
|
||||
|
||||
self._path = path
|
||||
self._path_len = len(path)
|
||||
self._is_root = 1 if (path == "" or path == "/") else 0
|
||||
self._path_with_slash = path + "/" if self._is_root == 0 else "/"
|
||||
self.name = name if name else path
|
||||
self.strip_path = 1 if strip_path else 0
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return self._path
|
||||
|
||||
cpdef bint matches(self, str request_path):
|
||||
cdef Py_ssize_t req_len
|
||||
|
||||
if self._is_root:
|
||||
return 1
|
||||
|
||||
req_len = len(request_path)
|
||||
|
||||
if req_len < self._path_len:
|
||||
return 0
|
||||
|
||||
if req_len == self._path_len:
|
||||
return 1 if request_path == self._path else 0
|
||||
|
||||
if request_path[self._path_len] == '/':
|
||||
return 1 if request_path[:self._path_len] == self._path else 0
|
||||
|
||||
return 0
|
||||
|
||||
cpdef str get_modified_path(self, str original_path):
|
||||
cdef str new_path
|
||||
|
||||
if not self.strip_path:
|
||||
return original_path
|
||||
|
||||
if self._is_root:
|
||||
return original_path
|
||||
|
||||
new_path = original_path[self._path_len:]
|
||||
|
||||
if not new_path:
|
||||
return "/"
|
||||
|
||||
return new_path
|
||||
|
||||
def __repr__(self):
|
||||
return f"FastMountedPath(path={self._path!r}, name={self.name!r})"
|
||||
|
||||
|
||||
def _get_path_len_neg(mount):
|
||||
return -len(mount.path)
|
||||
|
||||
|
||||
cdef class FastMountManager:
|
||||
cdef:
|
||||
list _mounts
|
||||
int _mount_count
|
||||
|
||||
def __cinit__(self):
|
||||
self._mounts = []
|
||||
self._mount_count = 0
|
||||
|
||||
def __init__(self):
|
||||
self._mounts = []
|
||||
self._mount_count = 0
|
||||
|
||||
cpdef void add_mount(self, FastMountedPath mount):
|
||||
self._mounts.append(mount)
|
||||
self._mounts = sorted(self._mounts, key=_get_path_len_neg, reverse=False)
|
||||
self._mount_count = len(self._mounts)
|
||||
|
||||
cpdef FastMountedPath get_mount(self, str request_path):
|
||||
cdef:
|
||||
int i
|
||||
FastMountedPath mount
|
||||
|
||||
for i in range(self._mount_count):
|
||||
mount = <FastMountedPath>self._mounts[i]
|
||||
if mount.matches(request_path):
|
||||
return mount
|
||||
|
||||
return None
|
||||
|
||||
cpdef bint remove_mount(self, str path):
|
||||
cdef:
|
||||
int i
|
||||
Py_ssize_t path_len
|
||||
FastMountedPath mount
|
||||
|
||||
path_len = len(path)
|
||||
if path_len > 1 and path[path_len - 1] == '/':
|
||||
path = path[:path_len - 1]
|
||||
|
||||
for i in range(self._mount_count):
|
||||
mount = <FastMountedPath>self._mounts[i]
|
||||
if mount._path == path:
|
||||
del self._mounts[i]
|
||||
self._mount_count -= 1
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
@property
|
||||
def mounts(self) -> list:
|
||||
return list(self._mounts)
|
||||
|
||||
@property
|
||||
def mount_count(self) -> int:
|
||||
return self._mount_count
|
||||
|
||||
cpdef list list_mounts(self):
|
||||
cdef:
|
||||
list result = []
|
||||
FastMountedPath mount
|
||||
|
||||
for mount in self._mounts:
|
||||
result.append({
|
||||
"path": mount._path,
|
||||
"name": mount.name,
|
||||
"strip_path": mount.strip_path,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
cpdef bint path_matches_prefix(str request_path, str mount_path):
|
||||
cdef:
|
||||
Py_ssize_t mount_len = len(mount_path)
|
||||
Py_ssize_t req_len = len(request_path)
|
||||
|
||||
if mount_len == 0 or mount_path == "/":
|
||||
return 1
|
||||
|
||||
if req_len < mount_len:
|
||||
return 0
|
||||
|
||||
if req_len == mount_len:
|
||||
return 1 if request_path == mount_path else 0
|
||||
|
||||
if request_path[mount_len] == '/':
|
||||
return 1 if request_path[:mount_len] == mount_path else 0
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
cpdef str strip_path_prefix(str original_path, str mount_path):
|
||||
cdef:
|
||||
Py_ssize_t mount_len = len(mount_path)
|
||||
str result
|
||||
|
||||
if mount_len == 0 or mount_path == "/":
|
||||
return original_path
|
||||
|
||||
result = original_path[mount_len:]
|
||||
|
||||
if not result:
|
||||
return "/"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
cpdef tuple match_and_modify_path(str request_path, str mount_path, bint strip_path=True):
|
||||
cdef:
|
||||
Py_ssize_t mount_len = len(mount_path)
|
||||
Py_ssize_t req_len = len(request_path)
|
||||
bint is_root = 1 if (mount_len == 0 or mount_path == "/") else 0
|
||||
str modified
|
||||
|
||||
if is_root:
|
||||
return (True, request_path if strip_path else request_path)
|
||||
|
||||
if req_len < mount_len:
|
||||
return (False, None)
|
||||
|
||||
if req_len == mount_len:
|
||||
if request_path == mount_path:
|
||||
return (True, "/" if strip_path else request_path)
|
||||
return (False, None)
|
||||
|
||||
if request_path[mount_len] == '/' and request_path[:mount_len] == mount_path:
|
||||
if strip_path:
|
||||
modified = request_path[mount_len:]
|
||||
return (True, modified if modified else "/")
|
||||
return (True, request_path)
|
||||
|
||||
return (False, None)
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Pure Python fallback for _path_matcher when Cython is not available.
|
||||
|
||||
This module provides the same interface as the Cython _path_matcher module,
|
||||
allowing the application to run without compilation.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
class FastMountedPath:
|
||||
__slots__ = ("_path", "_path_with_slash", "_path_len", "_is_root", "name", "strip_path")
|
||||
|
||||
def __init__(self, path: str, name: str = "", strip_path: bool = True):
|
||||
if path.endswith("/") and len(path) > 1:
|
||||
path = path[:-1]
|
||||
|
||||
self._path = path
|
||||
self._path_len = len(path)
|
||||
self._is_root = path == "" or path == "/"
|
||||
self._path_with_slash = path + "/" if not self._is_root else "/"
|
||||
self.name = name or path
|
||||
self.strip_path = strip_path
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return self._path
|
||||
|
||||
def matches(self, request_path: str) -> bool:
|
||||
if self._is_root:
|
||||
return True
|
||||
|
||||
req_len = len(request_path)
|
||||
|
||||
if req_len < self._path_len:
|
||||
return False
|
||||
|
||||
if req_len == self._path_len:
|
||||
return request_path == self._path
|
||||
|
||||
if request_path[self._path_len] == "/":
|
||||
return request_path[: self._path_len] == self._path
|
||||
|
||||
return False
|
||||
|
||||
def get_modified_path(self, original_path: str) -> str:
|
||||
if not self.strip_path:
|
||||
return original_path
|
||||
|
||||
if self._is_root:
|
||||
return original_path
|
||||
|
||||
new_path = original_path[self._path_len :]
|
||||
|
||||
if not new_path:
|
||||
return "/"
|
||||
|
||||
return new_path
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"FastMountedPath(path={self._path!r}, name={self.name!r})"
|
||||
|
||||
|
||||
class FastMountManager:
|
||||
__slots__ = ("_mounts", "_mount_count")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._mounts: List[FastMountedPath] = []
|
||||
self._mount_count: int = 0
|
||||
|
||||
def add_mount(self, mount: FastMountedPath) -> None:
|
||||
self._mounts.append(mount)
|
||||
self._mounts.sort(key=lambda m: len(m.path), reverse=True)
|
||||
self._mount_count = len(self._mounts)
|
||||
|
||||
def get_mount(self, request_path: str) -> Optional[FastMountedPath]:
|
||||
for mount in self._mounts:
|
||||
if mount.matches(request_path):
|
||||
return mount
|
||||
return None
|
||||
|
||||
def remove_mount(self, path: str) -> bool:
|
||||
if path.endswith("/") and len(path) > 1:
|
||||
path = path[:-1]
|
||||
|
||||
for i, mount in enumerate(self._mounts):
|
||||
if mount._path == path:
|
||||
del self._mounts[i]
|
||||
self._mount_count -= 1
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def mounts(self) -> List[FastMountedPath]:
|
||||
return self._mounts.copy()
|
||||
|
||||
@property
|
||||
def mount_count(self) -> int:
|
||||
return self._mount_count
|
||||
|
||||
def list_mounts(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"path": mount._path,
|
||||
"name": mount.name,
|
||||
"strip_path": mount.strip_path,
|
||||
}
|
||||
for mount in self._mounts
|
||||
]
|
||||
|
||||
|
||||
def path_matches_prefix(request_path: str, mount_path: str) -> bool:
|
||||
mount_len = len(mount_path)
|
||||
req_len = len(request_path)
|
||||
|
||||
if mount_len == 0 or mount_path == "/":
|
||||
return True
|
||||
|
||||
if req_len < mount_len:
|
||||
return False
|
||||
|
||||
if req_len == mount_len:
|
||||
return request_path == mount_path
|
||||
|
||||
if request_path[mount_len] == "/":
|
||||
return request_path[:mount_len] == mount_path
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def strip_path_prefix(original_path: str, mount_path: str) -> str:
|
||||
mount_len = len(mount_path)
|
||||
|
||||
if mount_len == 0 or mount_path == "/":
|
||||
return original_path
|
||||
|
||||
result = original_path[mount_len:]
|
||||
|
||||
if not result:
|
||||
return "/"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def match_and_modify_path(request_path: str, mount_path: str, strip_path: bool = True) -> Tuple[bool, Optional[str]]:
|
||||
mount_len = len(mount_path)
|
||||
req_len = len(request_path)
|
||||
is_root = mount_len == 0 or mount_path == "/"
|
||||
|
||||
if is_root:
|
||||
return (True, request_path)
|
||||
|
||||
if req_len < mount_len:
|
||||
return (False, None)
|
||||
|
||||
if req_len == mount_len:
|
||||
if request_path == mount_path:
|
||||
return (True, "/" if strip_path else request_path)
|
||||
return (False, None)
|
||||
|
||||
if request_path[mount_len] == "/" and request_path[:mount_len] == mount_path:
|
||||
if strip_path:
|
||||
modified = request_path[mount_len:]
|
||||
return (True, modified if modified else "/")
|
||||
return (True, request_path)
|
||||
|
||||
return (False, None)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
WSGI Wrapper Module for Process Orchestration.
|
||||
|
||||
This module provides a wrapper that allows WSGI applications to be run
|
||||
via uvicorn by wrapping them with a2wsgi.
|
||||
|
||||
The WSGI app path is passed via environment variables:
|
||||
- PYSERVE_WSGI_APP: The app path (e.g., "myapp:app" or "myapp.main:create_app")
|
||||
- PYSERVE_WSGI_FACTORY: "1" if the app path points to a factory function
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
from typing import Any, Callable, Optional, Type
|
||||
|
||||
WSGIMiddlewareType = Optional[Type[Any]]
|
||||
WSGI_ADAPTER: Optional[str] = None
|
||||
WSGIMiddleware: WSGIMiddlewareType = None
|
||||
|
||||
try:
|
||||
from a2wsgi import WSGIMiddleware as _A2WSGIMiddleware
|
||||
|
||||
WSGIMiddleware = _A2WSGIMiddleware
|
||||
WSGI_ADAPTER = "a2wsgi"
|
||||
except ImportError:
|
||||
try:
|
||||
from asgiref.wsgi import WsgiToAsgi as _AsgirefMiddleware
|
||||
|
||||
WSGIMiddleware = _AsgirefMiddleware
|
||||
WSGI_ADAPTER = "asgiref"
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
def _load_wsgi_app() -> Callable[..., Any]:
|
||||
app_path = os.environ.get("PYSERVE_WSGI_APP")
|
||||
is_factory = os.environ.get("PYSERVE_WSGI_FACTORY", "0") == "1"
|
||||
|
||||
if not app_path:
|
||||
raise RuntimeError("PYSERVE_WSGI_APP environment variable not set. " "This module should only be used by PyServe process orchestration.")
|
||||
|
||||
if ":" in app_path:
|
||||
module_name, attr_name = app_path.rsplit(":", 1)
|
||||
else:
|
||||
module_name = app_path
|
||||
attr_name = "app"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except ImportError as e:
|
||||
raise RuntimeError(f"Failed to import WSGI module '{module_name}': {e}")
|
||||
|
||||
try:
|
||||
app_or_factory = getattr(module, attr_name)
|
||||
except AttributeError:
|
||||
raise RuntimeError(f"Module '{module_name}' has no attribute '{attr_name}'")
|
||||
|
||||
if is_factory:
|
||||
result: Callable[..., Any] = app_or_factory()
|
||||
return result
|
||||
loaded_app: Callable[..., Any] = app_or_factory
|
||||
return loaded_app
|
||||
|
||||
|
||||
def _create_asgi_app() -> Any:
|
||||
if WSGIMiddleware is None:
|
||||
raise RuntimeError("No WSGI adapter available. " "Install a2wsgi (recommended) or asgiref: pip install a2wsgi")
|
||||
|
||||
wsgi_app = _load_wsgi_app()
|
||||
return WSGIMiddleware(wsgi_app)
|
||||
|
||||
|
||||
app = _create_asgi_app()
|
||||
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
ASGI Application Mount Module
|
||||
|
||||
This module provides functionality to mount external ASGI/WSGI applications
|
||||
(FastAPI, Flask, Django, etc.) at specified paths within PyServe.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Optional, cast
|
||||
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from .logging_utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ASGIAppLoader:
|
||||
def __init__(self) -> None:
|
||||
self._apps: Dict[str, ASGIApp] = {}
|
||||
self._wsgi_adapters: Dict[str, ASGIApp] = {}
|
||||
|
||||
def load_app(
|
||||
self,
|
||||
app_path: str,
|
||||
app_type: str = "asgi",
|
||||
module_path: Optional[str] = None,
|
||||
factory: bool = False,
|
||||
factory_args: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[ASGIApp]:
|
||||
try:
|
||||
if module_path:
|
||||
module_dir = Path(module_path).resolve()
|
||||
if str(module_dir) not in sys.path:
|
||||
sys.path.insert(0, str(module_dir))
|
||||
logger.debug(f"Added {module_dir} to sys.path")
|
||||
|
||||
if ":" in app_path:
|
||||
module_name, attr_name = app_path.rsplit(":", 1)
|
||||
else:
|
||||
module_name = app_path
|
||||
attr_name = "app"
|
||||
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
app_or_factory = getattr(module, attr_name)
|
||||
|
||||
if factory:
|
||||
factory_args = factory_args or {}
|
||||
app = app_or_factory(**factory_args)
|
||||
logger.info(f"Created app from factory: {app_path}")
|
||||
else:
|
||||
app = app_or_factory
|
||||
logger.info(f"Loaded app: {app_path}")
|
||||
|
||||
if app_type == "wsgi":
|
||||
app = self._wrap_wsgi(app)
|
||||
logger.info(f"Wrapped WSGI app: {app_path}")
|
||||
|
||||
self._apps[app_path] = app
|
||||
return cast(ASGIApp, app)
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import application {app_path}: {e}")
|
||||
return None
|
||||
except AttributeError as e:
|
||||
logger.error(f"Failed to get attribute from {app_path}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load application {app_path}: {e}")
|
||||
return None
|
||||
|
||||
def _wrap_wsgi(self, wsgi_app: Callable) -> ASGIApp:
|
||||
try:
|
||||
from a2wsgi import WSGIMiddleware
|
||||
|
||||
return cast(ASGIApp, WSGIMiddleware(wsgi_app))
|
||||
except ImportError:
|
||||
logger.warning("a2wsgi not installed, trying asgiref")
|
||||
try:
|
||||
from asgiref.wsgi import WsgiToAsgi
|
||||
|
||||
return cast(ASGIApp, WsgiToAsgi(wsgi_app))
|
||||
except ImportError:
|
||||
logger.error("Neither a2wsgi nor asgiref installed. " "Install with: pip install a2wsgi or pip install asgiref")
|
||||
raise ImportError("WSGI adapter not available. Install a2wsgi or asgiref.")
|
||||
|
||||
def get_app(self, app_path: str) -> Optional[ASGIApp]:
|
||||
return self._apps.get(app_path)
|
||||
|
||||
def reload_app(self, app_path: str, **kwargs: Any) -> Optional[ASGIApp]:
|
||||
if app_path in self._apps:
|
||||
del self._apps[app_path]
|
||||
|
||||
if ":" in app_path:
|
||||
module_name, _ = app_path.rsplit(":", 1)
|
||||
else:
|
||||
module_name = app_path
|
||||
|
||||
if module_name in sys.modules:
|
||||
importlib.reload(sys.modules[module_name])
|
||||
|
||||
return self.load_app(app_path, **kwargs)
|
||||
|
||||
|
||||
class MountedApp:
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
app: ASGIApp,
|
||||
name: str = "",
|
||||
strip_path: bool = True,
|
||||
):
|
||||
self.path = path.rstrip("/")
|
||||
self.app = app
|
||||
self.name = name or path
|
||||
self.strip_path = strip_path
|
||||
|
||||
def matches(self, request_path: str) -> bool:
|
||||
if self.path == "":
|
||||
return True
|
||||
return request_path == self.path or request_path.startswith(f"{self.path}/")
|
||||
|
||||
def get_modified_path(self, original_path: str) -> str:
|
||||
if not self.strip_path:
|
||||
return original_path
|
||||
|
||||
if self.path == "":
|
||||
return original_path
|
||||
|
||||
new_path = original_path[len(self.path) :]
|
||||
return new_path if new_path else "/"
|
||||
|
||||
|
||||
class ASGIMountManager:
|
||||
def __init__(self) -> None:
|
||||
self._mounts: list[MountedApp] = []
|
||||
self._loader = ASGIAppLoader()
|
||||
|
||||
def mount(
|
||||
self,
|
||||
path: str,
|
||||
app: Optional[ASGIApp] = None,
|
||||
app_path: Optional[str] = None,
|
||||
app_type: str = "asgi",
|
||||
module_path: Optional[str] = None,
|
||||
factory: bool = False,
|
||||
factory_args: Optional[Dict[str, Any]] = None,
|
||||
name: str = "",
|
||||
strip_path: bool = True,
|
||||
) -> bool:
|
||||
if app is None and app_path is None:
|
||||
logger.error("Either 'app' or 'app_path' must be provided")
|
||||
return False
|
||||
|
||||
if app is None:
|
||||
app = self._loader.load_app(
|
||||
app_path=app_path, # type: ignore
|
||||
app_type=app_type,
|
||||
module_path=module_path,
|
||||
factory=factory,
|
||||
factory_args=factory_args,
|
||||
)
|
||||
if app is None:
|
||||
return False
|
||||
|
||||
mounted = MountedApp(
|
||||
path=path,
|
||||
app=app,
|
||||
name=name or app_path or "unnamed",
|
||||
strip_path=strip_path,
|
||||
)
|
||||
|
||||
self._mounts.append(mounted)
|
||||
self._mounts.sort(key=lambda m: len(m.path), reverse=True)
|
||||
|
||||
logger.info(f"Mounted application '{mounted.name}' at path '{path}'")
|
||||
return True
|
||||
|
||||
def unmount(self, path: str) -> bool:
|
||||
for i, mount in enumerate(self._mounts):
|
||||
if mount.path == path.rstrip("/"):
|
||||
del self._mounts[i]
|
||||
logger.info(f"Unmounted application at path '{path}'")
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_mount(self, request_path: str) -> Optional[MountedApp]:
|
||||
for mount in self._mounts:
|
||||
if mount.matches(request_path):
|
||||
return mount
|
||||
return None
|
||||
|
||||
async def handle_request(
|
||||
self,
|
||||
scope: Scope,
|
||||
receive: Receive,
|
||||
send: Send,
|
||||
) -> bool:
|
||||
if scope["type"] != "http":
|
||||
return False
|
||||
|
||||
path = scope.get("path", "/")
|
||||
mount = self.get_mount(path)
|
||||
|
||||
if mount is None:
|
||||
return False
|
||||
|
||||
modified_scope = dict(scope)
|
||||
if mount.strip_path:
|
||||
modified_scope["path"] = mount.get_modified_path(path)
|
||||
modified_scope["root_path"] = scope.get("root_path", "") + mount.path
|
||||
|
||||
logger.debug(f"Routing request to mounted app '{mount.name}': " f"{path} -> {modified_scope['path']}")
|
||||
|
||||
try:
|
||||
await mount.app(modified_scope, receive, send)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error in mounted app '{mount.name}': {e}")
|
||||
raise
|
||||
|
||||
@property
|
||||
def mounts(self) -> list[MountedApp]:
|
||||
return self._mounts.copy()
|
||||
|
||||
def list_mounts(self) -> list[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"path": mount.path,
|
||||
"name": mount.name,
|
||||
"strip_path": mount.strip_path,
|
||||
}
|
||||
for mount in self._mounts
|
||||
]
|
||||
|
||||
|
||||
def create_fastapi_app(
|
||||
app_path: str,
|
||||
module_path: Optional[str] = None,
|
||||
factory: bool = False,
|
||||
factory_args: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[ASGIApp]:
|
||||
loader = ASGIAppLoader()
|
||||
return loader.load_app(
|
||||
app_path=app_path,
|
||||
app_type="asgi",
|
||||
module_path=module_path,
|
||||
factory=factory,
|
||||
factory_args=factory_args,
|
||||
)
|
||||
|
||||
|
||||
def create_flask_app(
|
||||
app_path: str,
|
||||
module_path: Optional[str] = None,
|
||||
factory: bool = False,
|
||||
factory_args: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[ASGIApp]:
|
||||
loader = ASGIAppLoader()
|
||||
return loader.load_app(
|
||||
app_path=app_path,
|
||||
app_type="wsgi",
|
||||
module_path=module_path,
|
||||
factory=factory,
|
||||
factory_args=factory_args,
|
||||
)
|
||||
|
||||
|
||||
def create_django_app(
|
||||
settings_module: str,
|
||||
module_path: Optional[str] = None,
|
||||
) -> Optional[ASGIApp]:
|
||||
import os
|
||||
|
||||
if module_path:
|
||||
module_dir = Path(module_path).resolve()
|
||||
if str(module_dir) not in sys.path:
|
||||
sys.path.insert(0, str(module_dir))
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings_module)
|
||||
|
||||
try:
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
return cast(ASGIApp, get_asgi_application())
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to load Django application: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def create_starlette_app(
|
||||
app_path: str,
|
||||
module_path: Optional[str] = None,
|
||||
factory: bool = False,
|
||||
factory_args: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[ASGIApp]:
|
||||
loader = ASGIAppLoader()
|
||||
return loader.load_app(
|
||||
app_path=app_path,
|
||||
app_type="asgi",
|
||||
module_path=module_path,
|
||||
factory=factory,
|
||||
factory_args=factory_args,
|
||||
)
|
||||
+17
-8
@@ -1,38 +1,47 @@
|
||||
import sys
|
||||
"""
|
||||
PyServe CLI - Server entry point
|
||||
|
||||
Simple CLI for running the PyServe HTTP server.
|
||||
For service management, use pyservectl.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import PyServeServer, Config, __version__
|
||||
from . import Config, PyServeServer, __version__
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="PyServe - HTTP web server",
|
||||
prog="pyserve",
|
||||
epilog="For service management (start/stop/restart/logs), use: pyservectl",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c", "--config",
|
||||
"-c",
|
||||
"--config",
|
||||
default="config.yaml",
|
||||
help="Path to configuration file (default: config.yaml)"
|
||||
help="Path to configuration file (default: config.yaml)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
help="Host to bind the server to"
|
||||
help="Host to bind the server to",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
help="Port to bind the server to"
|
||||
help="Port to bind the server to",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="Enable debug mode"
|
||||
help="Enable debug mode",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
action="version",
|
||||
version=f"%(prog)s {__version__}"
|
||||
version=f"%(prog)s {__version__}",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
+101
-115
@@ -1,8 +1,10 @@
|
||||
import yaml
|
||||
import os
|
||||
from typing import Dict, Any, List, cast
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, cast
|
||||
|
||||
import yaml
|
||||
|
||||
from .logging_utils import setup_logging
|
||||
|
||||
|
||||
@@ -84,7 +86,7 @@ class Config:
|
||||
@classmethod
|
||||
def from_yaml(cls, file_path: str) -> "Config":
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
return cls._from_dict(data)
|
||||
@@ -99,133 +101,117 @@ class Config:
|
||||
def _from_dict(cls, data: Dict[str, Any]) -> "Config":
|
||||
config = cls()
|
||||
|
||||
if 'http' in data:
|
||||
http_data = data['http']
|
||||
if "http" in data:
|
||||
http_data = data["http"]
|
||||
config.http = HttpConfig(
|
||||
static_dir=http_data.get('static_dir', config.http.static_dir),
|
||||
templates_dir=http_data.get('templates_dir', config.http.templates_dir)
|
||||
static_dir=http_data.get("static_dir", config.http.static_dir),
|
||||
templates_dir=http_data.get("templates_dir", config.http.templates_dir),
|
||||
)
|
||||
|
||||
if 'server' in data:
|
||||
server_data = data['server']
|
||||
if "server" in data:
|
||||
server_data = data["server"]
|
||||
config.server = ServerConfig(
|
||||
host=server_data.get('host', config.server.host),
|
||||
port=server_data.get('port', config.server.port),
|
||||
backlog=server_data.get('backlog', config.server.backlog),
|
||||
default_root=server_data.get('default_root', config.server.default_root),
|
||||
proxy_timeout=server_data.get('proxy_timeout', config.server.proxy_timeout),
|
||||
redirect_instructions=server_data.get('redirect_instructions', {})
|
||||
host=server_data.get("host", config.server.host),
|
||||
port=server_data.get("port", config.server.port),
|
||||
backlog=server_data.get("backlog", config.server.backlog),
|
||||
default_root=server_data.get("default_root", config.server.default_root),
|
||||
proxy_timeout=server_data.get("proxy_timeout", config.server.proxy_timeout),
|
||||
redirect_instructions=server_data.get("redirect_instructions", {}),
|
||||
)
|
||||
|
||||
if 'ssl' in data:
|
||||
ssl_data = data['ssl']
|
||||
if "ssl" in data:
|
||||
ssl_data = data["ssl"]
|
||||
config.ssl = SSLConfig(
|
||||
enabled=ssl_data.get('enabled', config.ssl.enabled),
|
||||
cert_file=ssl_data.get('cert_file', config.ssl.cert_file),
|
||||
key_file=ssl_data.get('key_file', config.ssl.key_file)
|
||||
enabled=ssl_data.get("enabled", config.ssl.enabled),
|
||||
cert_file=ssl_data.get("cert_file", config.ssl.cert_file),
|
||||
key_file=ssl_data.get("key_file", config.ssl.key_file),
|
||||
)
|
||||
|
||||
if 'logging' in data:
|
||||
log_data = data['logging']
|
||||
format_data = log_data.get('format', {})
|
||||
if "logging" in data:
|
||||
log_data = data["logging"]
|
||||
format_data = log_data.get("format", {})
|
||||
global_format = LogFormatConfig(
|
||||
type=format_data.get('type', 'standard'),
|
||||
use_colors=format_data.get('use_colors', True),
|
||||
show_module=format_data.get('show_module', True),
|
||||
timestamp_format=format_data.get('timestamp_format', '%Y-%m-%d %H:%M:%S')
|
||||
type=format_data.get("type", "standard"),
|
||||
use_colors=format_data.get("use_colors", True),
|
||||
show_module=format_data.get("show_module", True),
|
||||
timestamp_format=format_data.get("timestamp_format", "%Y-%m-%d %H:%M:%S"),
|
||||
)
|
||||
console_data = log_data.get('console', {})
|
||||
console_format_data = console_data.get('format', {})
|
||||
console_data = log_data.get("console", {})
|
||||
console_format_data = console_data.get("format", {})
|
||||
console_format = LogFormatConfig(
|
||||
type=console_format_data.get('type', global_format.type),
|
||||
use_colors=console_format_data.get('use_colors', global_format.use_colors),
|
||||
show_module=console_format_data.get('show_module', global_format.show_module),
|
||||
timestamp_format=console_format_data.get('timestamp_format', global_format.timestamp_format)
|
||||
)
|
||||
console_config = LogHandlerConfig(
|
||||
level=console_data.get('level', log_data.get('level', 'INFO')),
|
||||
format=console_format
|
||||
type=console_format_data.get("type", global_format.type),
|
||||
use_colors=console_format_data.get("use_colors", global_format.use_colors),
|
||||
show_module=console_format_data.get("show_module", global_format.show_module),
|
||||
timestamp_format=console_format_data.get("timestamp_format", global_format.timestamp_format),
|
||||
)
|
||||
console_config = LogHandlerConfig(level=console_data.get("level", log_data.get("level", "INFO")), format=console_format)
|
||||
files_config = []
|
||||
if 'log_file' in log_data:
|
||||
if "log_file" in log_data:
|
||||
default_file_format = LogFormatConfig(
|
||||
type=global_format.type,
|
||||
use_colors=False,
|
||||
show_module=global_format.show_module,
|
||||
timestamp_format=global_format.timestamp_format
|
||||
type=global_format.type, use_colors=False, show_module=global_format.show_module, timestamp_format=global_format.timestamp_format
|
||||
)
|
||||
default_file = LogFileConfig(
|
||||
path=log_data['log_file'],
|
||||
level=log_data.get('level', 'INFO'),
|
||||
path=log_data["log_file"],
|
||||
level=log_data.get("level", "INFO"),
|
||||
format=default_file_format,
|
||||
loggers=[], # Empty list means including all loggers
|
||||
max_bytes=10 * 1024 * 1024,
|
||||
backup_count=5
|
||||
backup_count=5,
|
||||
)
|
||||
files_config.append(default_file)
|
||||
|
||||
if 'files' in log_data:
|
||||
for file_data in log_data['files']:
|
||||
file_format_data = file_data.get('format', {})
|
||||
if "files" in log_data:
|
||||
for file_data in log_data["files"]:
|
||||
file_format_data = file_data.get("format", {})
|
||||
file_format = LogFormatConfig(
|
||||
type=file_format_data.get('type', global_format.type),
|
||||
use_colors=file_format_data.get('use_colors', False),
|
||||
show_module=file_format_data.get('show_module', global_format.show_module),
|
||||
timestamp_format=file_format_data.get('timestamp_format', global_format.timestamp_format)
|
||||
type=file_format_data.get("type", global_format.type),
|
||||
use_colors=file_format_data.get("use_colors", False),
|
||||
show_module=file_format_data.get("show_module", global_format.show_module),
|
||||
timestamp_format=file_format_data.get("timestamp_format", global_format.timestamp_format),
|
||||
)
|
||||
file_config = LogFileConfig(
|
||||
path=file_data.get('path', './logs/pyserve.log'),
|
||||
level=file_data.get('level', log_data.get('level', 'INFO')),
|
||||
path=file_data.get("path", "./logs/pyserve.log"),
|
||||
level=file_data.get("level", log_data.get("level", "INFO")),
|
||||
format=file_format,
|
||||
loggers=file_data.get('loggers', []),
|
||||
max_bytes=file_data.get('max_bytes', 10 * 1024 * 1024),
|
||||
backup_count=file_data.get('backup_count', 5)
|
||||
loggers=file_data.get("loggers", []),
|
||||
max_bytes=file_data.get("max_bytes", 10 * 1024 * 1024),
|
||||
backup_count=file_data.get("backup_count", 5),
|
||||
)
|
||||
files_config.append(file_config)
|
||||
|
||||
if 'show_module' in console_format_data:
|
||||
print(
|
||||
"\033[33mWARNING: Parameter 'show_module' in console.format in development and may work incorrectly\033[0m"
|
||||
)
|
||||
console_config.format.show_module = console_format_data.get('show_module')
|
||||
if "show_module" in console_format_data:
|
||||
print("\033[33mWARNING: Parameter 'show_module' in console.format in development and may work incorrectly\033[0m")
|
||||
console_config.format.show_module = console_format_data.get("show_module")
|
||||
|
||||
for i, file_data in enumerate(log_data.get('files', [])):
|
||||
if 'format' in file_data and 'show_module' in file_data['format']:
|
||||
print(
|
||||
f"\033[33mWARNING: Parameter 'show_module' in files[{i}].format in development and may work incorrectly\033[0m"
|
||||
)
|
||||
for i, file_data in enumerate(log_data.get("files", [])):
|
||||
if "format" in file_data and "show_module" in file_data["format"]:
|
||||
print(f"\033[33mWARNING: Parameter 'show_module' in files[{i}].format in development and may work incorrectly\033[0m")
|
||||
|
||||
if not files_config:
|
||||
default_file_format = LogFormatConfig(
|
||||
type=global_format.type,
|
||||
use_colors=False,
|
||||
show_module=global_format.show_module,
|
||||
timestamp_format=global_format.timestamp_format
|
||||
type=global_format.type, use_colors=False, show_module=global_format.show_module, timestamp_format=global_format.timestamp_format
|
||||
)
|
||||
default_file = LogFileConfig(
|
||||
path='./logs/pyserve.log',
|
||||
level=log_data.get('level', 'INFO'),
|
||||
path="./logs/pyserve.log",
|
||||
level=log_data.get("level", "INFO"),
|
||||
format=default_file_format,
|
||||
loggers=[],
|
||||
max_bytes=10 * 1024 * 1024,
|
||||
backup_count=5
|
||||
backup_count=5,
|
||||
)
|
||||
files_config.append(default_file)
|
||||
|
||||
config.logging = LoggingConfig(
|
||||
level=log_data.get('level', 'INFO'),
|
||||
console_output=log_data.get('console_output', True),
|
||||
level=log_data.get("level", "INFO"),
|
||||
console_output=log_data.get("console_output", True),
|
||||
format=global_format,
|
||||
console=console_config,
|
||||
files=files_config
|
||||
files=files_config,
|
||||
)
|
||||
|
||||
if 'extensions' in data:
|
||||
for ext_data in data['extensions']:
|
||||
extension = ExtensionConfig(
|
||||
type=ext_data.get('type', ''),
|
||||
config=ext_data.get('config', {})
|
||||
)
|
||||
if "extensions" in data:
|
||||
for ext_data in data["extensions"]:
|
||||
extension = ExtensionConfig(type=ext_data.get("type", ""), config=ext_data.get("config", {}))
|
||||
config.extensions.append(extension)
|
||||
|
||||
return config
|
||||
@@ -245,14 +231,14 @@ class Config:
|
||||
if not (1 <= self.server.port <= 65535):
|
||||
errors.append(f"Invalid port: {self.server.port}")
|
||||
|
||||
valid_log_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
||||
valid_log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
if self.logging.level.upper() not in valid_log_levels:
|
||||
errors.append(f"Invalid logging level: {self.logging.level}")
|
||||
|
||||
if self.logging.console.level.upper() not in valid_log_levels:
|
||||
errors.append(f"Invalid console logging level: {self.logging.console.level}")
|
||||
|
||||
valid_format_types = ['standard', 'json']
|
||||
valid_format_types = ["standard", "json"]
|
||||
|
||||
if self.logging.format.type not in valid_format_types:
|
||||
errors.append(f"Invalid logging format type: {self.logging.format.type}")
|
||||
@@ -283,40 +269,40 @@ class Config:
|
||||
|
||||
def setup_logging(self) -> None:
|
||||
config_dict = {
|
||||
'level': self.logging.level,
|
||||
'console_output': self.logging.console_output,
|
||||
'format': {
|
||||
'type': self.logging.format.type,
|
||||
'use_colors': self.logging.format.use_colors,
|
||||
'show_module': self.logging.format.show_module,
|
||||
'timestamp_format': self.logging.format.timestamp_format
|
||||
"level": self.logging.level,
|
||||
"console_output": self.logging.console_output,
|
||||
"format": {
|
||||
"type": self.logging.format.type,
|
||||
"use_colors": self.logging.format.use_colors,
|
||||
"show_module": self.logging.format.show_module,
|
||||
"timestamp_format": self.logging.format.timestamp_format,
|
||||
},
|
||||
'console': {
|
||||
'level': self.logging.console.level,
|
||||
'format': {
|
||||
'type': self.logging.console.format.type,
|
||||
'use_colors': self.logging.console.format.use_colors,
|
||||
'show_module': self.logging.console.format.show_module,
|
||||
'timestamp_format': self.logging.console.format.timestamp_format
|
||||
}
|
||||
"console": {
|
||||
"level": self.logging.console.level,
|
||||
"format": {
|
||||
"type": self.logging.console.format.type,
|
||||
"use_colors": self.logging.console.format.use_colors,
|
||||
"show_module": self.logging.console.format.show_module,
|
||||
"timestamp_format": self.logging.console.format.timestamp_format,
|
||||
},
|
||||
},
|
||||
'files': []
|
||||
"files": [],
|
||||
}
|
||||
|
||||
for file_config in self.logging.files:
|
||||
file_dict = {
|
||||
'path': file_config.path,
|
||||
'level': file_config.level,
|
||||
'loggers': file_config.loggers,
|
||||
'max_bytes': file_config.max_bytes,
|
||||
'backup_count': file_config.backup_count,
|
||||
'format': {
|
||||
'type': file_config.format.type,
|
||||
'use_colors': file_config.format.use_colors,
|
||||
'show_module': file_config.format.show_module,
|
||||
'timestamp_format': file_config.format.timestamp_format
|
||||
}
|
||||
"path": file_config.path,
|
||||
"level": file_config.level,
|
||||
"loggers": file_config.loggers,
|
||||
"max_bytes": file_config.max_bytes,
|
||||
"backup_count": file_config.backup_count,
|
||||
"format": {
|
||||
"type": file_config.format.type,
|
||||
"use_colors": file_config.format.use_colors,
|
||||
"show_module": file_config.format.show_module,
|
||||
"timestamp_format": file_config.format.timestamp_format,
|
||||
},
|
||||
}
|
||||
cast(List[Dict[str, Any]], config_dict['files']).append(file_dict)
|
||||
cast(List[Dict[str, Any]], config_dict["files"]).append(file_dict)
|
||||
|
||||
setup_logging(config_dict)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
PyServeCtl - Service management CLI
|
||||
|
||||
Docker-compose-like tool for managing PyServe services.
|
||||
|
||||
Usage:
|
||||
pyservectl [OPTIONS] COMMAND [ARGS]...
|
||||
|
||||
Commands:
|
||||
init Initialize a new project
|
||||
config Configuration management
|
||||
up Start all services
|
||||
down Stop all services
|
||||
start Start specific services
|
||||
stop Stop specific services
|
||||
restart Restart services
|
||||
ps Show service status
|
||||
logs View service logs
|
||||
top Live monitoring dashboard
|
||||
health Check service health
|
||||
scale Scale services
|
||||
"""
|
||||
|
||||
from .main import cli, main
|
||||
|
||||
__all__ = ["cli", "main"]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
PyServe Daemon Process
|
||||
|
||||
Runs pyserve services in background mode.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import FrameType
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="PyServe Daemon")
|
||||
parser.add_argument("--config", required=True, help="Configuration file path")
|
||||
parser.add_argument("--state-dir", required=True, help="State directory path")
|
||||
parser.add_argument("--services", default=None, help="Comma-separated list of services")
|
||||
parser.add_argument("--scale", action="append", default=[], help="Scale overrides (name=workers)")
|
||||
parser.add_argument("--force-recreate", action="store_true", help="Force recreate services")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
config_path = Path(args.config)
|
||||
state_dir = Path(args.state_dir)
|
||||
|
||||
services = args.services.split(",") if args.services else None
|
||||
|
||||
scale_map = {}
|
||||
for scale in args.scale:
|
||||
name, workers = scale.split("=")
|
||||
scale_map[name] = int(workers)
|
||||
|
||||
from ..config import Config
|
||||
|
||||
config = Config.from_yaml(str(config_path))
|
||||
|
||||
from .state import StateManager
|
||||
|
||||
state_manager = StateManager(state_dir)
|
||||
|
||||
log_file = state_dir / "logs" / "daemon.log"
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(log_file),
|
||||
],
|
||||
)
|
||||
|
||||
logger = logging.getLogger("pyserve.daemon")
|
||||
|
||||
pid_file = state_dir / "pyserve.pid"
|
||||
pid_file.write_text(str(os.getpid()))
|
||||
|
||||
logger.info(f"Starting daemon with PID {os.getpid()}")
|
||||
|
||||
from ._runner import ServiceRunner
|
||||
|
||||
runner = ServiceRunner(config, state_manager)
|
||||
|
||||
def signal_handler(signum: int, frame: Optional[FrameType]) -> None:
|
||||
logger.info(f"Received signal {signum}, shutting down...")
|
||||
runner.stop()
|
||||
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
try:
|
||||
asyncio.run(
|
||||
runner.start(
|
||||
services=services,
|
||||
scale_map=scale_map,
|
||||
force_recreate=args.force_recreate,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Daemon error: {e}")
|
||||
sys.exit(1)
|
||||
finally:
|
||||
if pid_file.exists():
|
||||
pid_file.unlink()
|
||||
logger.info("Daemon stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,389 @@
|
||||
"""
|
||||
PyServe Service Runner
|
||||
|
||||
Handles starting, stopping, and managing services.
|
||||
Integrates with ProcessManager for actual process management.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from ..config import Config
|
||||
from ..process_manager import ProcessConfig, ProcessManager, ProcessState
|
||||
from .state import StateManager
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceDefinition:
|
||||
name: str
|
||||
path: str
|
||||
app_path: str
|
||||
app_type: str = "asgi"
|
||||
module_path: Optional[str] = None
|
||||
workers: int = 1
|
||||
health_check_path: str = "/health"
|
||||
health_check_interval: float = 10.0
|
||||
health_check_timeout: float = 5.0
|
||||
health_check_retries: int = 3
|
||||
max_restart_count: int = 5
|
||||
restart_delay: float = 1.0
|
||||
shutdown_timeout: float = 30.0
|
||||
strip_path: bool = True
|
||||
env: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
class ServiceRunner:
|
||||
def __init__(self, config: Config, state_manager: StateManager):
|
||||
self.config = config
|
||||
self.state_manager = state_manager
|
||||
self._process_manager: Optional[ProcessManager] = None
|
||||
self._services: Dict[str, ServiceDefinition] = {}
|
||||
self._running = False
|
||||
|
||||
self._parse_services()
|
||||
|
||||
def _parse_services(self) -> None:
|
||||
for ext in self.config.extensions:
|
||||
if ext.type == "process_orchestration":
|
||||
apps = ext.config.get("apps", [])
|
||||
for app_config in apps:
|
||||
service = ServiceDefinition(
|
||||
name=app_config.get("name", "unnamed"),
|
||||
path=app_config.get("path", "/"),
|
||||
app_path=app_config.get("app_path", ""),
|
||||
app_type=app_config.get("app_type", "asgi"),
|
||||
module_path=app_config.get("module_path"),
|
||||
workers=app_config.get("workers", 1),
|
||||
health_check_path=app_config.get("health_check_path", "/health"),
|
||||
health_check_interval=app_config.get("health_check_interval", 10.0),
|
||||
health_check_timeout=app_config.get("health_check_timeout", 5.0),
|
||||
health_check_retries=app_config.get("health_check_retries", 3),
|
||||
max_restart_count=app_config.get("max_restart_count", 5),
|
||||
restart_delay=app_config.get("restart_delay", 1.0),
|
||||
shutdown_timeout=app_config.get("shutdown_timeout", 30.0),
|
||||
strip_path=app_config.get("strip_path", True),
|
||||
env=app_config.get("env", {}),
|
||||
)
|
||||
self._services[service.name] = service
|
||||
|
||||
def get_services(self) -> Dict[str, ServiceDefinition]:
|
||||
return self._services.copy()
|
||||
|
||||
def get_service(self, name: str) -> Optional[ServiceDefinition]:
|
||||
return self._services.get(name)
|
||||
|
||||
async def start(
|
||||
self,
|
||||
services: Optional[List[str]] = None,
|
||||
scale_map: Optional[Dict[str, int]] = None,
|
||||
force_recreate: bool = False,
|
||||
wait_healthy: bool = False,
|
||||
timeout: int = 60,
|
||||
) -> None:
|
||||
from .output import console, print_error, print_info, print_success
|
||||
|
||||
scale_map = scale_map or {}
|
||||
|
||||
target_services = services or list(self._services.keys())
|
||||
|
||||
if not target_services:
|
||||
print_info("No services configured. Add services to your config.yaml")
|
||||
return
|
||||
|
||||
for name in target_services:
|
||||
if name not in self._services:
|
||||
print_error(f"Service '{name}' not found in configuration")
|
||||
return
|
||||
|
||||
port_range = (9000, 9999)
|
||||
for ext in self.config.extensions:
|
||||
if ext.type == "process_orchestration":
|
||||
port_range = tuple(ext.config.get("port_range", [9000, 9999]))
|
||||
break
|
||||
|
||||
self._process_manager = ProcessManager(
|
||||
port_range=port_range,
|
||||
health_check_enabled=True,
|
||||
)
|
||||
await self._process_manager.start()
|
||||
|
||||
self._running = True
|
||||
|
||||
for name in target_services:
|
||||
service = self._services[name]
|
||||
workers = scale_map.get(name, service.workers)
|
||||
|
||||
proc_config = ProcessConfig(
|
||||
name=name,
|
||||
app_path=service.app_path,
|
||||
app_type=service.app_type,
|
||||
workers=workers,
|
||||
module_path=service.module_path,
|
||||
health_check_enabled=True,
|
||||
health_check_path=service.health_check_path,
|
||||
health_check_interval=service.health_check_interval,
|
||||
health_check_timeout=service.health_check_timeout,
|
||||
health_check_retries=service.health_check_retries,
|
||||
max_restart_count=service.max_restart_count,
|
||||
restart_delay=service.restart_delay,
|
||||
shutdown_timeout=service.shutdown_timeout,
|
||||
env=service.env,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._process_manager.register(proc_config)
|
||||
success = await self._process_manager.start_process(name)
|
||||
|
||||
if success:
|
||||
info = self._process_manager.get_process(name)
|
||||
if info:
|
||||
self.state_manager.update_service(
|
||||
name,
|
||||
state="running",
|
||||
pid=info.pid,
|
||||
port=info.port,
|
||||
workers=workers,
|
||||
started_at=time.time(),
|
||||
)
|
||||
print_success(f"Started service: {name}")
|
||||
else:
|
||||
self.state_manager.update_service(name, state="failed")
|
||||
print_error(f"Failed to start service: {name}")
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Error starting {name}: {e}")
|
||||
self.state_manager.update_service(name, state="failed")
|
||||
|
||||
if wait_healthy:
|
||||
print_info("Waiting for services to be healthy...")
|
||||
await self._wait_healthy(target_services, timeout)
|
||||
|
||||
console.print("\n[bold]Services running. Press Ctrl+C to stop.[/bold]\n")
|
||||
|
||||
try:
|
||||
while self._running:
|
||||
await asyncio.sleep(1)
|
||||
await self._sync_state()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
await self.stop_all()
|
||||
|
||||
async def _sync_state(self) -> None:
|
||||
if not self._process_manager:
|
||||
return
|
||||
|
||||
for name, info in self._process_manager.get_all_processes().items():
|
||||
state_str = info.state.value
|
||||
health_status = "healthy" if info.health_check_failures == 0 else "unhealthy"
|
||||
|
||||
self.state_manager.update_service(
|
||||
name,
|
||||
state=state_str,
|
||||
pid=info.pid,
|
||||
port=info.port,
|
||||
)
|
||||
|
||||
service_state = self.state_manager.get_service(name)
|
||||
if service_state:
|
||||
service_state.health.status = health_status
|
||||
service_state.health.failures = info.health_check_failures
|
||||
self.state_manager.save()
|
||||
|
||||
async def _wait_healthy(self, services: List[str], timeout: int) -> None:
|
||||
from .output import print_info, print_warning
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
all_healthy = True
|
||||
|
||||
for name in services:
|
||||
if not self._process_manager:
|
||||
continue
|
||||
|
||||
info = self._process_manager.get_process(name)
|
||||
if not info or info.state != ProcessState.RUNNING:
|
||||
all_healthy = False
|
||||
break
|
||||
|
||||
if all_healthy:
|
||||
print_info("All services healthy")
|
||||
return
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
print_warning("Timeout waiting for services to become healthy")
|
||||
|
||||
async def stop_all(self, timeout: int = 30) -> None:
|
||||
from .output import print_info
|
||||
|
||||
self._running = False
|
||||
|
||||
if self._process_manager:
|
||||
print_info("Stopping all services...")
|
||||
await self._process_manager.stop()
|
||||
self._process_manager = None
|
||||
|
||||
for name in self._services:
|
||||
self.state_manager.update_service(
|
||||
name,
|
||||
state="stopped",
|
||||
pid=None,
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
async def start_service(self, name: str, timeout: int = 60) -> bool:
|
||||
from .output import print_error
|
||||
|
||||
service = self._services.get(name)
|
||||
if not service:
|
||||
print_error(f"Service '{name}' not found")
|
||||
return False
|
||||
|
||||
if not self._process_manager:
|
||||
self._process_manager = ProcessManager()
|
||||
await self._process_manager.start()
|
||||
|
||||
proc_config = ProcessConfig(
|
||||
name=name,
|
||||
app_path=service.app_path,
|
||||
app_type=service.app_type,
|
||||
workers=service.workers,
|
||||
module_path=service.module_path,
|
||||
health_check_enabled=True,
|
||||
health_check_path=service.health_check_path,
|
||||
env=service.env,
|
||||
)
|
||||
|
||||
try:
|
||||
existing = self._process_manager.get_process(name)
|
||||
if not existing:
|
||||
await self._process_manager.register(proc_config)
|
||||
|
||||
success = await self._process_manager.start_process(name)
|
||||
|
||||
if success:
|
||||
info = self._process_manager.get_process(name)
|
||||
self.state_manager.update_service(
|
||||
name,
|
||||
state="running",
|
||||
pid=info.pid if info else None,
|
||||
port=info.port if info else 0,
|
||||
started_at=time.time(),
|
||||
)
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Error starting {name}: {e}")
|
||||
return False
|
||||
|
||||
async def stop_service(self, name: str, timeout: int = 30, force: bool = False) -> bool:
|
||||
if not self._process_manager:
|
||||
self.state_manager.update_service(name, state="stopped", pid=None)
|
||||
return True
|
||||
|
||||
try:
|
||||
success = await self._process_manager.stop_process(name)
|
||||
|
||||
if success:
|
||||
self.state_manager.update_service(
|
||||
name,
|
||||
state="stopped",
|
||||
pid=None,
|
||||
)
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
from .output import print_error
|
||||
|
||||
print_error(f"Error stopping {name}: {e}")
|
||||
return False
|
||||
|
||||
async def restart_service(self, name: str, timeout: int = 60) -> bool:
|
||||
if not self._process_manager:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.state_manager.update_service(name, state="restarting")
|
||||
success = await self._process_manager.restart_process(name)
|
||||
|
||||
if success:
|
||||
info = self._process_manager.get_process(name)
|
||||
self.state_manager.update_service(
|
||||
name,
|
||||
state="running",
|
||||
pid=info.pid if info else None,
|
||||
port=info.port if info else 0,
|
||||
started_at=time.time(),
|
||||
)
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
from .output import print_error
|
||||
|
||||
print_error(f"Error restarting {name}: {e}")
|
||||
return False
|
||||
|
||||
async def scale_service(self, name: str, workers: int, timeout: int = 60, wait: bool = True) -> bool:
|
||||
# For now, this requires restart with new worker count
|
||||
# In future, could implement hot-reloading
|
||||
|
||||
service = self._services.get(name)
|
||||
if not service:
|
||||
return False
|
||||
|
||||
# Update service definition
|
||||
service.workers = workers
|
||||
|
||||
# Restart with new configuration
|
||||
return await self.restart_service(name, timeout)
|
||||
|
||||
def start_daemon(
|
||||
self,
|
||||
services: Optional[List[str]] = None,
|
||||
scale_map: Optional[Dict[str, int]] = None,
|
||||
force_recreate: bool = False,
|
||||
) -> int:
|
||||
import subprocess
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pyserve.cli._daemon",
|
||||
"--config",
|
||||
str(self.state_manager.state_dir.parent / "config.yaml"),
|
||||
"--state-dir",
|
||||
str(self.state_manager.state_dir),
|
||||
]
|
||||
|
||||
if services:
|
||||
cmd.extend(["--services", ",".join(services)])
|
||||
|
||||
if scale_map:
|
||||
for name, workers in scale_map.items():
|
||||
cmd.extend(["--scale", f"{name}={workers}"])
|
||||
|
||||
if force_recreate:
|
||||
cmd.append("--force-recreate")
|
||||
|
||||
env = os.environ.copy()
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
return process.pid
|
||||
@@ -0,0 +1,25 @@
|
||||
from .config import config_cmd
|
||||
from .down import down_cmd
|
||||
from .health import health_cmd
|
||||
from .init import init_cmd
|
||||
from .logs import logs_cmd
|
||||
from .scale import scale_cmd
|
||||
from .service import restart_cmd, start_cmd, stop_cmd
|
||||
from .status import ps_cmd
|
||||
from .top import top_cmd
|
||||
from .up import up_cmd
|
||||
|
||||
__all__ = [
|
||||
"init_cmd",
|
||||
"config_cmd",
|
||||
"up_cmd",
|
||||
"down_cmd",
|
||||
"start_cmd",
|
||||
"stop_cmd",
|
||||
"restart_cmd",
|
||||
"ps_cmd",
|
||||
"logs_cmd",
|
||||
"top_cmd",
|
||||
"health_cmd",
|
||||
"scale_cmd",
|
||||
]
|
||||
@@ -0,0 +1,419 @@
|
||||
"""
|
||||
pyserve config - Configuration management commands
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import click
|
||||
import yaml
|
||||
|
||||
|
||||
@click.group("config")
|
||||
def config_cmd() -> None:
|
||||
"""
|
||||
Configuration management commands.
|
||||
|
||||
\b
|
||||
Commands:
|
||||
validate Validate configuration file
|
||||
show Display current configuration
|
||||
get Get a specific configuration value
|
||||
set Set a configuration value
|
||||
diff Compare two configuration files
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@config_cmd.command("validate")
|
||||
@click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
"config_file",
|
||||
default=None,
|
||||
help="Path to configuration file",
|
||||
)
|
||||
@click.option(
|
||||
"--strict",
|
||||
is_flag=True,
|
||||
help="Enable strict validation (warn on unknown fields)",
|
||||
)
|
||||
@click.pass_obj
|
||||
def validate_cmd(ctx: Any, config_file: Optional[str], strict: bool) -> None:
|
||||
"""
|
||||
Validate a configuration file.
|
||||
|
||||
Checks for syntax errors, missing required fields, and invalid values.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve config validate
|
||||
pyserve config validate -c production.yaml
|
||||
pyserve config validate --strict
|
||||
"""
|
||||
from ..output import console, print_error, print_success, print_warning
|
||||
|
||||
config_path = Path(config_file or ctx.config_file)
|
||||
|
||||
if not config_path.exists():
|
||||
print_error(f"Configuration file not found: {config_path}")
|
||||
raise click.Abort()
|
||||
|
||||
console.print(f"Validating [cyan]{config_path}[/cyan]...")
|
||||
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if data is None:
|
||||
print_error("Configuration file is empty")
|
||||
raise click.Abort()
|
||||
|
||||
from ...config import Config
|
||||
|
||||
config = Config.from_yaml(str(config_path))
|
||||
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
if not (1 <= config.server.port <= 65535):
|
||||
errors.append(f"Invalid server port: {config.server.port}")
|
||||
|
||||
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
if config.logging.level.upper() not in valid_levels:
|
||||
errors.append(f"Invalid logging level: {config.logging.level}")
|
||||
|
||||
if config.ssl.enabled:
|
||||
if not Path(config.ssl.cert_file).exists():
|
||||
warnings.append(f"SSL cert file not found: {config.ssl.cert_file}")
|
||||
if not Path(config.ssl.key_file).exists():
|
||||
warnings.append(f"SSL key file not found: {config.ssl.key_file}")
|
||||
|
||||
valid_extension_types = [
|
||||
"routing",
|
||||
"process_orchestration",
|
||||
"asgi_mount",
|
||||
]
|
||||
for ext in config.extensions:
|
||||
if ext.type not in valid_extension_types:
|
||||
warnings.append(f"Unknown extension type: {ext.type}")
|
||||
|
||||
if strict:
|
||||
known_top_level = {"http", "server", "ssl", "logging", "extensions"}
|
||||
for key in data.keys():
|
||||
if key not in known_top_level:
|
||||
warnings.append(f"Unknown top-level field: {key}")
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
print_error(error)
|
||||
raise click.Abort()
|
||||
|
||||
if warnings:
|
||||
for warning in warnings:
|
||||
print_warning(warning)
|
||||
|
||||
print_success("Configuration is valid!")
|
||||
|
||||
except yaml.YAMLError as e:
|
||||
print_error(f"YAML syntax error: {e}")
|
||||
raise click.Abort()
|
||||
except Exception as e:
|
||||
print_error(f"Validation error: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@config_cmd.command("show")
|
||||
@click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
"config_file",
|
||||
default=None,
|
||||
help="Path to configuration file",
|
||||
)
|
||||
@click.option(
|
||||
"--format",
|
||||
"output_format",
|
||||
type=click.Choice(["yaml", "json", "table"]),
|
||||
default="yaml",
|
||||
help="Output format",
|
||||
)
|
||||
@click.option(
|
||||
"--section",
|
||||
"section",
|
||||
default=None,
|
||||
help="Show only a specific section (e.g., server, logging)",
|
||||
)
|
||||
@click.pass_obj
|
||||
def show_cmd(ctx: Any, config_file: Optional[str], output_format: str, section: Optional[str]) -> None:
|
||||
"""
|
||||
Display current configuration.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve config show
|
||||
pyserve config show --format json
|
||||
pyserve config show --section server
|
||||
"""
|
||||
from ..output import console, print_error
|
||||
|
||||
config_path = Path(config_file or ctx.config_file)
|
||||
|
||||
if not config_path.exists():
|
||||
print_error(f"Configuration file not found: {config_path}")
|
||||
raise click.Abort()
|
||||
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
if section:
|
||||
if section in data:
|
||||
data = {section: data[section]}
|
||||
else:
|
||||
print_error(f"Section '{section}' not found in configuration")
|
||||
raise click.Abort()
|
||||
|
||||
if output_format == "yaml":
|
||||
from rich.syntax import Syntax
|
||||
|
||||
yaml_str = yaml.dump(data, default_flow_style=False, sort_keys=False)
|
||||
syntax = Syntax(yaml_str, "yaml", theme="monokai", line_numbers=False)
|
||||
console.print(syntax)
|
||||
|
||||
elif output_format == "json":
|
||||
from rich.syntax import Syntax
|
||||
|
||||
json_str = json.dumps(data, indent=2)
|
||||
syntax = Syntax(json_str, "json", theme="monokai", line_numbers=False)
|
||||
console.print(syntax)
|
||||
|
||||
elif output_format == "table":
|
||||
from rich.tree import Tree
|
||||
|
||||
def build_tree(data: Any, tree: Any) -> None:
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (dict, list)):
|
||||
branch = tree.add(f"[cyan]{key}[/cyan]")
|
||||
build_tree(value, branch)
|
||||
else:
|
||||
tree.add(f"[cyan]{key}[/cyan]: [green]{value}[/green]")
|
||||
elif isinstance(data, list):
|
||||
for i, item in enumerate(data):
|
||||
if isinstance(item, (dict, list)):
|
||||
branch = tree.add(f"[dim][{i}][/dim]")
|
||||
build_tree(item, branch)
|
||||
else:
|
||||
tree.add(f"[dim][{i}][/dim] [green]{item}[/green]")
|
||||
|
||||
tree = Tree(f"[bold]Configuration: {config_path}[/bold]")
|
||||
build_tree(data, tree)
|
||||
console.print(tree)
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Error reading configuration: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@config_cmd.command("get")
|
||||
@click.argument("key")
|
||||
@click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
"config_file",
|
||||
default=None,
|
||||
help="Path to configuration file",
|
||||
)
|
||||
@click.pass_obj
|
||||
def get_cmd(ctx: Any, key: str, config_file: Optional[str]) -> None:
|
||||
"""
|
||||
Get a specific configuration value.
|
||||
|
||||
Use dot notation to access nested values.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve config get server.port
|
||||
pyserve config get logging.level
|
||||
pyserve config get extensions.0.type
|
||||
"""
|
||||
from ..output import console, print_error
|
||||
|
||||
config_path = Path(config_file or ctx.config_file)
|
||||
|
||||
if not config_path.exists():
|
||||
print_error(f"Configuration file not found: {config_path}")
|
||||
raise click.Abort()
|
||||
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
value = data
|
||||
for part in key.split("."):
|
||||
if isinstance(value, dict):
|
||||
if part in value:
|
||||
value = value[part]
|
||||
else:
|
||||
print_error(f"Key '{key}' not found")
|
||||
raise click.Abort()
|
||||
elif isinstance(value, list):
|
||||
try:
|
||||
index = int(part)
|
||||
value = value[index]
|
||||
except (ValueError, IndexError):
|
||||
print_error(f"Invalid index '{part}' in key '{key}'")
|
||||
raise click.Abort()
|
||||
else:
|
||||
print_error(f"Cannot access '{part}' in {type(value).__name__}")
|
||||
raise click.Abort()
|
||||
|
||||
if isinstance(value, (dict, list)):
|
||||
console.print(yaml.dump(value, default_flow_style=False))
|
||||
else:
|
||||
console.print(str(value))
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Error: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@config_cmd.command("set")
|
||||
@click.argument("key")
|
||||
@click.argument("value")
|
||||
@click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
"config_file",
|
||||
default=None,
|
||||
help="Path to configuration file",
|
||||
)
|
||||
@click.pass_obj
|
||||
def set_cmd(ctx: Any, key: str, value: str, config_file: Optional[str]) -> None:
|
||||
"""
|
||||
Set a configuration value.
|
||||
|
||||
Use dot notation to access nested values.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve config set server.port 8080
|
||||
pyserve config set logging.level DEBUG
|
||||
"""
|
||||
from ..output import print_error, print_success
|
||||
|
||||
config_path = Path(config_file or ctx.config_file)
|
||||
|
||||
if not config_path.exists():
|
||||
print_error(f"Configuration file not found: {config_path}")
|
||||
raise click.Abort()
|
||||
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
parsed_value: Any
|
||||
if value.lower() == "true":
|
||||
parsed_value = True
|
||||
elif value.lower() == "false":
|
||||
parsed_value = False
|
||||
elif value.isdigit():
|
||||
parsed_value = int(value)
|
||||
else:
|
||||
try:
|
||||
parsed_value = float(value)
|
||||
except ValueError:
|
||||
parsed_value = value
|
||||
|
||||
parts = key.split(".")
|
||||
current = data
|
||||
for part in parts[:-1]:
|
||||
if isinstance(current, dict):
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
elif isinstance(current, list):
|
||||
index = int(part)
|
||||
current = current[index]
|
||||
|
||||
final_key = parts[-1]
|
||||
if isinstance(current, dict):
|
||||
current[final_key] = parsed_value
|
||||
elif isinstance(current, list):
|
||||
current[int(final_key)] = parsed_value
|
||||
|
||||
with open(config_path, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
print_success(f"Set {key} = {parsed_value}")
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Error: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@config_cmd.command("diff")
|
||||
@click.argument("file1", type=click.Path(exists=True))
|
||||
@click.argument("file2", type=click.Path(exists=True))
|
||||
def diff_cmd(file1: str, file2: str) -> None:
|
||||
"""
|
||||
Compare two configuration files.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve config diff config.yaml production.yaml
|
||||
"""
|
||||
from ..output import console, print_error
|
||||
|
||||
try:
|
||||
with open(file1) as f:
|
||||
data1 = yaml.safe_load(f)
|
||||
with open(file2) as f:
|
||||
data2 = yaml.safe_load(f)
|
||||
|
||||
def compare_dicts(d1: Any, d2: Any, path: str = "") -> list[tuple[str, str, Any, Any]]:
|
||||
differences: list[tuple[str, str, Any, Any]] = []
|
||||
|
||||
all_keys = set(d1.keys() if d1 else []) | set(d2.keys() if d2 else [])
|
||||
|
||||
for key in sorted(all_keys):
|
||||
current_path = f"{path}.{key}" if path else key
|
||||
v1 = d1.get(key) if d1 else None
|
||||
v2 = d2.get(key) if d2 else None
|
||||
|
||||
if key not in (d1 or {}):
|
||||
differences.append(("added", current_path, None, v2))
|
||||
elif key not in (d2 or {}):
|
||||
differences.append(("removed", current_path, v1, None))
|
||||
elif isinstance(v1, dict) and isinstance(v2, dict):
|
||||
differences.extend(compare_dicts(v1, v2, current_path))
|
||||
elif v1 != v2:
|
||||
differences.append(("changed", current_path, v1, v2))
|
||||
|
||||
return differences
|
||||
|
||||
differences = compare_dicts(data1, data2)
|
||||
|
||||
if not differences:
|
||||
console.print("[green]Files are identical[/green]")
|
||||
return
|
||||
|
||||
console.print(f"\n[bold]Differences between {file1} and {file2}:[/bold]\n")
|
||||
|
||||
for diff_type, path, v1, v2 in differences:
|
||||
if diff_type == "added":
|
||||
console.print(f" [green]+ {path}: {v2}[/green]")
|
||||
elif diff_type == "removed":
|
||||
console.print(f" [red]- {path}: {v1}[/red]")
|
||||
elif diff_type == "changed":
|
||||
console.print(f" [yellow]~ {path}:[/yellow]")
|
||||
console.print(f" [red]- {v1}[/red]")
|
||||
console.print(f" [green]+ {v2}[/green]")
|
||||
|
||||
console.print()
|
||||
|
||||
except Exception as e:
|
||||
print_error(f"Error: {e}")
|
||||
raise click.Abort()
|
||||
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
pyserve down - Stop all services
|
||||
"""
|
||||
|
||||
import signal
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command("down")
|
||||
@click.option(
|
||||
"--timeout",
|
||||
"timeout",
|
||||
default=30,
|
||||
type=int,
|
||||
help="Timeout in seconds for graceful shutdown",
|
||||
)
|
||||
@click.option(
|
||||
"-v",
|
||||
"--volumes",
|
||||
is_flag=True,
|
||||
help="Remove volumes/data",
|
||||
)
|
||||
@click.option(
|
||||
"--remove-orphans",
|
||||
is_flag=True,
|
||||
help="Remove orphaned services",
|
||||
)
|
||||
@click.pass_obj
|
||||
def down_cmd(
|
||||
ctx: Any,
|
||||
timeout: int,
|
||||
volumes: bool,
|
||||
remove_orphans: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Stop and remove all services.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve down # Stop all services
|
||||
pyserve down --timeout 60 # Extended shutdown timeout
|
||||
pyserve down -v # Remove volumes too
|
||||
"""
|
||||
from ..output import console, print_error, print_info, print_success, print_warning
|
||||
from ..state import StateManager
|
||||
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
|
||||
if state_manager.is_daemon_running():
|
||||
daemon_pid = state_manager.get_daemon_pid()
|
||||
console.print(f"[bold]Stopping PyServe daemon (PID: {daemon_pid})...[/bold]")
|
||||
|
||||
try:
|
||||
import os
|
||||
|
||||
# FIXME: Please fix the cast usage here
|
||||
os.kill(cast(int, daemon_pid), signal.SIGTERM)
|
||||
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
# FIXME: Please fix the cast usage here
|
||||
os.kill(cast(int, daemon_pid), 0)
|
||||
time.sleep(0.5)
|
||||
except ProcessLookupError:
|
||||
break
|
||||
else:
|
||||
print_warning("Graceful shutdown timed out, forcing...")
|
||||
try:
|
||||
# FIXME: Please fix the cast usage here
|
||||
os.kill(cast(int, daemon_pid), signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
state_manager.clear_daemon_pid()
|
||||
print_success("PyServe daemon stopped")
|
||||
|
||||
except ProcessLookupError:
|
||||
print_info("Daemon was not running")
|
||||
state_manager.clear_daemon_pid()
|
||||
except PermissionError:
|
||||
print_error("Permission denied to stop daemon")
|
||||
raise click.Abort()
|
||||
else:
|
||||
services = state_manager.get_all_services()
|
||||
|
||||
if not services:
|
||||
print_info("No services are running")
|
||||
return
|
||||
|
||||
console.print("[bold]Stopping services...[/bold]")
|
||||
|
||||
from ...config import Config
|
||||
from .._runner import ServiceRunner
|
||||
|
||||
config_path = Path(ctx.config_file)
|
||||
if config_path.exists():
|
||||
config = Config.from_yaml(str(config_path))
|
||||
else:
|
||||
config = Config()
|
||||
|
||||
runner = ServiceRunner(config, state_manager)
|
||||
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.run(runner.stop_all(timeout=timeout))
|
||||
print_success("All services stopped")
|
||||
except Exception as e:
|
||||
print_error(f"Error stopping services: {e}")
|
||||
|
||||
if volumes:
|
||||
console.print("Cleaning up state...")
|
||||
state_manager.clear()
|
||||
print_info("State cleared")
|
||||
|
||||
if remove_orphans:
|
||||
# This would remove services that are in state but not in config
|
||||
pass
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
pyserve health - Check health of services
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command("health")
|
||||
@click.argument("services", nargs=-1)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
"timeout",
|
||||
default=5,
|
||||
type=int,
|
||||
help="Health check timeout in seconds",
|
||||
)
|
||||
@click.option(
|
||||
"--format",
|
||||
"output_format",
|
||||
type=click.Choice(["table", "json"]),
|
||||
default="table",
|
||||
help="Output format",
|
||||
)
|
||||
@click.pass_obj
|
||||
def health_cmd(ctx: Any, services: tuple[str, ...], timeout: int, output_format: str) -> None:
|
||||
"""
|
||||
Check health of services.
|
||||
|
||||
Performs active health checks on running services.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve health # Check all services
|
||||
pyserve health api admin # Check specific services
|
||||
pyserve health --format json # JSON output
|
||||
"""
|
||||
from ..output import console, print_error, print_info
|
||||
from ..state import StateManager
|
||||
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
all_services = state_manager.get_all_services()
|
||||
|
||||
if services:
|
||||
all_services = {k: v for k, v in all_services.items() if k in services}
|
||||
|
||||
if not all_services:
|
||||
print_info("No services to check")
|
||||
return
|
||||
|
||||
results = asyncio.run(_check_health(all_services, timeout))
|
||||
|
||||
if output_format == "json":
|
||||
import json
|
||||
|
||||
console.print(json.dumps(results, indent=2))
|
||||
return
|
||||
|
||||
from rich.table import Table
|
||||
|
||||
from ..output import format_health
|
||||
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("SERVICE", style="cyan")
|
||||
table.add_column("HEALTH")
|
||||
table.add_column("CHECKS", justify="right")
|
||||
table.add_column("LAST CHECK", style="dim")
|
||||
table.add_column("RESPONSE TIME", justify="right")
|
||||
|
||||
for name, result in results.items():
|
||||
health_str = format_health(result["status"])
|
||||
checks = f"{result['successes']}/{result['total']}"
|
||||
last_check = result.get("last_check", "-")
|
||||
response_time = f"{result['response_time_ms']:.0f}ms" if result.get("response_time_ms") else "-"
|
||||
|
||||
table.add_row(name, health_str, checks, last_check, response_time)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
healthy = sum(1 for r in results.values() if r["status"] == "healthy")
|
||||
unhealthy = sum(1 for r in results.values() if r["status"] == "unhealthy")
|
||||
|
||||
if unhealthy:
|
||||
print_error(f"{unhealthy} service(s) unhealthy")
|
||||
raise SystemExit(1)
|
||||
else:
|
||||
from ..output import print_success
|
||||
|
||||
print_success(f"All {healthy} service(s) healthy")
|
||||
|
||||
|
||||
async def _check_health(services: dict, timeout: int) -> dict:
|
||||
import time
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
return {name: {"status": "unknown", "error": "httpx not installed"} for name in services}
|
||||
|
||||
results = {}
|
||||
|
||||
for name, service in services.items():
|
||||
if service.state != "running" or not service.port:
|
||||
results[name] = {
|
||||
"status": "unknown",
|
||||
"successes": 0,
|
||||
"total": 0,
|
||||
"error": "Service not running",
|
||||
}
|
||||
continue
|
||||
|
||||
health_path = "/health"
|
||||
url = f"http://127.0.0.1:{service.port}{health_path}"
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(url)
|
||||
response_time = (time.time() - start_time) * 1000
|
||||
|
||||
if resp.status_code < 500:
|
||||
results[name] = {
|
||||
"status": "healthy",
|
||||
"successes": 1,
|
||||
"total": 1,
|
||||
"response_time_ms": response_time,
|
||||
"last_check": "just now",
|
||||
"status_code": resp.status_code,
|
||||
}
|
||||
else:
|
||||
results[name] = {
|
||||
"status": "unhealthy",
|
||||
"successes": 0,
|
||||
"total": 1,
|
||||
"response_time_ms": response_time,
|
||||
"last_check": "just now",
|
||||
"status_code": resp.status_code,
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
results[name] = {
|
||||
"status": "unhealthy",
|
||||
"successes": 0,
|
||||
"total": 1,
|
||||
"error": "timeout",
|
||||
"last_check": "just now",
|
||||
}
|
||||
except Exception as e:
|
||||
results[name] = {
|
||||
"status": "unhealthy",
|
||||
"successes": 0,
|
||||
"total": 1,
|
||||
"error": str(e),
|
||||
"last_check": "just now",
|
||||
}
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,432 @@
|
||||
"""
|
||||
pyserve init - Initialize a new pyserve project
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
TEMPLATES = {
|
||||
"basic": {
|
||||
"description": "Basic configuration with static files and routing",
|
||||
"filename": "config.yaml",
|
||||
},
|
||||
"orchestration": {
|
||||
"description": "Process orchestration with multiple ASGI/WSGI apps",
|
||||
"filename": "config.yaml",
|
||||
},
|
||||
"asgi": {
|
||||
"description": "ASGI mount configuration for in-process apps",
|
||||
"filename": "config.yaml",
|
||||
},
|
||||
"full": {
|
||||
"description": "Full configuration with all features",
|
||||
"filename": "config.yaml",
|
||||
},
|
||||
}
|
||||
|
||||
BASIC_TEMPLATE = """\
|
||||
# PyServe Configuration
|
||||
# Generated by: pyserve init
|
||||
|
||||
http:
|
||||
static_dir: ./static
|
||||
templates_dir: ./templates
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
backlog: 100
|
||||
proxy_timeout: 30.0
|
||||
|
||||
ssl:
|
||||
enabled: false
|
||||
cert_file: ./ssl/cert.pem
|
||||
key_file: ./ssl/key.pem
|
||||
|
||||
logging:
|
||||
level: INFO
|
||||
console_output: true
|
||||
format:
|
||||
type: standard
|
||||
use_colors: true
|
||||
show_module: true
|
||||
timestamp_format: "%Y-%m-%d %H:%M:%S"
|
||||
console:
|
||||
level: INFO
|
||||
format:
|
||||
type: standard
|
||||
use_colors: true
|
||||
files:
|
||||
- path: ./logs/pyserve.log
|
||||
level: INFO
|
||||
format:
|
||||
type: standard
|
||||
use_colors: false
|
||||
|
||||
extensions:
|
||||
- type: routing
|
||||
config:
|
||||
regex_locations:
|
||||
# Health check endpoint
|
||||
"=/health":
|
||||
return: "200 OK"
|
||||
content_type: "text/plain"
|
||||
|
||||
# Static files
|
||||
"^/static/":
|
||||
root: "./static"
|
||||
strip_prefix: "/static"
|
||||
|
||||
# Default fallback
|
||||
"__default__":
|
||||
spa_fallback: true
|
||||
root: "./static"
|
||||
index_file: "index.html"
|
||||
"""
|
||||
|
||||
ORCHESTRATION_TEMPLATE = """\
|
||||
# PyServe Process Orchestration Configuration
|
||||
# Generated by: pyserve init --template orchestration
|
||||
#
|
||||
# This configuration runs multiple ASGI/WSGI apps as isolated processes
|
||||
# with automatic health monitoring and restart.
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
backlog: 2048
|
||||
proxy_timeout: 60.0
|
||||
|
||||
logging:
|
||||
level: INFO
|
||||
console_output: true
|
||||
format:
|
||||
type: standard
|
||||
use_colors: true
|
||||
files:
|
||||
- path: ./logs/pyserve.log
|
||||
level: DEBUG
|
||||
format:
|
||||
type: standard
|
||||
use_colors: false
|
||||
|
||||
extensions:
|
||||
# Process Orchestration - runs each app in its own process
|
||||
- type: process_orchestration
|
||||
config:
|
||||
port_range: [9000, 9999]
|
||||
health_check_enabled: true
|
||||
proxy_timeout: 60.0
|
||||
|
||||
apps:
|
||||
# Example: FastAPI application
|
||||
- name: api
|
||||
path: /api
|
||||
app_path: myapp.api:app
|
||||
module_path: "."
|
||||
workers: 2
|
||||
health_check_path: /health
|
||||
health_check_interval: 10.0
|
||||
health_check_timeout: 5.0
|
||||
health_check_retries: 3
|
||||
max_restart_count: 5
|
||||
restart_delay: 1.0
|
||||
strip_path: true
|
||||
env:
|
||||
APP_ENV: "production"
|
||||
|
||||
# Example: Flask application (WSGI)
|
||||
# - name: admin
|
||||
# path: /admin
|
||||
# app_path: myapp.admin:app
|
||||
# app_type: wsgi
|
||||
# module_path: "."
|
||||
# workers: 1
|
||||
# health_check_path: /health
|
||||
# strip_path: true
|
||||
|
||||
# Static files routing
|
||||
- type: routing
|
||||
config:
|
||||
regex_locations:
|
||||
"=/health":
|
||||
return: "200 OK"
|
||||
content_type: "text/plain"
|
||||
|
||||
"^/static/":
|
||||
root: "./static"
|
||||
strip_prefix: "/static"
|
||||
"""
|
||||
|
||||
ASGI_TEMPLATE = """\
|
||||
# PyServe ASGI Mount Configuration
|
||||
# Generated by: pyserve init --template asgi
|
||||
#
|
||||
# This configuration mounts ASGI apps in-process (like ASGI Lifespan).
|
||||
# More efficient but apps share the same process.
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
backlog: 100
|
||||
proxy_timeout: 30.0
|
||||
|
||||
logging:
|
||||
level: INFO
|
||||
console_output: true
|
||||
format:
|
||||
type: standard
|
||||
use_colors: true
|
||||
files:
|
||||
- path: ./logs/pyserve.log
|
||||
level: DEBUG
|
||||
|
||||
extensions:
|
||||
- type: asgi_mount
|
||||
config:
|
||||
mounts:
|
||||
# FastAPI app mounted at /api
|
||||
- path: /api
|
||||
app: myapp.api:app
|
||||
# factory: false # Set to true if app is a factory function
|
||||
|
||||
# Starlette app mounted at /web
|
||||
# - path: /web
|
||||
# app: myapp.web:app
|
||||
|
||||
- type: routing
|
||||
config:
|
||||
regex_locations:
|
||||
"=/health":
|
||||
return: "200 OK"
|
||||
content_type: "text/plain"
|
||||
|
||||
"^/static/":
|
||||
root: "./static"
|
||||
strip_prefix: "/static"
|
||||
|
||||
"__default__":
|
||||
spa_fallback: true
|
||||
root: "./static"
|
||||
index_file: "index.html"
|
||||
"""
|
||||
|
||||
FULL_TEMPLATE = """\
|
||||
# PyServe Full Configuration
|
||||
# Generated by: pyserve init --template full
|
||||
#
|
||||
# Comprehensive configuration showcasing all PyServe features.
|
||||
|
||||
http:
|
||||
static_dir: ./static
|
||||
templates_dir: ./templates
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
backlog: 2048
|
||||
default_root: false
|
||||
proxy_timeout: 60.0
|
||||
redirect_instructions:
|
||||
"/old-path": "/new-path"
|
||||
|
||||
ssl:
|
||||
enabled: false
|
||||
cert_file: ./ssl/cert.pem
|
||||
key_file: ./ssl/key.pem
|
||||
|
||||
logging:
|
||||
level: INFO
|
||||
console_output: true
|
||||
format:
|
||||
type: standard
|
||||
use_colors: true
|
||||
show_module: true
|
||||
timestamp_format: "%Y-%m-%d %H:%M:%S"
|
||||
console:
|
||||
level: DEBUG
|
||||
format:
|
||||
type: standard
|
||||
use_colors: true
|
||||
files:
|
||||
# Main log file
|
||||
- path: ./logs/pyserve.log
|
||||
level: DEBUG
|
||||
format:
|
||||
type: standard
|
||||
use_colors: false
|
||||
|
||||
# JSON logs for log aggregation
|
||||
- path: ./logs/pyserve.json
|
||||
level: INFO
|
||||
format:
|
||||
type: json
|
||||
|
||||
# Access logs
|
||||
- path: ./logs/access.log
|
||||
level: INFO
|
||||
loggers: ["pyserve.access"]
|
||||
max_bytes: 10485760 # 10MB
|
||||
backup_count: 10
|
||||
|
||||
extensions:
|
||||
# Process Orchestration for background services
|
||||
- type: process_orchestration
|
||||
config:
|
||||
port_range: [9000, 9999]
|
||||
health_check_enabled: true
|
||||
proxy_timeout: 60.0
|
||||
|
||||
apps:
|
||||
- name: api
|
||||
path: /api
|
||||
app_path: myapp.api:app
|
||||
module_path: "."
|
||||
workers: 2
|
||||
health_check_path: /health
|
||||
strip_path: true
|
||||
env:
|
||||
APP_ENV: "production"
|
||||
|
||||
# Advanced routing with regex
|
||||
- type: routing
|
||||
config:
|
||||
regex_locations:
|
||||
# API versioning
|
||||
"~^/api/v(?P<version>\\\\d+)/":
|
||||
proxy_pass: "http://localhost:9001"
|
||||
headers:
|
||||
- "API-Version: {version}"
|
||||
- "X-Forwarded-For: $remote_addr"
|
||||
|
||||
# Static files with caching
|
||||
"~*\\\\.(js|css|png|jpg|gif|ico|svg|woff2?)$":
|
||||
root: "./static"
|
||||
cache_control: "public, max-age=31536000"
|
||||
headers:
|
||||
- "Access-Control-Allow-Origin: *"
|
||||
|
||||
# Health check
|
||||
"=/health":
|
||||
return: "200 OK"
|
||||
content_type: "text/plain"
|
||||
|
||||
# Static files
|
||||
"^/static/":
|
||||
root: "./static"
|
||||
strip_prefix: "/static"
|
||||
|
||||
# SPA fallback
|
||||
"__default__":
|
||||
spa_fallback: true
|
||||
root: "./static"
|
||||
index_file: "index.html"
|
||||
"""
|
||||
|
||||
|
||||
def get_template_content(template: str) -> str:
|
||||
templates = {
|
||||
"basic": BASIC_TEMPLATE,
|
||||
"orchestration": ORCHESTRATION_TEMPLATE,
|
||||
"asgi": ASGI_TEMPLATE,
|
||||
"full": FULL_TEMPLATE,
|
||||
}
|
||||
return templates.get(template, BASIC_TEMPLATE)
|
||||
|
||||
|
||||
@click.command("init")
|
||||
@click.option(
|
||||
"-t",
|
||||
"--template",
|
||||
"template",
|
||||
type=click.Choice(list(TEMPLATES.keys())),
|
||||
default="basic",
|
||||
help="Configuration template to use",
|
||||
)
|
||||
@click.option(
|
||||
"-o",
|
||||
"--output",
|
||||
"output_file",
|
||||
default="config.yaml",
|
||||
help="Output file path (default: config.yaml)",
|
||||
)
|
||||
@click.option(
|
||||
"-f",
|
||||
"--force",
|
||||
is_flag=True,
|
||||
help="Overwrite existing configuration",
|
||||
)
|
||||
@click.option(
|
||||
"--list-templates",
|
||||
is_flag=True,
|
||||
help="List available templates",
|
||||
)
|
||||
@click.pass_context
|
||||
def init_cmd(
|
||||
ctx: click.Context,
|
||||
template: str,
|
||||
output_file: str,
|
||||
force: bool,
|
||||
list_templates: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize a new pyserve project.
|
||||
|
||||
Creates a configuration file with sensible defaults and directory structure.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve init # Basic configuration
|
||||
pyserve init -t orchestration # Process orchestration setup
|
||||
pyserve init -t asgi # ASGI mount setup
|
||||
pyserve init -t full # All features
|
||||
pyserve init -o production.yaml # Custom output file
|
||||
"""
|
||||
from ..output import console, print_info, print_success, print_warning
|
||||
|
||||
if list_templates:
|
||||
console.print("\n[bold]Available Templates:[/bold]\n")
|
||||
for name, info in TEMPLATES.items():
|
||||
console.print(f" [cyan]{name:15}[/cyan] - {info['description']}")
|
||||
console.print()
|
||||
return
|
||||
|
||||
output_path = Path(output_file)
|
||||
|
||||
if output_path.exists() and not force:
|
||||
print_warning(f"Configuration file '{output_file}' already exists.")
|
||||
if not click.confirm("Do you want to overwrite it?"):
|
||||
raise click.Abort()
|
||||
|
||||
dirs_to_create = ["static", "templates", "logs"]
|
||||
if template == "orchestration":
|
||||
dirs_to_create.append("apps")
|
||||
|
||||
for dir_name in dirs_to_create:
|
||||
dir_path = Path(dir_name)
|
||||
if not dir_path.exists():
|
||||
dir_path.mkdir(parents=True)
|
||||
print_info(f"Created directory: {dir_name}/")
|
||||
|
||||
state_dir = Path(".pyserve")
|
||||
if not state_dir.exists():
|
||||
state_dir.mkdir()
|
||||
print_info("Created directory: .pyserve/")
|
||||
|
||||
content = get_template_content(template)
|
||||
output_path.write_text(content)
|
||||
|
||||
print_success(f"Created configuration file: {output_file}")
|
||||
print_info(f"Template: {template}")
|
||||
|
||||
gitignore_path = Path(".pyserve/.gitignore")
|
||||
if not gitignore_path.exists():
|
||||
gitignore_path.write_text("*\n!.gitignore\n")
|
||||
|
||||
console.print()
|
||||
console.print("[bold]Next steps:[/bold]")
|
||||
console.print(f" 1. Edit [cyan]{output_file}[/cyan] to configure your services")
|
||||
console.print(" 2. Run [cyan]pyserve config validate[/cyan] to check configuration")
|
||||
console.print(" 3. Run [cyan]pyserve up[/cyan] to start services")
|
||||
console.print()
|
||||
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
pyserve logs - View service logs
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command("logs")
|
||||
@click.argument("services", nargs=-1)
|
||||
@click.option(
|
||||
"-f",
|
||||
"--follow",
|
||||
is_flag=True,
|
||||
help="Follow log output",
|
||||
)
|
||||
@click.option(
|
||||
"--tail",
|
||||
"tail",
|
||||
default=100,
|
||||
type=int,
|
||||
help="Number of lines to show from the end",
|
||||
)
|
||||
@click.option(
|
||||
"--since",
|
||||
"since",
|
||||
default=None,
|
||||
help="Show logs since timestamp (e.g., '10m', '1h', '2024-01-01')",
|
||||
)
|
||||
@click.option(
|
||||
"--until",
|
||||
"until_time",
|
||||
default=None,
|
||||
help="Show logs until timestamp",
|
||||
)
|
||||
@click.option(
|
||||
"-t",
|
||||
"--timestamps",
|
||||
is_flag=True,
|
||||
help="Show timestamps",
|
||||
)
|
||||
@click.option(
|
||||
"--no-color",
|
||||
is_flag=True,
|
||||
help="Disable colored output",
|
||||
)
|
||||
@click.option(
|
||||
"--filter",
|
||||
"filter_pattern",
|
||||
default=None,
|
||||
help="Filter logs by pattern",
|
||||
)
|
||||
@click.pass_obj
|
||||
def logs_cmd(
|
||||
ctx: Any,
|
||||
services: tuple[str, ...],
|
||||
follow: bool,
|
||||
tail: int,
|
||||
since: Optional[str],
|
||||
until_time: Optional[str],
|
||||
timestamps: bool,
|
||||
no_color: bool,
|
||||
filter_pattern: Optional[str],
|
||||
) -> None:
|
||||
"""
|
||||
View service logs.
|
||||
|
||||
If no services are specified, shows logs from all services.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve logs # All logs
|
||||
pyserve logs api # Logs from api service
|
||||
pyserve logs api admin # Logs from multiple services
|
||||
pyserve logs -f # Follow logs
|
||||
pyserve logs --tail 50 # Last 50 lines
|
||||
pyserve logs --since "10m" # Logs from last 10 minutes
|
||||
"""
|
||||
from ..output import print_info
|
||||
from ..state import StateManager
|
||||
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
|
||||
if services:
|
||||
log_files = [(name, state_manager.get_service_log_file(name)) for name in services]
|
||||
else:
|
||||
all_services = state_manager.get_all_services()
|
||||
if not all_services:
|
||||
main_log = Path("logs/pyserve.log")
|
||||
if main_log.exists():
|
||||
log_files = [("pyserve", main_log)]
|
||||
else:
|
||||
print_info("No logs available. Start services with 'pyserve up'")
|
||||
return
|
||||
else:
|
||||
log_files = [(name, state_manager.get_service_log_file(name)) for name in all_services]
|
||||
|
||||
existing_logs = [(name, path) for name, path in log_files if path.exists()]
|
||||
|
||||
if not existing_logs:
|
||||
print_info("No log files found")
|
||||
return
|
||||
|
||||
since_time = _parse_time(since) if since else None
|
||||
until_timestamp = _parse_time(until_time) if until_time else None
|
||||
|
||||
colors = ["cyan", "green", "yellow", "blue", "magenta"]
|
||||
service_colors = {name: colors[i % len(colors)] for i, (name, _) in enumerate(existing_logs)}
|
||||
|
||||
if follow:
|
||||
asyncio.run(
|
||||
_follow_logs(
|
||||
existing_logs,
|
||||
service_colors,
|
||||
timestamps,
|
||||
no_color,
|
||||
filter_pattern,
|
||||
)
|
||||
)
|
||||
else:
|
||||
_read_logs(
|
||||
existing_logs,
|
||||
service_colors,
|
||||
tail,
|
||||
since_time,
|
||||
until_timestamp,
|
||||
timestamps,
|
||||
no_color,
|
||||
filter_pattern,
|
||||
)
|
||||
|
||||
|
||||
def _parse_time(time_str: str) -> Optional[float]:
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
# Relative time (e.g., "10m", "1h", "2d")
|
||||
match = re.match(r"^(\d+)([smhd])$", time_str)
|
||||
if match:
|
||||
value = int(match.group(1))
|
||||
unit = match.group(2)
|
||||
units = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
||||
return time.time() - (value * units[unit])
|
||||
|
||||
# Relative phrase (e.g., "10m ago")
|
||||
match = re.match(r"^(\d+)([smhd])\s+ago$", time_str)
|
||||
if match:
|
||||
value = int(match.group(1))
|
||||
unit = match.group(2)
|
||||
units = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
||||
return time.time() - (value * units[unit])
|
||||
|
||||
# ISO format
|
||||
try:
|
||||
dt = datetime.fromisoformat(time_str)
|
||||
return dt.timestamp()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _read_logs(
|
||||
log_files: list[tuple[str, Path]],
|
||||
service_colors: dict[str, str],
|
||||
tail: int,
|
||||
since_time: Optional[float],
|
||||
until_time: Optional[float],
|
||||
timestamps: bool,
|
||||
no_color: bool,
|
||||
filter_pattern: Optional[str],
|
||||
) -> None:
|
||||
import re
|
||||
|
||||
from ..output import console
|
||||
|
||||
all_lines = []
|
||||
|
||||
for service_name, log_path in log_files:
|
||||
try:
|
||||
with open(log_path) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Take last N lines
|
||||
lines = lines[-tail:] if tail else lines
|
||||
|
||||
for line in lines:
|
||||
line = line.rstrip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if filter_pattern and filter_pattern not in line:
|
||||
continue
|
||||
|
||||
line_time = None
|
||||
timestamp_match = re.match(r"^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})", line)
|
||||
if timestamp_match:
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
line_time = datetime.fromisoformat(timestamp_match.group(1).replace(" ", "T")).timestamp()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if since_time and line_time and line_time < since_time:
|
||||
continue
|
||||
if until_time and line_time and line_time > until_time:
|
||||
continue
|
||||
|
||||
all_lines.append((line_time or 0, service_name, line))
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error reading {log_path}: {e}[/red]")
|
||||
|
||||
all_lines.sort(key=lambda x: x[0])
|
||||
|
||||
for _, service_name, line in all_lines:
|
||||
if len(log_files) > 1:
|
||||
# Multiple services - prefix with service name
|
||||
if no_color:
|
||||
console.print(f"{service_name} | {line}")
|
||||
else:
|
||||
color = service_colors.get(service_name, "white")
|
||||
console.print(f"[{color}]{service_name}[/{color}] | {line}")
|
||||
else:
|
||||
console.print(line)
|
||||
|
||||
|
||||
async def _follow_logs(
|
||||
log_files: list[tuple[str, Path]],
|
||||
service_colors: dict[str, str],
|
||||
timestamps: bool,
|
||||
no_color: bool,
|
||||
filter_pattern: Optional[str],
|
||||
) -> None:
|
||||
from ..output import console
|
||||
|
||||
positions = {}
|
||||
for service_name, log_path in log_files:
|
||||
if log_path.exists():
|
||||
positions[service_name] = log_path.stat().st_size
|
||||
else:
|
||||
positions[service_name] = 0
|
||||
|
||||
console.print("[dim]Following logs... Press Ctrl+C to stop[/dim]\n")
|
||||
|
||||
try:
|
||||
while True:
|
||||
for service_name, log_path in log_files:
|
||||
if not log_path.exists():
|
||||
continue
|
||||
|
||||
current_size = log_path.stat().st_size
|
||||
if current_size > positions[service_name]:
|
||||
with open(log_path) as f:
|
||||
f.seek(positions[service_name])
|
||||
new_content = f.read()
|
||||
positions[service_name] = f.tell()
|
||||
|
||||
for line in new_content.splitlines():
|
||||
if filter_pattern and filter_pattern not in line:
|
||||
continue
|
||||
|
||||
if len(log_files) > 1:
|
||||
if no_color:
|
||||
console.print(f"{service_name} | {line}")
|
||||
else:
|
||||
color = service_colors.get(service_name, "white")
|
||||
console.print(f"[{color}]{service_name}[/{color}] | {line}")
|
||||
else:
|
||||
console.print(line)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Stopped following logs[/dim]")
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
pyserve scale - Scale services
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command("scale")
|
||||
@click.argument("scales", nargs=-1, required=True)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
"timeout",
|
||||
default=60,
|
||||
type=int,
|
||||
help="Timeout in seconds for scaling operation",
|
||||
)
|
||||
@click.option(
|
||||
"--no-wait",
|
||||
is_flag=True,
|
||||
help="Don't wait for services to be ready",
|
||||
)
|
||||
@click.pass_obj
|
||||
def scale_cmd(ctx: Any, scales: tuple[str, ...], timeout: int, no_wait: bool) -> None:
|
||||
"""
|
||||
Scale services to specified number of workers.
|
||||
|
||||
Use SERVICE=NUM format to specify scaling.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve scale api=4 # Scale api to 4 workers
|
||||
pyserve scale api=4 admin=2 # Scale multiple services
|
||||
"""
|
||||
from ...config import Config
|
||||
from .._runner import ServiceRunner
|
||||
from ..output import console, print_error, print_info, print_success
|
||||
from ..state import StateManager
|
||||
|
||||
scale_map = {}
|
||||
for scale in scales:
|
||||
try:
|
||||
service, num = scale.split("=")
|
||||
scale_map[service] = int(num)
|
||||
except ValueError:
|
||||
print_error(f"Invalid scale format: {scale}. Use SERVICE=NUM")
|
||||
raise click.Abort()
|
||||
|
||||
config_path = Path(ctx.config_file)
|
||||
if not config_path.exists():
|
||||
print_error(f"Configuration file not found: {config_path}")
|
||||
raise click.Abort()
|
||||
|
||||
config = Config.from_yaml(str(config_path))
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
|
||||
all_services = state_manager.get_all_services()
|
||||
for service in scale_map:
|
||||
if service not in all_services:
|
||||
print_error(f"Service '{service}' not found")
|
||||
raise click.Abort()
|
||||
|
||||
runner = ServiceRunner(config, state_manager)
|
||||
|
||||
console.print("[bold]Scaling services...[/bold]")
|
||||
|
||||
async def do_scale() -> None:
|
||||
for service, workers in scale_map.items():
|
||||
current = all_services[service].workers or 1
|
||||
print_info(f"Scaling {service}: {current} → {workers} workers")
|
||||
|
||||
try:
|
||||
success = await runner.scale_service(service, workers, timeout=timeout, wait=not no_wait)
|
||||
if success:
|
||||
print_success(f"Scaled {service} to {workers} workers")
|
||||
else:
|
||||
print_error(f"Failed to scale {service}")
|
||||
except Exception as e:
|
||||
print_error(f"Error scaling {service}: {e}")
|
||||
|
||||
try:
|
||||
asyncio.run(do_scale())
|
||||
except Exception as e:
|
||||
print_error(f"Scaling failed: {e}")
|
||||
raise click.Abort()
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
pyserve start/stop/restart - Service management commands
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command("start")
|
||||
@click.argument("services", nargs=-1, required=True)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
"timeout",
|
||||
default=60,
|
||||
type=int,
|
||||
help="Timeout in seconds for service startup",
|
||||
)
|
||||
@click.pass_obj
|
||||
def start_cmd(ctx: Any, services: tuple[str, ...], timeout: int) -> None:
|
||||
"""
|
||||
Start one or more services.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve start api # Start api service
|
||||
pyserve start api admin # Start multiple services
|
||||
"""
|
||||
from ...config import Config
|
||||
from .._runner import ServiceRunner
|
||||
from ..output import console, print_error, print_success
|
||||
from ..state import StateManager
|
||||
|
||||
config_path = Path(ctx.config_file)
|
||||
if not config_path.exists():
|
||||
print_error(f"Configuration file not found: {config_path}")
|
||||
raise click.Abort()
|
||||
|
||||
config = Config.from_yaml(str(config_path))
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
runner = ServiceRunner(config, state_manager)
|
||||
|
||||
console.print(f"[bold]Starting services: {', '.join(services)}[/bold]")
|
||||
|
||||
async def do_start() -> Dict[str, bool]:
|
||||
results: Dict[str, bool] = {}
|
||||
for service in services:
|
||||
try:
|
||||
success = await runner.start_service(service, timeout=timeout)
|
||||
results[service] = success
|
||||
if success:
|
||||
print_success(f"Started {service}")
|
||||
else:
|
||||
print_error(f"Failed to start {service}")
|
||||
except Exception as e:
|
||||
print_error(f"Error starting {service}: {e}")
|
||||
results[service] = False
|
||||
return results
|
||||
|
||||
try:
|
||||
results = asyncio.run(do_start())
|
||||
if not all(results.values()):
|
||||
raise click.Abort()
|
||||
except Exception as e:
|
||||
print_error(f"Error: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@click.command("stop")
|
||||
@click.argument("services", nargs=-1, required=True)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
"timeout",
|
||||
default=30,
|
||||
type=int,
|
||||
help="Timeout in seconds for graceful shutdown",
|
||||
)
|
||||
@click.option(
|
||||
"-f",
|
||||
"--force",
|
||||
is_flag=True,
|
||||
help="Force stop (SIGKILL)",
|
||||
)
|
||||
@click.pass_obj
|
||||
def stop_cmd(ctx: Any, services: tuple[str, ...], timeout: int, force: bool) -> None:
|
||||
"""
|
||||
Stop one or more services.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve stop api # Stop api service
|
||||
pyserve stop api admin # Stop multiple services
|
||||
pyserve stop api --force # Force stop
|
||||
"""
|
||||
from ...config import Config
|
||||
from .._runner import ServiceRunner
|
||||
from ..output import console, print_error, print_success
|
||||
from ..state import StateManager
|
||||
|
||||
config_path = Path(ctx.config_file)
|
||||
config = Config.from_yaml(str(config_path)) if config_path.exists() else Config()
|
||||
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
runner = ServiceRunner(config, state_manager)
|
||||
|
||||
console.print(f"[bold]Stopping services: {', '.join(services)}[/bold]")
|
||||
|
||||
async def do_stop() -> Dict[str, bool]:
|
||||
results: Dict[str, bool] = {}
|
||||
for service in services:
|
||||
try:
|
||||
success = await runner.stop_service(service, timeout=timeout, force=force)
|
||||
results[service] = success
|
||||
if success:
|
||||
print_success(f"Stopped {service}")
|
||||
else:
|
||||
print_error(f"Failed to stop {service}")
|
||||
except Exception as e:
|
||||
print_error(f"Error stopping {service}: {e}")
|
||||
results[service] = False
|
||||
return results
|
||||
|
||||
try:
|
||||
results = asyncio.run(do_stop())
|
||||
if not all(results.values()):
|
||||
raise click.Abort()
|
||||
except Exception as e:
|
||||
print_error(f"Error: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@click.command("restart")
|
||||
@click.argument("services", nargs=-1, required=True)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
"timeout",
|
||||
default=60,
|
||||
type=int,
|
||||
help="Timeout in seconds for restart",
|
||||
)
|
||||
@click.pass_obj
|
||||
def restart_cmd(ctx: Any, services: tuple[str, ...], timeout: int) -> None:
|
||||
"""
|
||||
Restart one or more services.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve restart api # Restart api service
|
||||
pyserve restart api admin # Restart multiple services
|
||||
"""
|
||||
from ...config import Config
|
||||
from .._runner import ServiceRunner
|
||||
from ..output import console, print_error, print_success
|
||||
from ..state import StateManager
|
||||
|
||||
config_path = Path(ctx.config_file)
|
||||
if not config_path.exists():
|
||||
print_error(f"Configuration file not found: {config_path}")
|
||||
raise click.Abort()
|
||||
|
||||
config = Config.from_yaml(str(config_path))
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
runner = ServiceRunner(config, state_manager)
|
||||
|
||||
console.print(f"[bold]Restarting services: {', '.join(services)}[/bold]")
|
||||
|
||||
async def do_restart() -> Dict[str, bool]:
|
||||
results = {}
|
||||
for service in services:
|
||||
try:
|
||||
success = await runner.restart_service(service, timeout=timeout)
|
||||
results[service] = success
|
||||
if success:
|
||||
print_success(f"Restarted {service}")
|
||||
else:
|
||||
print_error(f"Failed to restart {service}")
|
||||
except Exception as e:
|
||||
print_error(f"Error restarting {service}: {e}")
|
||||
results[service] = False
|
||||
return results
|
||||
|
||||
try:
|
||||
results = asyncio.run(do_restart())
|
||||
if not all(results.values()):
|
||||
raise click.Abort()
|
||||
except Exception as e:
|
||||
print_error(f"Error: {e}")
|
||||
raise click.Abort()
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
pyserve ps / status - Show service status
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command("ps")
|
||||
@click.argument("services", nargs=-1)
|
||||
@click.option(
|
||||
"-a",
|
||||
"--all",
|
||||
"show_all",
|
||||
is_flag=True,
|
||||
help="Show all services (including stopped)",
|
||||
)
|
||||
@click.option(
|
||||
"-q",
|
||||
"--quiet",
|
||||
is_flag=True,
|
||||
help="Only show service names",
|
||||
)
|
||||
@click.option(
|
||||
"--format",
|
||||
"output_format",
|
||||
type=click.Choice(["table", "json", "yaml"]),
|
||||
default="table",
|
||||
help="Output format",
|
||||
)
|
||||
@click.option(
|
||||
"--filter",
|
||||
"filter_status",
|
||||
default=None,
|
||||
help="Filter by status (running, stopped, failed)",
|
||||
)
|
||||
@click.pass_obj
|
||||
def ps_cmd(
|
||||
ctx: Any,
|
||||
services: tuple[str, ...],
|
||||
show_all: bool,
|
||||
quiet: bool,
|
||||
output_format: str,
|
||||
filter_status: Optional[str],
|
||||
) -> None:
|
||||
"""
|
||||
Show status of services.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve ps # Show running services
|
||||
pyserve ps -a # Show all services
|
||||
pyserve ps api admin # Show specific services
|
||||
pyserve ps --format json # JSON output
|
||||
pyserve ps --filter running # Filter by status
|
||||
"""
|
||||
from ..output import (
|
||||
console,
|
||||
create_services_table,
|
||||
format_health,
|
||||
format_status,
|
||||
format_uptime,
|
||||
print_info,
|
||||
)
|
||||
from ..state import StateManager
|
||||
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
all_services = state_manager.get_all_services()
|
||||
|
||||
# Check if daemon is running
|
||||
daemon_running = state_manager.is_daemon_running()
|
||||
|
||||
# Filter services
|
||||
if services:
|
||||
all_services = {k: v for k, v in all_services.items() if k in services}
|
||||
|
||||
if filter_status:
|
||||
all_services = {k: v for k, v in all_services.items() if v.state.lower() == filter_status.lower()}
|
||||
|
||||
if not show_all:
|
||||
# By default, show only running/starting/failed services
|
||||
all_services = {k: v for k, v in all_services.items() if v.state.lower() in ("running", "starting", "stopping", "failed", "restarting")}
|
||||
|
||||
if not all_services:
|
||||
if daemon_running:
|
||||
print_info("No services found. Daemon is running but no services are configured.")
|
||||
else:
|
||||
print_info("No services running. Use 'pyserve up' to start services.")
|
||||
return
|
||||
|
||||
if quiet:
|
||||
for name in all_services:
|
||||
click.echo(name)
|
||||
return
|
||||
|
||||
if output_format == "json":
|
||||
data = {name: svc.to_dict() for name, svc in all_services.items()}
|
||||
console.print(json.dumps(data, indent=2))
|
||||
return
|
||||
|
||||
if output_format == "yaml":
|
||||
import yaml
|
||||
|
||||
data = {name: svc.to_dict() for name, svc in all_services.items()}
|
||||
console.print(yaml.dump(data, default_flow_style=False))
|
||||
return
|
||||
|
||||
table = create_services_table()
|
||||
|
||||
for name, service in sorted(all_services.items()):
|
||||
ports = f"{service.port}" if service.port else "-"
|
||||
uptime = format_uptime(service.uptime) if service.state == "running" else "-"
|
||||
health = format_health(service.health.status if service.state == "running" else "-")
|
||||
pid = str(service.pid) if service.pid else "-"
|
||||
workers = f"{service.workers}" if service.workers else "-"
|
||||
|
||||
table.add_row(
|
||||
name,
|
||||
format_status(service.state),
|
||||
ports,
|
||||
uptime,
|
||||
health,
|
||||
pid,
|
||||
workers,
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
|
||||
total = len(all_services)
|
||||
running = sum(1 for s in all_services.values() if s.state == "running")
|
||||
failed = sum(1 for s in all_services.values() if s.state == "failed")
|
||||
|
||||
summary_parts = [f"[bold]{total}[/bold] service(s)"]
|
||||
if running:
|
||||
summary_parts.append(f"[green]{running} running[/green]")
|
||||
if failed:
|
||||
summary_parts.append(f"[red]{failed} failed[/red]")
|
||||
if total - running - failed > 0:
|
||||
summary_parts.append(f"[dim]{total - running - failed} stopped[/dim]")
|
||||
|
||||
console.print(" | ".join(summary_parts))
|
||||
console.print()
|
||||
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
pyserve top - Live monitoring dashboard
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command("top")
|
||||
@click.argument("services", nargs=-1)
|
||||
@click.option(
|
||||
"--refresh",
|
||||
"refresh_interval",
|
||||
default=2,
|
||||
type=float,
|
||||
help="Refresh interval in seconds",
|
||||
)
|
||||
@click.option(
|
||||
"--no-color",
|
||||
is_flag=True,
|
||||
help="Disable colored output",
|
||||
)
|
||||
@click.pass_obj
|
||||
def top_cmd(ctx: Any, services: tuple[str, ...], refresh_interval: float, no_color: bool) -> None:
|
||||
"""
|
||||
Live monitoring dashboard for services.
|
||||
|
||||
Shows real-time CPU, memory usage, and request metrics.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve top # Monitor all services
|
||||
pyserve top api admin # Monitor specific services
|
||||
pyserve top --refresh 5 # Slower refresh rate
|
||||
"""
|
||||
from ..output import console, print_info
|
||||
from ..state import StateManager
|
||||
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
|
||||
if not state_manager.is_daemon_running():
|
||||
print_info("No services running. Start with 'pyserve up -d'")
|
||||
return
|
||||
|
||||
try:
|
||||
asyncio.run(
|
||||
_run_dashboard(
|
||||
state_manager,
|
||||
list(services) if services else None,
|
||||
refresh_interval,
|
||||
no_color,
|
||||
)
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n")
|
||||
|
||||
|
||||
async def _run_dashboard(
|
||||
state_manager: Any,
|
||||
filter_services: Optional[list[str]],
|
||||
refresh_interval: float,
|
||||
no_color: bool,
|
||||
) -> None:
|
||||
from rich.layout import Layout
|
||||
from rich.live import Live
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from ..output import console, format_bytes, format_uptime
|
||||
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
console.print("[yellow]psutil not installed. Install with: pip install psutil[/yellow]")
|
||||
return
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
def make_dashboard() -> Any:
|
||||
all_services = state_manager.get_all_services()
|
||||
|
||||
if filter_services:
|
||||
all_services = {k: v for k, v in all_services.items() if k in filter_services}
|
||||
|
||||
table = Table(
|
||||
title=None,
|
||||
show_header=True,
|
||||
header_style="bold",
|
||||
border_style="dim",
|
||||
expand=True,
|
||||
)
|
||||
table.add_column("SERVICE", style="cyan", no_wrap=True)
|
||||
table.add_column("STATUS", no_wrap=True)
|
||||
table.add_column("CPU%", justify="right")
|
||||
table.add_column("MEM", justify="right")
|
||||
table.add_column("PID", style="dim")
|
||||
table.add_column("UPTIME", style="dim")
|
||||
table.add_column("HEALTH", no_wrap=True)
|
||||
|
||||
total_cpu = 0.0
|
||||
total_mem = 0
|
||||
running_count = 0
|
||||
total_count = len(all_services)
|
||||
|
||||
for name, service in sorted(all_services.items()):
|
||||
status_style = {
|
||||
"running": "[green]● RUN[/green]",
|
||||
"stopped": "[dim]○ STOP[/dim]",
|
||||
"failed": "[red]✗ FAIL[/red]",
|
||||
"starting": "[yellow]◐ START[/yellow]",
|
||||
"stopping": "[yellow]◑ STOP[/yellow]",
|
||||
}.get(service.state, service.state)
|
||||
|
||||
cpu_str = "-"
|
||||
mem_str = "-"
|
||||
|
||||
if service.pid and service.state == "running":
|
||||
try:
|
||||
proc = psutil.Process(service.pid)
|
||||
cpu = proc.cpu_percent(interval=0.1)
|
||||
mem = proc.memory_info().rss
|
||||
cpu_str = f"{cpu:.1f}%"
|
||||
mem_str = format_bytes(mem)
|
||||
total_cpu += cpu
|
||||
total_mem += mem
|
||||
running_count += 1
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
|
||||
health_style = {
|
||||
"healthy": "[green]✓[/green]",
|
||||
"unhealthy": "[red]✗[/red]",
|
||||
"degraded": "[yellow]⚠[/yellow]",
|
||||
"unknown": "[dim]?[/dim]",
|
||||
}.get(service.health.status, "[dim]-[/dim]")
|
||||
|
||||
uptime = format_uptime(service.uptime) if service.state == "running" else "-"
|
||||
pid = str(service.pid) if service.pid else "-"
|
||||
|
||||
table.add_row(
|
||||
name,
|
||||
status_style,
|
||||
cpu_str,
|
||||
mem_str,
|
||||
pid,
|
||||
uptime,
|
||||
health_style,
|
||||
)
|
||||
|
||||
elapsed = format_uptime(time.time() - start_time)
|
||||
summary = Text()
|
||||
summary.append(f"Running: {running_count}/{total_count}", style="bold")
|
||||
summary.append(" | ")
|
||||
summary.append(f"CPU: {total_cpu:.1f}%", style="cyan")
|
||||
summary.append(" | ")
|
||||
summary.append(f"MEM: {format_bytes(total_mem)}", style="cyan")
|
||||
summary.append(" | ")
|
||||
summary.append(f"Session: {elapsed}", style="dim")
|
||||
|
||||
layout = Layout()
|
||||
layout.split_column(
|
||||
Layout(
|
||||
Panel(
|
||||
Text("PyServe Dashboard", style="bold cyan", justify="center"),
|
||||
border_style="cyan",
|
||||
),
|
||||
size=3,
|
||||
),
|
||||
Layout(table),
|
||||
Layout(Panel(summary, border_style="dim"), size=3),
|
||||
)
|
||||
|
||||
return layout
|
||||
|
||||
with Live(make_dashboard(), refresh_per_second=1 / refresh_interval, console=console) as live:
|
||||
while True:
|
||||
await asyncio.sleep(refresh_interval)
|
||||
live.update(make_dashboard())
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
pyserve up - Start all services
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command("up")
|
||||
@click.argument("services", nargs=-1)
|
||||
@click.option(
|
||||
"-d",
|
||||
"--detach",
|
||||
is_flag=True,
|
||||
help="Run in background (detached mode)",
|
||||
)
|
||||
@click.option(
|
||||
"--build",
|
||||
is_flag=True,
|
||||
help="Build/reload applications before starting",
|
||||
)
|
||||
@click.option(
|
||||
"--force-recreate",
|
||||
is_flag=True,
|
||||
help="Recreate services even if configuration hasn't changed",
|
||||
)
|
||||
@click.option(
|
||||
"--scale",
|
||||
"scales",
|
||||
multiple=True,
|
||||
help="Scale SERVICE to NUM workers (e.g., --scale api=4)",
|
||||
)
|
||||
@click.option(
|
||||
"--timeout",
|
||||
"timeout",
|
||||
default=60,
|
||||
type=int,
|
||||
help="Timeout in seconds for service startup",
|
||||
)
|
||||
@click.option(
|
||||
"--wait",
|
||||
is_flag=True,
|
||||
help="Wait for services to be healthy before returning",
|
||||
)
|
||||
@click.option(
|
||||
"--remove-orphans",
|
||||
is_flag=True,
|
||||
help="Remove services not defined in configuration",
|
||||
)
|
||||
@click.pass_obj
|
||||
def up_cmd(
|
||||
ctx: Any,
|
||||
services: tuple[str, ...],
|
||||
detach: bool,
|
||||
build: bool,
|
||||
force_recreate: bool,
|
||||
scales: tuple[str, ...],
|
||||
timeout: int,
|
||||
wait: bool,
|
||||
remove_orphans: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Start services defined in configuration.
|
||||
|
||||
If no services are specified, all services will be started.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyserve up # Start all services
|
||||
pyserve up -d # Start in background
|
||||
pyserve up api admin # Start specific services
|
||||
pyserve up --scale api=4 # Scale api to 4 workers
|
||||
pyserve up --wait # Wait for healthy status
|
||||
"""
|
||||
from .._runner import ServiceRunner
|
||||
from ..output import console, print_error, print_info, print_success, print_warning
|
||||
from ..state import StateManager
|
||||
|
||||
config_path = Path(ctx.config_file)
|
||||
|
||||
if not config_path.exists():
|
||||
print_error(f"Configuration file not found: {config_path}")
|
||||
print_info("Run 'pyserve init' to create a configuration file")
|
||||
raise click.Abort()
|
||||
|
||||
scale_map = {}
|
||||
for scale in scales:
|
||||
try:
|
||||
service, num = scale.split("=")
|
||||
scale_map[service] = int(num)
|
||||
except ValueError:
|
||||
print_error(f"Invalid scale format: {scale}. Use SERVICE=NUM")
|
||||
raise click.Abort()
|
||||
|
||||
try:
|
||||
from ...config import Config
|
||||
|
||||
config = Config.from_yaml(str(config_path))
|
||||
except Exception as e:
|
||||
print_error(f"Failed to load configuration: {e}")
|
||||
raise click.Abort()
|
||||
|
||||
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||
|
||||
if state_manager.is_daemon_running():
|
||||
daemon_pid = state_manager.get_daemon_pid()
|
||||
print_warning(f"PyServe daemon is already running (PID: {daemon_pid})")
|
||||
if not click.confirm("Do you want to restart it?"):
|
||||
raise click.Abort()
|
||||
try:
|
||||
import os
|
||||
from typing import cast
|
||||
|
||||
# FIXME: Please fix the cast usage here
|
||||
os.kill(cast(int, daemon_pid), signal.SIGTERM)
|
||||
time.sleep(2)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
state_manager.clear_daemon_pid()
|
||||
|
||||
runner = ServiceRunner(config, state_manager)
|
||||
|
||||
service_list = list(services) if services else None
|
||||
|
||||
if detach:
|
||||
console.print("[bold]Starting PyServe in background...[/bold]")
|
||||
|
||||
try:
|
||||
pid = runner.start_daemon(
|
||||
service_list,
|
||||
scale_map=scale_map,
|
||||
force_recreate=force_recreate,
|
||||
)
|
||||
state_manager.set_daemon_pid(pid)
|
||||
print_success(f"PyServe started in background (PID: {pid})")
|
||||
print_info("Use 'pyserve ps' to see service status")
|
||||
print_info("Use 'pyserve logs -f' to follow logs")
|
||||
print_info("Use 'pyserve down' to stop")
|
||||
except Exception as e:
|
||||
print_error(f"Failed to start daemon: {e}")
|
||||
raise click.Abort()
|
||||
else:
|
||||
console.print("[bold]Starting PyServe...[/bold]")
|
||||
|
||||
def signal_handler(signum: int, frame: Any) -> None:
|
||||
console.print("\n[yellow]Received shutdown signal...[/yellow]")
|
||||
runner.stop()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
try:
|
||||
asyncio.run(
|
||||
runner.start(
|
||||
service_list,
|
||||
scale_map=scale_map,
|
||||
force_recreate=force_recreate,
|
||||
wait_healthy=wait,
|
||||
timeout=timeout,
|
||||
)
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Shutting down...[/yellow]")
|
||||
except Exception as e:
|
||||
print_error(f"Failed to start services: {e}")
|
||||
if ctx.debug:
|
||||
raise
|
||||
raise click.Abort()
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
PyServeCTL - Main entry point
|
||||
|
||||
Usage:
|
||||
pyservectl [OPTIONS] COMMAND [ARGS]...
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import click
|
||||
|
||||
from .. import __version__
|
||||
from .commands import (
|
||||
config_cmd,
|
||||
down_cmd,
|
||||
health_cmd,
|
||||
init_cmd,
|
||||
logs_cmd,
|
||||
ps_cmd,
|
||||
restart_cmd,
|
||||
scale_cmd,
|
||||
start_cmd,
|
||||
stop_cmd,
|
||||
top_cmd,
|
||||
up_cmd,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import Config
|
||||
from .state import StateManager
|
||||
|
||||
DEFAULT_CONFIG = "config.yaml"
|
||||
DEFAULT_STATE_DIR = ".pyserve"
|
||||
|
||||
|
||||
class Context:
|
||||
def __init__(self) -> None:
|
||||
self.config_file: str = DEFAULT_CONFIG
|
||||
self.state_dir: Path = Path(DEFAULT_STATE_DIR)
|
||||
self.verbose: bool = False
|
||||
self.debug: bool = False
|
||||
self.project: Optional[str] = None
|
||||
self._config: Optional["Config"] = None
|
||||
self._state: Optional["StateManager"] = None
|
||||
|
||||
@property
|
||||
def config(self) -> "Config":
|
||||
if self._config is None:
|
||||
from ..config import Config
|
||||
|
||||
if Path(self.config_file).exists():
|
||||
self._config = Config.from_yaml(self.config_file)
|
||||
else:
|
||||
self._config = Config()
|
||||
return self._config
|
||||
|
||||
@property
|
||||
def state(self) -> "StateManager":
|
||||
if self._state is None:
|
||||
from .state import StateManager
|
||||
|
||||
self._state = StateManager(self.state_dir, self.project)
|
||||
return self._state
|
||||
|
||||
|
||||
pass_context = click.make_pass_decorator(Context, ensure=True)
|
||||
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
"config_file",
|
||||
default=DEFAULT_CONFIG,
|
||||
envvar="PYSERVE_CONFIG",
|
||||
help=f"Path to configuration file (default: {DEFAULT_CONFIG})",
|
||||
type=click.Path(),
|
||||
)
|
||||
@click.option(
|
||||
"-p",
|
||||
"--project",
|
||||
"project",
|
||||
default=None,
|
||||
envvar="PYSERVE_PROJECT",
|
||||
help="Project name for isolation",
|
||||
)
|
||||
@click.option(
|
||||
"-v",
|
||||
"--verbose",
|
||||
is_flag=True,
|
||||
help="Enable verbose output",
|
||||
)
|
||||
@click.option(
|
||||
"--debug",
|
||||
is_flag=True,
|
||||
help="Enable debug mode",
|
||||
)
|
||||
@click.version_option(version=__version__, prog_name="pyservectl")
|
||||
@click.pass_context
|
||||
def cli(ctx: click.Context, config_file: str, project: Optional[str], verbose: bool, debug: bool) -> None:
|
||||
"""
|
||||
PyServeCTL - Service management CLI for PyServe.
|
||||
|
||||
Docker-compose-like tool for managing PyServe services.
|
||||
|
||||
\b
|
||||
Quick Start:
|
||||
pyservectl init # Initialize a new project
|
||||
pyservectl up # Start all services
|
||||
pyservectl ps # Show service status
|
||||
pyservectl logs -f # Follow logs
|
||||
pyservectl down # Stop all services
|
||||
|
||||
\b
|
||||
Examples:
|
||||
pyservectl up -d # Start in background
|
||||
pyservectl up -c prod.yaml # Use custom config
|
||||
pyservectl logs api -f --tail 100 # Follow api logs
|
||||
pyservectl restart api admin # Restart specific services
|
||||
pyservectl scale api=4 # Scale api to 4 workers
|
||||
"""
|
||||
ctx.ensure_object(Context)
|
||||
ctx.obj.config_file = config_file
|
||||
ctx.obj.verbose = verbose
|
||||
ctx.obj.debug = debug
|
||||
ctx.obj.project = project
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
click.echo(ctx.get_help())
|
||||
|
||||
|
||||
cli.add_command(init_cmd, name="init")
|
||||
cli.add_command(config_cmd, name="config")
|
||||
cli.add_command(up_cmd, name="up")
|
||||
cli.add_command(down_cmd, name="down")
|
||||
cli.add_command(start_cmd, name="start")
|
||||
cli.add_command(stop_cmd, name="stop")
|
||||
cli.add_command(restart_cmd, name="restart")
|
||||
cli.add_command(ps_cmd, name="ps")
|
||||
cli.add_command(logs_cmd, name="logs")
|
||||
cli.add_command(top_cmd, name="top")
|
||||
cli.add_command(health_cmd, name="health")
|
||||
cli.add_command(scale_cmd, name="scale")
|
||||
|
||||
# Alias 'status' -> 'ps'
|
||||
cli.add_command(ps_cmd, name="status")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
cli(standalone_mode=False)
|
||||
except click.ClickException as e:
|
||||
e.show()
|
||||
sys.exit(e.exit_code)
|
||||
except KeyboardInterrupt:
|
||||
click.echo("\nInterrupted by user")
|
||||
sys.exit(130)
|
||||
except Exception as e:
|
||||
if "--debug" in sys.argv:
|
||||
raise
|
||||
click.secho(f"Error: {e}", fg="red", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
PyServe CLI Output utilities
|
||||
|
||||
Rich-based formatters and helpers for CLI output.
|
||||
"""
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.theme import Theme
|
||||
|
||||
pyserve_theme = Theme(
|
||||
{
|
||||
"info": "cyan",
|
||||
"warning": "yellow",
|
||||
"error": "red bold",
|
||||
"success": "green",
|
||||
"service.running": "green",
|
||||
"service.stopped": "dim",
|
||||
"service.failed": "red",
|
||||
"service.starting": "yellow",
|
||||
}
|
||||
)
|
||||
|
||||
console = Console(theme=pyserve_theme)
|
||||
|
||||
|
||||
def print_error(message: str) -> None:
|
||||
console.print(f"[error] {message}[/error]")
|
||||
|
||||
|
||||
def print_warning(message: str) -> None:
|
||||
console.print(f"[warning] {message}[/warning]")
|
||||
|
||||
|
||||
def print_success(message: str) -> None:
|
||||
console.print(f"[success] {message}[/success]")
|
||||
|
||||
|
||||
def print_info(message: str) -> None:
|
||||
console.print(f"[info] {message}[/info]")
|
||||
|
||||
|
||||
def create_services_table() -> Table:
|
||||
table = Table(
|
||||
title=None,
|
||||
show_header=True,
|
||||
header_style="bold",
|
||||
border_style="dim",
|
||||
)
|
||||
table.add_column("NAME", style="cyan", no_wrap=True)
|
||||
table.add_column("STATUS", no_wrap=True)
|
||||
table.add_column("PORTS", style="dim")
|
||||
table.add_column("UPTIME", style="dim")
|
||||
table.add_column("HEALTH", no_wrap=True)
|
||||
table.add_column("PID", style="dim")
|
||||
table.add_column("WORKERS", style="dim")
|
||||
return table
|
||||
|
||||
|
||||
def format_status(status: str) -> str:
|
||||
status_styles = {
|
||||
"running": "[service.running]● running[/service.running]",
|
||||
"stopped": "[service.stopped]○ stopped[/service.stopped]",
|
||||
"failed": "[service.failed]✗ failed[/service.failed]",
|
||||
"starting": "[service.starting]◐ starting[/service.starting]",
|
||||
"stopping": "[service.starting]◑ stopping[/service.starting]",
|
||||
"restarting": "[service.starting]↻ restarting[/service.starting]",
|
||||
"pending": "[service.stopped]○ pending[/service.stopped]",
|
||||
}
|
||||
return status_styles.get(status.lower(), status)
|
||||
|
||||
|
||||
def format_health(health: str) -> str:
|
||||
health_styles = {
|
||||
"healthy": "[green] healthy[/green]",
|
||||
"unhealthy": "[red] unhealthy[/red]",
|
||||
"degraded": "[yellow] degraded[/yellow]",
|
||||
"unknown": "[dim] unknown[/dim]",
|
||||
"-": "[dim]-[/dim]",
|
||||
}
|
||||
return health_styles.get(health.lower(), health)
|
||||
|
||||
|
||||
def format_uptime(seconds: float) -> str:
|
||||
if seconds <= 0:
|
||||
return "-"
|
||||
|
||||
if seconds < 60:
|
||||
return f"{int(seconds)}s"
|
||||
elif seconds < 3600:
|
||||
minutes = int(seconds / 60)
|
||||
secs = int(seconds % 60)
|
||||
return f"{minutes}m {secs}s"
|
||||
elif seconds < 86400:
|
||||
hours = int(seconds / 3600)
|
||||
minutes = int((seconds % 3600) / 60)
|
||||
return f"{hours}h {minutes}m"
|
||||
else:
|
||||
days = int(seconds / 86400)
|
||||
hours = int((seconds % 86400) / 3600)
|
||||
return f"{days}d {hours}h"
|
||||
|
||||
|
||||
def format_bytes(num_bytes: int) -> str:
|
||||
value = float(num_bytes)
|
||||
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
||||
if abs(value) < 1024.0:
|
||||
return f"{value:.1f}{unit}"
|
||||
value /= 1024.0
|
||||
return f"{value:.1f}PB"
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
PyServe CLI State Management
|
||||
|
||||
Manages the state of running services.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceHealth:
|
||||
status: str = "unknown" # healthy, unhealthy, degraded, unknown
|
||||
last_check: Optional[float] = None
|
||||
failures: int = 0
|
||||
response_time_ms: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceState:
|
||||
name: str
|
||||
state: str = "stopped" # pending, starting, running, stopping, stopped, failed, restarting
|
||||
pid: Optional[int] = None
|
||||
port: int = 0
|
||||
workers: int = 0
|
||||
started_at: Optional[float] = None
|
||||
restart_count: int = 0
|
||||
health: ServiceHealth = field(default_factory=ServiceHealth)
|
||||
config_hash: str = ""
|
||||
|
||||
@property
|
||||
def uptime(self) -> float:
|
||||
if self.started_at is None:
|
||||
return 0.0
|
||||
return time.time() - self.started_at
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"state": self.state,
|
||||
"pid": self.pid,
|
||||
"port": self.port,
|
||||
"workers": self.workers,
|
||||
"started_at": self.started_at,
|
||||
"restart_count": self.restart_count,
|
||||
"health": asdict(self.health),
|
||||
"config_hash": self.config_hash,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ServiceState":
|
||||
health_data = data.pop("health", {})
|
||||
health = ServiceHealth(**health_data) if health_data else ServiceHealth()
|
||||
return cls(**data, health=health)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectState:
|
||||
version: str = "1.0"
|
||||
project: str = ""
|
||||
config_file: str = ""
|
||||
config_hash: str = ""
|
||||
started_at: Optional[float] = None
|
||||
daemon_pid: Optional[int] = None
|
||||
services: Dict[str, ServiceState] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"version": self.version,
|
||||
"project": self.project,
|
||||
"config_file": self.config_file,
|
||||
"config_hash": self.config_hash,
|
||||
"started_at": self.started_at,
|
||||
"daemon_pid": self.daemon_pid,
|
||||
"services": {name: svc.to_dict() for name, svc in self.services.items()},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ProjectState":
|
||||
services_data = data.pop("services", {})
|
||||
services = {name: ServiceState.from_dict(svc) for name, svc in services_data.items()}
|
||||
return cls(**data, services=services)
|
||||
|
||||
|
||||
class StateManager:
|
||||
STATE_FILE = "state.json"
|
||||
PID_FILE = "pyserve.pid"
|
||||
SOCKET_FILE = "pyserve.sock"
|
||||
LOGS_DIR = "logs"
|
||||
|
||||
def __init__(self, state_dir: Path, project: Optional[str] = None):
|
||||
self.state_dir = Path(state_dir)
|
||||
self.project = project or self._detect_project()
|
||||
self._state: Optional[ProjectState] = None
|
||||
|
||||
def _detect_project(self) -> str:
|
||||
return Path.cwd().name
|
||||
|
||||
@property
|
||||
def state_file(self) -> Path:
|
||||
return self.state_dir / self.STATE_FILE
|
||||
|
||||
@property
|
||||
def pid_file(self) -> Path:
|
||||
return self.state_dir / self.PID_FILE
|
||||
|
||||
@property
|
||||
def socket_file(self) -> Path:
|
||||
return self.state_dir / self.SOCKET_FILE
|
||||
|
||||
@property
|
||||
def logs_dir(self) -> Path:
|
||||
return self.state_dir / self.LOGS_DIR
|
||||
|
||||
def ensure_dirs(self) -> None:
|
||||
self.state_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def load(self) -> ProjectState:
|
||||
if self._state is not None:
|
||||
return self._state
|
||||
|
||||
if self.state_file.exists():
|
||||
try:
|
||||
with open(self.state_file) as f:
|
||||
data = json.load(f)
|
||||
self._state = ProjectState.from_dict(data)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
self._state = ProjectState(project=self.project)
|
||||
else:
|
||||
self._state = ProjectState(project=self.project)
|
||||
|
||||
return self._state
|
||||
|
||||
def save(self) -> None:
|
||||
if self._state is None:
|
||||
return
|
||||
|
||||
self.ensure_dirs()
|
||||
|
||||
with open(self.state_file, "w") as f:
|
||||
json.dump(self._state.to_dict(), f, indent=2)
|
||||
|
||||
def get_state(self) -> ProjectState:
|
||||
return self.load()
|
||||
|
||||
def update_service(self, name: str, **kwargs: Any) -> ServiceState:
|
||||
state = self.load()
|
||||
|
||||
if name not in state.services:
|
||||
state.services[name] = ServiceState(name=name)
|
||||
|
||||
service = state.services[name]
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(service, key):
|
||||
setattr(service, key, value)
|
||||
|
||||
self.save()
|
||||
return service
|
||||
|
||||
def remove_service(self, name: str) -> None:
|
||||
state = self.load()
|
||||
if name in state.services:
|
||||
del state.services[name]
|
||||
self.save()
|
||||
|
||||
def get_service(self, name: str) -> Optional[ServiceState]:
|
||||
state = self.load()
|
||||
return state.services.get(name)
|
||||
|
||||
def get_all_services(self) -> Dict[str, ServiceState]:
|
||||
state = self.load()
|
||||
return state.services.copy()
|
||||
|
||||
def clear(self) -> None:
|
||||
self._state = ProjectState(project=self.project)
|
||||
self.save()
|
||||
|
||||
def is_daemon_running(self) -> bool:
|
||||
if not self.pid_file.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
pid = int(self.pid_file.read_text().strip())
|
||||
# Check if process exists
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (ValueError, ProcessLookupError, PermissionError):
|
||||
return False
|
||||
|
||||
def get_daemon_pid(self) -> Optional[int]:
|
||||
if not self.is_daemon_running():
|
||||
return None
|
||||
|
||||
try:
|
||||
return int(self.pid_file.read_text().strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def set_daemon_pid(self, pid: int) -> None:
|
||||
self.ensure_dirs()
|
||||
self.pid_file.write_text(str(pid))
|
||||
|
||||
state = self.load()
|
||||
state.daemon_pid = pid
|
||||
self.save()
|
||||
|
||||
def clear_daemon_pid(self) -> None:
|
||||
if self.pid_file.exists():
|
||||
self.pid_file.unlink()
|
||||
|
||||
state = self.load()
|
||||
state.daemon_pid = None
|
||||
self.save()
|
||||
|
||||
def get_service_log_file(self, service_name: str) -> Path:
|
||||
self.ensure_dirs()
|
||||
return self.logs_dir / f"{service_name}.log"
|
||||
|
||||
def compute_config_hash(self, config_file: str) -> str:
|
||||
import hashlib
|
||||
|
||||
path = Path(config_file)
|
||||
if not path.exists():
|
||||
return ""
|
||||
|
||||
content = path.read_bytes()
|
||||
return hashlib.sha256(content).hexdigest()[:16]
|
||||
+121
-16
@@ -1,7 +1,10 @@
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, List, Optional, Type
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from .logging_utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -36,6 +39,7 @@ class RoutingExtension(Extension):
|
||||
default_proxy_timeout = config.get("default_proxy_timeout", 30.0)
|
||||
self.router = create_router_from_config(regex_locations)
|
||||
from .routing import RequestHandler
|
||||
|
||||
self.handler = RequestHandler(self.router, default_proxy_timeout=default_proxy_timeout)
|
||||
|
||||
async def process_request(self, request: Request) -> Optional[Response]:
|
||||
@@ -54,11 +58,9 @@ class SecurityExtension(Extension):
|
||||
super().__init__(config)
|
||||
self.allowed_ips = config.get("allowed_ips", [])
|
||||
self.blocked_ips = config.get("blocked_ips", [])
|
||||
self.security_headers = config.get("security_headers", {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block"
|
||||
})
|
||||
self.security_headers = config.get(
|
||||
"security_headers", {"X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "X-XSS-Protection": "1; mode=block"}
|
||||
)
|
||||
|
||||
async def process_request(self, request: Request) -> Optional[Response]:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
@@ -66,11 +68,13 @@ class SecurityExtension(Extension):
|
||||
if self.blocked_ips and client_ip in self.blocked_ips:
|
||||
logger.warning(f"Blocked request from IP: {client_ip}")
|
||||
from starlette.responses import PlainTextResponse
|
||||
|
||||
return PlainTextResponse("403 Forbidden", status_code=403)
|
||||
|
||||
if self.allowed_ips and client_ip not in self.allowed_ips:
|
||||
logger.warning(f"Access denied for IP: {client_ip}")
|
||||
from starlette.responses import PlainTextResponse
|
||||
|
||||
return PlainTextResponse("403 Forbidden", status_code=403)
|
||||
|
||||
return None
|
||||
@@ -108,36 +112,101 @@ class MonitoringExtension(Extension):
|
||||
async def process_request(self, request: Request) -> Optional[Response]:
|
||||
if self.enable_metrics:
|
||||
self.request_count += 1
|
||||
request.state.start_time = __import__('time').time()
|
||||
request.state.start_time = __import__("time").time()
|
||||
return None
|
||||
|
||||
async def process_response(self, request: Request, response: Response) -> Response:
|
||||
if self.enable_metrics and hasattr(request.state, 'start_time'):
|
||||
response_time = __import__('time').time() - request.state.start_time
|
||||
if self.enable_metrics and hasattr(request.state, "start_time"):
|
||||
response_time = __import__("time").time() - request.state.start_time
|
||||
self.response_times.append(response_time)
|
||||
|
||||
if response.status_code >= 400:
|
||||
self.error_count += 1
|
||||
|
||||
logger.info(f"Request: {request.method} {request.url.path} - "
|
||||
f"Status: {response.status_code} - "
|
||||
f"Time: {response_time:.3f}s")
|
||||
logger.info(f"Request: {request.method} {request.url.path} - " f"Status: {response.status_code} - " f"Time: {response_time:.3f}s")
|
||||
|
||||
return response
|
||||
|
||||
def get_metrics(self) -> Dict[str, Any]:
|
||||
avg_response_time = (sum(self.response_times) / len(self.response_times)
|
||||
if self.response_times else 0)
|
||||
avg_response_time = sum(self.response_times) / len(self.response_times) if self.response_times else 0
|
||||
|
||||
return {
|
||||
"request_count": self.request_count,
|
||||
"error_count": self.error_count,
|
||||
"error_rate": self.error_count / max(self.request_count, 1),
|
||||
"avg_response_time": avg_response_time,
|
||||
"total_response_times": len(self.response_times)
|
||||
"total_response_times": len(self.response_times),
|
||||
}
|
||||
|
||||
|
||||
class ASGIExtension(Extension):
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
super().__init__(config)
|
||||
from .asgi_mount import ASGIMountManager
|
||||
|
||||
self.mount_manager = ASGIMountManager()
|
||||
self._load_mounts(config.get("mounts", []))
|
||||
|
||||
def _load_mounts(self, mounts: List[Dict[str, Any]]) -> None:
|
||||
from .asgi_mount import create_django_app
|
||||
|
||||
for mount_config in mounts:
|
||||
path = mount_config.get("path", "/")
|
||||
|
||||
if "django_settings" in mount_config:
|
||||
app = create_django_app(
|
||||
settings_module=mount_config["django_settings"],
|
||||
module_path=mount_config.get("module_path"),
|
||||
)
|
||||
if app:
|
||||
self.mount_manager.mount(
|
||||
path=path,
|
||||
app=app,
|
||||
name=mount_config.get("name", f"django:{mount_config['django_settings']}"),
|
||||
strip_path=mount_config.get("strip_path", True),
|
||||
)
|
||||
continue
|
||||
|
||||
self.mount_manager.mount(
|
||||
path=path,
|
||||
app_path=mount_config.get("app_path"),
|
||||
app_type=mount_config.get("app_type", "asgi"),
|
||||
module_path=mount_config.get("module_path"),
|
||||
factory=mount_config.get("factory", False),
|
||||
factory_args=mount_config.get("factory_args"),
|
||||
name=mount_config.get("name", ""),
|
||||
strip_path=mount_config.get("strip_path", True),
|
||||
)
|
||||
|
||||
async def process_request(self, request: Request) -> Optional[Response]:
|
||||
path = request.url.path
|
||||
mount = self.mount_manager.get_mount(path)
|
||||
|
||||
if mount is not None:
|
||||
# Store mount info in request state for middleware to use
|
||||
request.state.asgi_mount = mount
|
||||
# Return a special marker response that middleware will intercept
|
||||
return None # Will be handled by get_asgi_handler
|
||||
|
||||
return None
|
||||
|
||||
async def process_response(self, request: Request, response: Response) -> Response:
|
||||
return response
|
||||
|
||||
def get_asgi_handler(self, request: Request) -> Optional[Any]:
|
||||
path = request.url.path
|
||||
return self.mount_manager.get_mount(path)
|
||||
|
||||
def get_metrics(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"asgi_mounts": self.mount_manager.list_mounts(),
|
||||
"asgi_mount_count": len(self.mount_manager.mounts),
|
||||
}
|
||||
|
||||
def cleanup(self) -> None:
|
||||
logger.info("Cleaning up ASGI mounts")
|
||||
|
||||
|
||||
class ExtensionManager:
|
||||
def __init__(self) -> None:
|
||||
self.extensions: List[Extension] = []
|
||||
@@ -145,8 +214,18 @@ class ExtensionManager:
|
||||
"routing": RoutingExtension,
|
||||
"security": SecurityExtension,
|
||||
"caching": CachingExtension,
|
||||
"monitoring": MonitoringExtension
|
||||
"monitoring": MonitoringExtension,
|
||||
"asgi": ASGIExtension,
|
||||
}
|
||||
self._register_process_orchestration()
|
||||
|
||||
def _register_process_orchestration(self) -> None:
|
||||
try:
|
||||
from .process_extension import ProcessOrchestrationExtension
|
||||
|
||||
self.extension_registry["process_orchestration"] = ProcessOrchestrationExtension
|
||||
except ImportError:
|
||||
pass # Optional dependency
|
||||
|
||||
def register_extension_type(self, name: str, extension_class: Type[Extension]) -> None:
|
||||
self.extension_registry[name] = extension_class
|
||||
@@ -165,6 +244,32 @@ class ExtensionManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading extension {extension_type}: {e}")
|
||||
|
||||
async def load_extension_async(self, extension_type: str, config: Dict[str, Any]) -> None:
|
||||
"""Load extension with async setup support (for ProcessOrchestration)."""
|
||||
if extension_type not in self.extension_registry:
|
||||
logger.error(f"Unknown extension type: {extension_type}")
|
||||
return
|
||||
|
||||
try:
|
||||
extension_class = self.extension_registry[extension_type]
|
||||
extension = extension_class(config)
|
||||
|
||||
setup_method = getattr(extension, "setup", None)
|
||||
if setup_method is not None and asyncio.iscoroutinefunction(setup_method):
|
||||
await setup_method(config)
|
||||
else:
|
||||
extension.initialize()
|
||||
|
||||
start_method = getattr(extension, "start", None)
|
||||
if start_method is not None and asyncio.iscoroutinefunction(start_method):
|
||||
await start_method()
|
||||
|
||||
# Insert at the beginning so process_orchestration is checked first
|
||||
self.extensions.insert(0, extension)
|
||||
logger.info(f"Loaded extension (async): {extension_type}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading extension {extension_type}: {e}")
|
||||
|
||||
async def process_request(self, request: Request) -> Optional[Response]:
|
||||
for extension in self.extensions:
|
||||
if not extension.enabled:
|
||||
|
||||
+93
-129
@@ -3,9 +3,10 @@ import logging.handlers
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, cast, Callable
|
||||
from typing import Any, Callable, Dict, List, cast
|
||||
|
||||
import structlog
|
||||
from structlog.types import FilteringBoundLogger, EventDict
|
||||
from structlog.types import EventDict, FilteringBoundLogger
|
||||
|
||||
from . import __version__
|
||||
|
||||
@@ -21,15 +22,15 @@ class StructlogFilter(logging.Filter):
|
||||
return True
|
||||
|
||||
for logger_name in self.logger_names:
|
||||
if record.name == logger_name or record.name.startswith(logger_name + '.'):
|
||||
if record.name == logger_name or record.name.startswith(logger_name + "."):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class UvicornStructlogFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
if hasattr(record, 'name') and 'uvicorn.access' in record.name:
|
||||
if hasattr(record, 'getMessage'):
|
||||
if hasattr(record, "name") and "uvicorn.access" in record.name:
|
||||
if hasattr(record, "getMessage"):
|
||||
msg = record.getMessage()
|
||||
if ' - "' in msg and '" ' in msg:
|
||||
parts = msg.split(' - "')
|
||||
@@ -56,14 +57,14 @@ def add_log_level(logger: FilteringBoundLogger, method_name: str, event_dict: Ev
|
||||
|
||||
|
||||
def add_module_info(logger: FilteringBoundLogger, method_name: str, event_dict: EventDict) -> EventDict:
|
||||
if hasattr(logger, '_context') and 'logger_name' in logger._context:
|
||||
logger_name = logger._context['logger_name']
|
||||
if logger_name.startswith('pyserve'):
|
||||
if hasattr(logger, "_context") and "logger_name" in logger._context:
|
||||
logger_name = logger._context["logger_name"]
|
||||
if logger_name.startswith("pyserve"):
|
||||
event_dict["module"] = logger_name
|
||||
elif logger_name.startswith('uvicorn'):
|
||||
event_dict["module"] = 'uvicorn'
|
||||
elif logger_name.startswith('starlette'):
|
||||
event_dict["module"] = 'starlette'
|
||||
elif logger_name.startswith("uvicorn"):
|
||||
event_dict["module"] = "uvicorn"
|
||||
elif logger_name.startswith("starlette"):
|
||||
event_dict["module"] = "starlette"
|
||||
else:
|
||||
event_dict["module"] = logger_name
|
||||
return event_dict
|
||||
@@ -74,18 +75,19 @@ def filter_module_info(show_module: bool) -> Callable[[FilteringBoundLogger, str
|
||||
if not show_module and "module" in event_dict:
|
||||
del event_dict["module"]
|
||||
return event_dict
|
||||
|
||||
return processor
|
||||
|
||||
|
||||
def colored_console_renderer(use_colors: bool = True, show_module: bool = True) -> structlog.dev.ConsoleRenderer:
|
||||
return structlog.dev.ConsoleRenderer(
|
||||
colors=use_colors and hasattr(sys.stderr, 'isatty') and sys.stderr.isatty(),
|
||||
colors=use_colors and hasattr(sys.stderr, "isatty") and sys.stderr.isatty(),
|
||||
level_styles={
|
||||
"critical": "\033[35m", # Magenta
|
||||
"error": "\033[31m", # Red
|
||||
"warning": "\033[33m", # Yellow
|
||||
"info": "\033[32m", # Green
|
||||
"debug": "\033[36m", # Cyan
|
||||
"error": "\033[31m", # Red
|
||||
"warning": "\033[33m", # Yellow
|
||||
"info": "\033[32m", # Green
|
||||
"debug": "\033[36m", # Cyan
|
||||
},
|
||||
pad_event=25,
|
||||
)
|
||||
@@ -113,43 +115,35 @@ class PyServeLogManager:
|
||||
if self.configured:
|
||||
return
|
||||
|
||||
if 'format' not in config and 'console' not in config and 'files' not in config:
|
||||
level = config.get('level', 'INFO').upper()
|
||||
console_output = config.get('console_output', True)
|
||||
log_file = config.get('log_file', './logs/pyserve.log')
|
||||
if "format" not in config and "console" not in config and "files" not in config:
|
||||
level = config.get("level", "INFO").upper()
|
||||
console_output = config.get("console_output", True)
|
||||
log_file = config.get("log_file", "./logs/pyserve.log")
|
||||
config = {
|
||||
'level': level,
|
||||
'console_output': console_output,
|
||||
'format': {
|
||||
'type': 'standard',
|
||||
'use_colors': True,
|
||||
'show_module': True,
|
||||
'timestamp_format': '%Y-%m-%d %H:%M:%S'
|
||||
},
|
||||
'files': [{
|
||||
'path': log_file,
|
||||
'level': level,
|
||||
'loggers': [],
|
||||
'max_bytes': 10 * 1024 * 1024,
|
||||
'backup_count': 5,
|
||||
'format': {
|
||||
'type': 'standard',
|
||||
'use_colors': False,
|
||||
'show_module': True,
|
||||
'timestamp_format': '%Y-%m-%d %H:%M:%S'
|
||||
"level": level,
|
||||
"console_output": console_output,
|
||||
"format": {"type": "standard", "use_colors": True, "show_module": True, "timestamp_format": "%Y-%m-%d %H:%M:%S"},
|
||||
"files": [
|
||||
{
|
||||
"path": log_file,
|
||||
"level": level,
|
||||
"loggers": [],
|
||||
"max_bytes": 10 * 1024 * 1024,
|
||||
"backup_count": 5,
|
||||
"format": {"type": "standard", "use_colors": False, "show_module": True, "timestamp_format": "%Y-%m-%d %H:%M:%S"},
|
||||
}
|
||||
}]
|
||||
],
|
||||
}
|
||||
|
||||
main_level = config.get('level', 'INFO').upper()
|
||||
console_output = config.get('console_output', True)
|
||||
main_level = config.get("level", "INFO").upper()
|
||||
console_output = config.get("console_output", True)
|
||||
|
||||
global_format = config.get('format', {})
|
||||
console_config = config.get('console', {})
|
||||
files_config = config.get('files', [])
|
||||
global_format = config.get("format", {})
|
||||
console_config = config.get("console", {})
|
||||
files_config = config.get("files", [])
|
||||
|
||||
console_format = {**global_format, **console_config.get('format', {})}
|
||||
console_level = console_config.get('level', main_level)
|
||||
console_format = {**global_format, **console_config.get("format", {})}
|
||||
console_level = console_config.get("level", main_level)
|
||||
|
||||
self._save_original_handlers()
|
||||
self._clear_all_handlers()
|
||||
@@ -159,38 +153,33 @@ class PyServeLogManager:
|
||||
console_output=console_output,
|
||||
console_format=console_format,
|
||||
console_level=console_level,
|
||||
files_config=files_config
|
||||
files_config=files_config,
|
||||
)
|
||||
|
||||
self._configure_stdlib_loggers(main_level)
|
||||
|
||||
logger = self.get_logger('pyserve')
|
||||
logger = self.get_logger("pyserve")
|
||||
logger.info(
|
||||
"PyServe logger initialized",
|
||||
version=__version__,
|
||||
level=main_level,
|
||||
console_output=console_output,
|
||||
console_format=console_format.get('type', 'standard')
|
||||
console_format=console_format.get("type", "standard"),
|
||||
)
|
||||
|
||||
for i, file_config in enumerate(files_config):
|
||||
logger.info(
|
||||
"File logging configured",
|
||||
file_index=i,
|
||||
path=file_config.get('path'),
|
||||
level=file_config.get('level', main_level),
|
||||
format_type=file_config.get('format', {}).get('type', 'standard')
|
||||
path=file_config.get("path"),
|
||||
level=file_config.get("level", main_level),
|
||||
format_type=file_config.get("format", {}).get("type", "standard"),
|
||||
)
|
||||
|
||||
self.configured = True
|
||||
|
||||
def _configure_structlog(
|
||||
self,
|
||||
main_level: str,
|
||||
console_output: bool,
|
||||
console_format: Dict[str, Any],
|
||||
console_level: str,
|
||||
files_config: List[Dict[str, Any]]
|
||||
self, main_level: str, console_output: bool, console_format: Dict[str, Any], console_level: str, files_config: List[Dict[str, Any]]
|
||||
) -> None:
|
||||
shared_processors = [
|
||||
structlog.stdlib.filter_by_level,
|
||||
@@ -202,57 +191,46 @@ class PyServeLogManager:
|
||||
]
|
||||
|
||||
if console_output:
|
||||
console_show_module = console_format.get('show_module', True)
|
||||
console_show_module = console_format.get("show_module", True)
|
||||
console_processors = shared_processors.copy()
|
||||
|
||||
console_processors.append(filter_module_info(console_show_module))
|
||||
|
||||
if console_format.get('type') == 'json':
|
||||
if console_format.get("type") == "json":
|
||||
console_processors.append(json_renderer())
|
||||
else:
|
||||
console_processors.append(
|
||||
colored_console_renderer(
|
||||
console_format.get('use_colors', True),
|
||||
console_show_module
|
||||
)
|
||||
)
|
||||
console_processors.append(colored_console_renderer(console_format.get("use_colors", True), console_show_module))
|
||||
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(getattr(logging, console_level))
|
||||
console_handler.addFilter(UvicornStructlogFilter())
|
||||
|
||||
console_formatter = structlog.stdlib.ProcessorFormatter(
|
||||
processor=colored_console_renderer(
|
||||
console_format.get('use_colors', True),
|
||||
console_show_module
|
||||
)
|
||||
if console_format.get('type') != 'json'
|
||||
else json_renderer(),
|
||||
processor=(
|
||||
colored_console_renderer(console_format.get("use_colors", True), console_show_module)
|
||||
if console_format.get("type") != "json"
|
||||
else json_renderer()
|
||||
),
|
||||
)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.DEBUG)
|
||||
root_logger.addHandler(console_handler)
|
||||
self.handlers['console'] = console_handler
|
||||
self.handlers["console"] = console_handler
|
||||
|
||||
for i, file_config in enumerate(files_config):
|
||||
file_path = file_config.get('path', './logs/pyserve.log')
|
||||
file_level = file_config.get('level', main_level)
|
||||
file_loggers = file_config.get('loggers', [])
|
||||
max_bytes = file_config.get('max_bytes', 10 * 1024 * 1024)
|
||||
backup_count = file_config.get('backup_count', 5)
|
||||
file_format = file_config.get('format', {})
|
||||
file_show_module = file_format.get('show_module', True)
|
||||
file_path = file_config.get("path", "./logs/pyserve.log")
|
||||
file_level = file_config.get("level", main_level)
|
||||
file_loggers = file_config.get("loggers", [])
|
||||
max_bytes = file_config.get("max_bytes", 10 * 1024 * 1024)
|
||||
backup_count = file_config.get("backup_count", 5)
|
||||
file_format = file_config.get("format", {})
|
||||
file_show_module = file_format.get("show_module", True)
|
||||
|
||||
self._ensure_log_directory(file_path)
|
||||
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
file_path,
|
||||
maxBytes=max_bytes,
|
||||
backupCount=backup_count,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler = logging.handlers.RotatingFileHandler(file_path, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8")
|
||||
file_handler.setLevel(getattr(logging, file_level))
|
||||
|
||||
if file_loggers:
|
||||
@@ -262,15 +240,13 @@ class PyServeLogManager:
|
||||
file_processors.append(filter_module_info(file_show_module))
|
||||
|
||||
file_formatter = structlog.stdlib.ProcessorFormatter(
|
||||
processor=json_renderer()
|
||||
if file_format.get('type') == 'json'
|
||||
else plain_console_renderer(file_show_module),
|
||||
processor=json_renderer() if file_format.get("type") == "json" else plain_console_renderer(file_show_module),
|
||||
)
|
||||
file_handler.setFormatter(file_formatter)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.addHandler(file_handler)
|
||||
self.handlers[f'file_{i}'] = file_handler
|
||||
self.handlers[f"file_{i}"] = file_handler
|
||||
|
||||
base_processors = [
|
||||
structlog.stdlib.filter_by_level,
|
||||
@@ -293,14 +269,14 @@ class PyServeLogManager:
|
||||
|
||||
def _configure_stdlib_loggers(self, main_level: str) -> None:
|
||||
library_configs = {
|
||||
'uvicorn': 'DEBUG' if main_level == 'DEBUG' else 'WARNING',
|
||||
'uvicorn.access': 'DEBUG' if main_level == 'DEBUG' else 'WARNING',
|
||||
'uvicorn.error': 'DEBUG' if main_level == 'DEBUG' else 'ERROR',
|
||||
'uvicorn.asgi': 'DEBUG' if main_level == 'DEBUG' else 'WARNING',
|
||||
'starlette': 'DEBUG' if main_level == 'DEBUG' else 'WARNING',
|
||||
'asyncio': 'WARNING',
|
||||
'concurrent.futures': 'WARNING',
|
||||
'multiprocessing': 'WARNING',
|
||||
"uvicorn": "DEBUG" if main_level == "DEBUG" else "WARNING",
|
||||
"uvicorn.access": "DEBUG" if main_level == "DEBUG" else "WARNING",
|
||||
"uvicorn.error": "DEBUG" if main_level == "DEBUG" else "ERROR",
|
||||
"uvicorn.asgi": "DEBUG" if main_level == "DEBUG" else "WARNING",
|
||||
"starlette": "DEBUG" if main_level == "DEBUG" else "WARNING",
|
||||
"asyncio": "WARNING",
|
||||
"concurrent.futures": "WARNING",
|
||||
"multiprocessing": "WARNING",
|
||||
}
|
||||
|
||||
for logger_name, level in library_configs.items():
|
||||
@@ -309,7 +285,7 @@ class PyServeLogManager:
|
||||
logger.propagate = True
|
||||
|
||||
def _save_original_handlers(self) -> None:
|
||||
logger_names = ['', 'uvicorn', 'uvicorn.access', 'uvicorn.error', 'starlette']
|
||||
logger_names = ["", "uvicorn", "uvicorn.access", "uvicorn.error", "starlette"]
|
||||
|
||||
for name in logger_names:
|
||||
logger = logging.getLogger(name)
|
||||
@@ -320,7 +296,7 @@ class PyServeLogManager:
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
logger_names = ['uvicorn', 'uvicorn.access', 'uvicorn.error', 'starlette']
|
||||
logger_names = ["uvicorn", "uvicorn.access", "uvicorn.error", "starlette"]
|
||||
for name in logger_names:
|
||||
logger = logging.getLogger(name)
|
||||
for handler in logger.handlers[:]:
|
||||
@@ -335,14 +311,17 @@ class PyServeLogManager:
|
||||
def get_logger(self, name: str) -> structlog.stdlib.BoundLogger:
|
||||
if not self._structlog_configured:
|
||||
structlog.configure(
|
||||
processors=cast(Any, [
|
||||
structlog.stdlib.filter_by_level,
|
||||
add_timestamp,
|
||||
add_log_level,
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
]),
|
||||
processors=cast(
|
||||
Any,
|
||||
[
|
||||
structlog.stdlib.filter_by_level,
|
||||
add_timestamp,
|
||||
add_log_level,
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
],
|
||||
),
|
||||
context_class=dict,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
@@ -370,16 +349,8 @@ class PyServeLogManager:
|
||||
handler.close()
|
||||
del self.handlers[name]
|
||||
|
||||
def create_access_log(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
status_code: int,
|
||||
response_time: float,
|
||||
client_ip: str,
|
||||
user_agent: str = ""
|
||||
) -> None:
|
||||
access_logger = self.get_logger('pyserve.access')
|
||||
def create_access_log(self, method: str, path: str, status_code: int, response_time: float, client_ip: str, user_agent: str = "") -> None:
|
||||
access_logger = self.get_logger("pyserve.access")
|
||||
access_logger.info(
|
||||
"HTTP access",
|
||||
method=method,
|
||||
@@ -388,7 +359,7 @@ class PyServeLogManager:
|
||||
response_time_ms=round(response_time * 1000, 2),
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
timestamp_format="access"
|
||||
timestamp_format="access",
|
||||
)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
@@ -416,14 +387,7 @@ def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
return log_manager.get_logger(name)
|
||||
|
||||
|
||||
def create_access_log(
|
||||
method: str,
|
||||
path: str,
|
||||
status_code: int,
|
||||
response_time: float,
|
||||
client_ip: str,
|
||||
user_agent: str = ""
|
||||
) -> None:
|
||||
def create_access_log(method: str, path: str, status_code: int, response_time: float, client_ip: str, user_agent: str = "") -> None:
|
||||
log_manager.create_access_log(method, path, status_code, response_time, client_ip, user_agent)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Path matcher module - uses Cython implementation if available, falls back to pure Python.
|
||||
"""
|
||||
|
||||
try:
|
||||
from pyserve._path_matcher import (
|
||||
FastMountedPath,
|
||||
FastMountManager,
|
||||
match_and_modify_path,
|
||||
path_matches_prefix,
|
||||
strip_path_prefix,
|
||||
)
|
||||
|
||||
CYTHON_AVAILABLE = True
|
||||
except ImportError:
|
||||
from pyserve._path_matcher_py import (
|
||||
FastMountedPath,
|
||||
FastMountManager,
|
||||
match_and_modify_path,
|
||||
path_matches_prefix,
|
||||
strip_path_prefix,
|
||||
)
|
||||
|
||||
CYTHON_AVAILABLE = False
|
||||
|
||||
__all__ = [
|
||||
"FastMountedPath",
|
||||
"FastMountManager",
|
||||
"path_matches_prefix",
|
||||
"strip_path_prefix",
|
||||
"match_and_modify_path",
|
||||
"CYTHON_AVAILABLE",
|
||||
]
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Process Orchestration Extension
|
||||
|
||||
Extension that manages ASGI/WSGI applications as isolated processes
|
||||
and routes requests to them via reverse proxy.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from .extensions import Extension
|
||||
from .logging_utils import get_logger
|
||||
from .process_manager import ProcessConfig, ProcessManager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ProcessOrchestrationExtension(Extension):
|
||||
"""
|
||||
Extension that orchestrates ASGI/WSGI applications as separate processes.
|
||||
|
||||
Unlike ASGIExtension which runs apps in-process, this extension:
|
||||
- Runs each app in its own isolated process
|
||||
- Provides health monitoring and auto-restart
|
||||
- Routes requests via HTTP reverse proxy
|
||||
- Supports multiple workers per app
|
||||
|
||||
Configuration example:
|
||||
```yaml
|
||||
extensions:
|
||||
- type: process_orchestration
|
||||
config:
|
||||
port_range: [9000, 9999]
|
||||
health_check_enabled: true
|
||||
apps:
|
||||
- name: api
|
||||
path: /api
|
||||
app_path: myapp.api:app
|
||||
workers: 4
|
||||
health_check_path: /health
|
||||
|
||||
- name: admin
|
||||
path: /admin
|
||||
app_path: myapp.admin:create_app
|
||||
factory: true
|
||||
workers: 2
|
||||
```
|
||||
"""
|
||||
|
||||
name = "process_orchestration"
|
||||
|
||||
def __init__(self, config: Dict[str, Any]) -> None:
|
||||
super().__init__(config)
|
||||
self._manager: Optional[ProcessManager] = None
|
||||
self._mounts: Dict[str, MountConfig] = {} # path -> config
|
||||
self._http_client: Optional[httpx.AsyncClient] = None
|
||||
self._started = False
|
||||
self._proxy_timeout: float = config.get("proxy_timeout", 60.0)
|
||||
self._pending_config = config # Store for async setup
|
||||
|
||||
logging_config = config.get("logging", {})
|
||||
self._log_proxy_requests: bool = logging_config.get("proxy_logs", True)
|
||||
self._log_health_checks: bool = logging_config.get("health_check_logs", False)
|
||||
|
||||
httpx_level = logging_config.get("httpx_level", "warning").upper()
|
||||
logging.getLogger("httpx").setLevel(getattr(logging, httpx_level, logging.WARNING))
|
||||
logging.getLogger("httpcore").setLevel(getattr(logging, httpx_level, logging.WARNING))
|
||||
|
||||
async def setup(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
if config is None:
|
||||
config = self._pending_config
|
||||
|
||||
port_range = tuple(config.get("port_range", [9000, 9999]))
|
||||
health_check_enabled = config.get("health_check_enabled", True)
|
||||
self._proxy_timeout = config.get("proxy_timeout", 60.0)
|
||||
|
||||
self._manager = ProcessManager(
|
||||
port_range=port_range,
|
||||
health_check_enabled=health_check_enabled,
|
||||
)
|
||||
|
||||
self._http_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self._proxy_timeout),
|
||||
follow_redirects=False,
|
||||
limits=httpx.Limits(
|
||||
max_keepalive_connections=100,
|
||||
max_connections=200,
|
||||
),
|
||||
)
|
||||
|
||||
apps_config = config.get("apps", [])
|
||||
for app_config in apps_config:
|
||||
await self._register_app(app_config)
|
||||
|
||||
logger.info(
|
||||
"Process orchestration extension initialized",
|
||||
app_count=len(self._mounts),
|
||||
)
|
||||
|
||||
async def _register_app(self, app_config: Dict[str, Any]) -> None:
|
||||
if not self._manager:
|
||||
return
|
||||
|
||||
name = app_config.get("name")
|
||||
path = app_config.get("path", "").rstrip("/")
|
||||
app_path = app_config.get("app_path")
|
||||
|
||||
if not name or not app_path:
|
||||
logger.error("App config missing 'name' or 'app_path'")
|
||||
return
|
||||
|
||||
process_config = ProcessConfig(
|
||||
name=name,
|
||||
app_path=app_path,
|
||||
app_type=app_config.get("app_type", "asgi"),
|
||||
workers=app_config.get("workers", 1),
|
||||
module_path=app_config.get("module_path"),
|
||||
factory=app_config.get("factory", False),
|
||||
factory_args=app_config.get("factory_args"),
|
||||
env=app_config.get("env", {}),
|
||||
health_check_enabled=app_config.get("health_check_enabled", True),
|
||||
health_check_path=app_config.get("health_check_path", "/health"),
|
||||
health_check_interval=app_config.get("health_check_interval", 10.0),
|
||||
health_check_timeout=app_config.get("health_check_timeout", 5.0),
|
||||
health_check_retries=app_config.get("health_check_retries", 3),
|
||||
max_memory_mb=app_config.get("max_memory_mb"),
|
||||
max_restart_count=app_config.get("max_restart_count", 5),
|
||||
restart_delay=app_config.get("restart_delay", 1.0),
|
||||
shutdown_timeout=app_config.get("shutdown_timeout", 30.0),
|
||||
)
|
||||
|
||||
await self._manager.register(process_config)
|
||||
|
||||
self._mounts[path] = MountConfig(
|
||||
path=path,
|
||||
process_name=name,
|
||||
strip_path=app_config.get("strip_path", True),
|
||||
)
|
||||
|
||||
logger.info(f"Registered app '{name}' at path '{path}'")
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._started or not self._manager:
|
||||
return
|
||||
|
||||
await self._manager.start()
|
||||
results = await self._manager.start_all()
|
||||
|
||||
self._started = True
|
||||
|
||||
success = sum(1 for v in results.values() if v)
|
||||
failed = len(results) - success
|
||||
|
||||
logger.info(
|
||||
"Process orchestration started",
|
||||
success=success,
|
||||
failed=failed,
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._started:
|
||||
return
|
||||
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
|
||||
if self._manager:
|
||||
await self._manager.stop()
|
||||
|
||||
self._started = False
|
||||
logger.info("Process orchestration stopped")
|
||||
|
||||
def cleanup(self) -> None:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(self.stop())
|
||||
except RuntimeError:
|
||||
asyncio.run(self.stop())
|
||||
|
||||
async def process_request(self, request: Request) -> Optional[Response]:
|
||||
if not self._started or not self._manager:
|
||||
logger.debug(
|
||||
"Process orchestration not ready",
|
||||
started=self._started,
|
||||
has_manager=self._manager is not None,
|
||||
)
|
||||
return None
|
||||
|
||||
mount = self._get_mount(request.url.path)
|
||||
if not mount:
|
||||
logger.debug(
|
||||
"No mount found for path",
|
||||
path=request.url.path,
|
||||
available_mounts=list(self._mounts.keys()),
|
||||
)
|
||||
return None
|
||||
|
||||
upstream_url = self._manager.get_upstream_url(mount.process_name)
|
||||
if not upstream_url:
|
||||
logger.warning(
|
||||
f"Process '{mount.process_name}' not running",
|
||||
path=request.url.path,
|
||||
)
|
||||
return Response("Service Unavailable", status_code=503)
|
||||
|
||||
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())[:8])
|
||||
|
||||
start_time = time.perf_counter()
|
||||
response = await self._proxy_request(request, upstream_url, mount, request_id)
|
||||
latency_ms = (time.perf_counter() - start_time) * 1000
|
||||
|
||||
if self._log_proxy_requests:
|
||||
logger.info(
|
||||
"Proxy request completed",
|
||||
request_id=request_id,
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
process=mount.process_name,
|
||||
upstream=upstream_url,
|
||||
status=response.status_code,
|
||||
latency_ms=round(latency_ms, 2),
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def _get_mount(self, path: str) -> Optional["MountConfig"]:
|
||||
for mount_path in sorted(self._mounts.keys(), key=len, reverse=True):
|
||||
if mount_path == "":
|
||||
return self._mounts[mount_path]
|
||||
if path == mount_path or path.startswith(f"{mount_path}/"):
|
||||
return self._mounts[mount_path]
|
||||
return None
|
||||
|
||||
async def _proxy_request(
|
||||
self,
|
||||
request: Request,
|
||||
upstream_url: str,
|
||||
mount: "MountConfig",
|
||||
request_id: str = "",
|
||||
) -> Response:
|
||||
path = request.url.path
|
||||
if mount.strip_path and mount.path:
|
||||
path = path[len(mount.path) :] or "/"
|
||||
|
||||
target_url = f"{upstream_url}{path}"
|
||||
if request.url.query:
|
||||
target_url += f"?{request.url.query}"
|
||||
|
||||
headers = dict(request.headers)
|
||||
headers.pop("host", None)
|
||||
headers["X-Forwarded-For"] = request.client.host if request.client else "unknown"
|
||||
headers["X-Forwarded-Proto"] = request.url.scheme
|
||||
headers["X-Forwarded-Host"] = request.headers.get("host", "")
|
||||
if request_id:
|
||||
headers["X-Request-ID"] = request_id
|
||||
|
||||
try:
|
||||
if not self._http_client:
|
||||
return Response("Service Unavailable", status_code=503)
|
||||
|
||||
body = await request.body()
|
||||
|
||||
response = await self._http_client.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
headers=headers,
|
||||
content=body,
|
||||
)
|
||||
|
||||
response_headers = dict(response.headers)
|
||||
for header in ["transfer-encoding", "connection", "keep-alive"]:
|
||||
response_headers.pop(header, None)
|
||||
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error(f"Proxy timeout to {upstream_url}")
|
||||
return Response("Gateway Timeout", status_code=504)
|
||||
except httpx.ConnectError as e:
|
||||
logger.error(f"Proxy connection error to {upstream_url}: {e}")
|
||||
return Response("Bad Gateway", status_code=502)
|
||||
except Exception as e:
|
||||
logger.error(f"Proxy error to {upstream_url}: {e}")
|
||||
return Response("Internal Server Error", status_code=500)
|
||||
|
||||
async def process_response(
|
||||
self,
|
||||
request: Request,
|
||||
response: Response,
|
||||
) -> Response:
|
||||
return response
|
||||
|
||||
def get_metrics(self) -> Dict[str, Any]:
|
||||
metrics = {
|
||||
"process_orchestration": {
|
||||
"enabled": self._started,
|
||||
"mounts": len(self._mounts),
|
||||
}
|
||||
}
|
||||
|
||||
if self._manager:
|
||||
metrics["process_orchestration"].update(self._manager.get_metrics())
|
||||
|
||||
return metrics
|
||||
|
||||
async def get_process_status(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
if not self._manager:
|
||||
return None
|
||||
info = self._manager.get_process(name)
|
||||
return info.to_dict() if info else None
|
||||
|
||||
async def get_all_status(self) -> Dict[str, Any]:
|
||||
if not self._manager:
|
||||
return {}
|
||||
return {name: info.to_dict() for name, info in self._manager.get_all_processes().items()}
|
||||
|
||||
async def restart_process(self, name: str) -> bool:
|
||||
if not self._manager:
|
||||
return False
|
||||
return await self._manager.restart_process(name)
|
||||
|
||||
async def scale_process(self, name: str, workers: int) -> bool:
|
||||
if not self._manager:
|
||||
return False
|
||||
|
||||
info = self._manager.get_process(name)
|
||||
if not info:
|
||||
return False
|
||||
|
||||
info.config.workers = workers
|
||||
return await self._manager.restart_process(name)
|
||||
|
||||
|
||||
class MountConfig:
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
process_name: str,
|
||||
strip_path: bool = True,
|
||||
):
|
||||
self.path = path
|
||||
self.process_name = process_name
|
||||
self.strip_path = strip_path
|
||||
|
||||
|
||||
async def setup_process_orchestration(config: Dict[str, Any]) -> ProcessOrchestrationExtension:
|
||||
ext = ProcessOrchestrationExtension(config)
|
||||
await ext.setup(config)
|
||||
await ext.start()
|
||||
return ext
|
||||
|
||||
|
||||
async def shutdown_process_orchestration(ext: ProcessOrchestrationExtension) -> None:
|
||||
await ext.stop()
|
||||
@@ -0,0 +1,553 @@
|
||||
"""Process Manager Module
|
||||
|
||||
Orchestrates ASGI/WSGI applications as separate processes
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .logging_utils import get_logger
|
||||
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ProcessState(Enum):
|
||||
PENDING = "pending"
|
||||
STARTING = "starting"
|
||||
RUNNING = "running"
|
||||
STOPPING = "stopping"
|
||||
STOPPED = "stopped"
|
||||
FAILED = "failed"
|
||||
RESTARTING = "restarting"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessConfig:
|
||||
name: str
|
||||
app_path: str
|
||||
app_type: str = "asgi" # asgi, wsgi
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 0 # 0 = auto-assign
|
||||
workers: int = 1
|
||||
module_path: Optional[str] = None
|
||||
factory: bool = False
|
||||
factory_args: Optional[Dict[str, Any]] = None
|
||||
env: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
health_check_enabled: bool = True
|
||||
health_check_path: str = "/health"
|
||||
health_check_interval: float = 10.0
|
||||
health_check_timeout: float = 5.0
|
||||
health_check_retries: int = 3
|
||||
|
||||
max_memory_mb: Optional[int] = None
|
||||
max_restart_count: int = 5
|
||||
restart_delay: float = 1.0 # seconds
|
||||
|
||||
shutdown_timeout: float = 30.0 # seconds
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessInfo:
|
||||
config: ProcessConfig
|
||||
state: ProcessState = ProcessState.PENDING
|
||||
pid: Optional[int] = None
|
||||
port: int = 0
|
||||
start_time: Optional[float] = None
|
||||
restart_count: int = 0
|
||||
last_health_check: Optional[float] = None
|
||||
health_check_failures: int = 0
|
||||
process: Optional[subprocess.Popen] = None
|
||||
|
||||
@property
|
||||
def uptime(self) -> float:
|
||||
if self.start_time is None:
|
||||
return 0.0
|
||||
return time.time() - self.start_time
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self.state == ProcessState.RUNNING and self.process is not None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": self.config.name,
|
||||
"state": self.state.value,
|
||||
"pid": self.pid,
|
||||
"port": self.port,
|
||||
"uptime": round(self.uptime, 2),
|
||||
"restart_count": self.restart_count,
|
||||
"health_check_failures": self.health_check_failures,
|
||||
"workers": self.config.workers,
|
||||
}
|
||||
|
||||
|
||||
class PortAllocator:
|
||||
def __init__(self, start_port: int = 9000, end_port: int = 9999):
|
||||
self.start_port = start_port
|
||||
self.end_port = end_port
|
||||
self._allocated: set[int] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def allocate(self) -> int:
|
||||
async with self._lock:
|
||||
for port in range(self.start_port, self.end_port + 1):
|
||||
if port in self._allocated:
|
||||
continue
|
||||
if self._is_port_available(port):
|
||||
self._allocated.add(port)
|
||||
return port
|
||||
raise RuntimeError(f"No available ports in range {self.start_port}-{self.end_port}")
|
||||
|
||||
async def release(self, port: int) -> None:
|
||||
async with self._lock:
|
||||
self._allocated.discard(port)
|
||||
|
||||
def _is_port_available(self, port: int) -> bool:
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind(("127.0.0.1", port))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
class ProcessManager:
|
||||
def __init__(
|
||||
self,
|
||||
port_range: tuple[int, int] = (9000, 9999),
|
||||
health_check_enabled: bool = True,
|
||||
):
|
||||
self._processes: Dict[str, ProcessInfo] = {}
|
||||
self._port_allocator = PortAllocator(*port_range)
|
||||
self._health_check_enabled = health_check_enabled
|
||||
self._health_check_task: Optional[asyncio.Task] = None
|
||||
self._shutdown_event = asyncio.Event()
|
||||
self._started = False
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._started:
|
||||
return
|
||||
|
||||
self._started = True
|
||||
self._shutdown_event.clear()
|
||||
|
||||
if self._health_check_enabled:
|
||||
self._health_check_task = asyncio.create_task(self._health_check_loop(), name="process_manager_health_check")
|
||||
|
||||
logger.info("Process manager started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._started:
|
||||
return
|
||||
|
||||
logger.info("Stopping process manager...")
|
||||
self._shutdown_event.set()
|
||||
|
||||
if self._health_check_task:
|
||||
self._health_check_task.cancel()
|
||||
try:
|
||||
await self._health_check_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
await self.stop_all()
|
||||
|
||||
self._started = False
|
||||
logger.info("Process manager stopped")
|
||||
|
||||
async def register(self, config: ProcessConfig) -> ProcessInfo:
|
||||
async with self._lock:
|
||||
if config.name in self._processes:
|
||||
raise ValueError(f"Process '{config.name}' already registered")
|
||||
|
||||
info = ProcessInfo(config=config)
|
||||
self._processes[config.name] = info
|
||||
|
||||
logger.info(f"Registered process '{config.name}'", app_path=config.app_path)
|
||||
return info
|
||||
|
||||
async def unregister(self, name: str) -> None:
|
||||
async with self._lock:
|
||||
if name not in self._processes:
|
||||
return
|
||||
|
||||
info = self._processes[name]
|
||||
if info.is_running:
|
||||
await self._stop_process(info)
|
||||
|
||||
if info.port:
|
||||
await self._port_allocator.release(info.port)
|
||||
|
||||
del self._processes[name]
|
||||
logger.info(f"Unregistered process '{name}'")
|
||||
|
||||
async def start_process(self, name: str) -> bool:
|
||||
info = self._processes.get(name)
|
||||
if not info:
|
||||
logger.error(f"Process '{name}' not found")
|
||||
return False
|
||||
|
||||
if info.is_running:
|
||||
logger.warning(f"Process '{name}' is already running")
|
||||
return True
|
||||
|
||||
return await self._start_process(info)
|
||||
|
||||
async def stop_process(self, name: str) -> bool:
|
||||
info = self._processes.get(name)
|
||||
if not info:
|
||||
logger.error(f"Process '{name}' not found")
|
||||
return False
|
||||
|
||||
return await self._stop_process(info)
|
||||
|
||||
async def restart_process(self, name: str) -> bool:
|
||||
info = self._processes.get(name)
|
||||
if not info:
|
||||
logger.error(f"Process '{name}' not found")
|
||||
return False
|
||||
|
||||
info.state = ProcessState.RESTARTING
|
||||
|
||||
if info.is_running:
|
||||
await self._stop_process(info)
|
||||
|
||||
await asyncio.sleep(info.config.restart_delay)
|
||||
return await self._start_process(info)
|
||||
|
||||
async def start_all(self) -> Dict[str, bool]:
|
||||
results = {}
|
||||
for name in self._processes:
|
||||
results[name] = await self.start_process(name)
|
||||
return results
|
||||
|
||||
async def stop_all(self) -> None:
|
||||
tasks = []
|
||||
for info in self._processes.values():
|
||||
if info.is_running:
|
||||
tasks.append(self._stop_process(info))
|
||||
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
def get_process(self, name: str) -> Optional[ProcessInfo]:
|
||||
return self._processes.get(name)
|
||||
|
||||
def get_all_processes(self) -> Dict[str, ProcessInfo]:
|
||||
return self._processes.copy()
|
||||
|
||||
def get_process_by_port(self, port: int) -> Optional[ProcessInfo]:
|
||||
for info in self._processes.values():
|
||||
if info.port == port:
|
||||
return info
|
||||
return None
|
||||
|
||||
def get_upstream_url(self, name: str) -> Optional[str]:
|
||||
info = self._processes.get(name)
|
||||
if not info or not info.is_running:
|
||||
return None
|
||||
return f"http://{info.config.host}:{info.port}"
|
||||
|
||||
async def _start_process(self, info: ProcessInfo) -> bool:
|
||||
config = info.config
|
||||
|
||||
try:
|
||||
info.state = ProcessState.STARTING
|
||||
|
||||
if info.port == 0:
|
||||
info.port = await self._port_allocator.allocate()
|
||||
|
||||
cmd = self._build_command(config, info.port)
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(config.env)
|
||||
|
||||
if config.module_path:
|
||||
python_path = env.get("PYTHONPATH", "")
|
||||
module_dir = str(Path(config.module_path).resolve())
|
||||
env["PYTHONPATH"] = f"{module_dir}:{python_path}" if python_path else module_dir
|
||||
|
||||
# For WSGI apps, pass configuration via environment variables
|
||||
if config.app_type == "wsgi":
|
||||
env["PYSERVE_WSGI_APP"] = config.app_path
|
||||
env["PYSERVE_WSGI_FACTORY"] = "1" if config.factory else "0"
|
||||
|
||||
logger.info(
|
||||
f"Starting process '{config.name}'",
|
||||
command=" ".join(cmd),
|
||||
port=info.port,
|
||||
)
|
||||
|
||||
info.process = subprocess.Popen(
|
||||
cmd,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
preexec_fn=os.setsid if hasattr(os, "setsid") else None,
|
||||
)
|
||||
|
||||
info.pid = info.process.pid
|
||||
info.start_time = time.time()
|
||||
|
||||
if not await self._wait_for_ready(info):
|
||||
raise RuntimeError(f"Process '{config.name}' failed to start")
|
||||
|
||||
info.state = ProcessState.RUNNING
|
||||
logger.info(
|
||||
f"Process '{config.name}' started successfully",
|
||||
pid=info.pid,
|
||||
port=info.port,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start process '{config.name}': {e}")
|
||||
info.state = ProcessState.FAILED
|
||||
if info.port:
|
||||
await self._port_allocator.release(info.port)
|
||||
info.port = 0
|
||||
return False
|
||||
|
||||
async def _stop_process(self, info: ProcessInfo) -> bool:
|
||||
if not info.process:
|
||||
info.state = ProcessState.STOPPED
|
||||
return True
|
||||
|
||||
config = info.config
|
||||
info.state = ProcessState.STOPPING
|
||||
|
||||
try:
|
||||
if hasattr(os, "killpg"):
|
||||
try:
|
||||
os.killpg(os.getpgid(info.process.pid), signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
else:
|
||||
info.process.terminate()
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.get_event_loop().run_in_executor(None, info.process.wait), timeout=config.shutdown_timeout)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"Process '{config.name}' did not stop gracefully, forcing kill")
|
||||
if hasattr(os, "killpg"):
|
||||
try:
|
||||
os.killpg(os.getpgid(info.process.pid), signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
else:
|
||||
info.process.kill()
|
||||
info.process.wait()
|
||||
|
||||
if info.port:
|
||||
await self._port_allocator.release(info.port)
|
||||
|
||||
info.state = ProcessState.STOPPED
|
||||
info.process = None
|
||||
info.pid = None
|
||||
|
||||
logger.info(f"Process '{config.name}' stopped")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping process '{config.name}': {e}")
|
||||
info.state = ProcessState.FAILED
|
||||
return False
|
||||
|
||||
async def _wait_for_ready(self, info: ProcessInfo, timeout: float = 30.0) -> bool:
|
||||
import httpx
|
||||
|
||||
start_time = time.time()
|
||||
url = f"http://{info.config.host}:{info.port}{info.config.health_check_path}"
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
if info.process and info.process.poll() is not None:
|
||||
stdout, stderr = info.process.communicate()
|
||||
logger.error(
|
||||
f"Process '{info.config.name}' exited during startup",
|
||||
returncode=info.process.returncode,
|
||||
stderr=stderr.decode() if stderr else "",
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code < 500:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
return False
|
||||
|
||||
async def _health_check_loop(self) -> None:
|
||||
while not self._shutdown_event.is_set():
|
||||
try:
|
||||
for info in list(self._processes.values()):
|
||||
if not info.is_running or not info.config.health_check_enabled:
|
||||
continue
|
||||
|
||||
await self._check_process_health(info)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._shutdown_event.wait(),
|
||||
timeout=(
|
||||
min(p.config.health_check_interval for p in self._processes.values() if p.config.health_check_enabled)
|
||||
if self._processes
|
||||
else 10.0
|
||||
),
|
||||
)
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in health check loop: {e}")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _check_process_health(self, info: ProcessInfo) -> bool:
|
||||
import httpx
|
||||
|
||||
config = info.config
|
||||
url = f"http://{config.host}:{info.port}{config.health_check_path}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=config.health_check_timeout) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code < 500:
|
||||
info.health_check_failures = 0
|
||||
info.last_health_check = time.time()
|
||||
return True
|
||||
else:
|
||||
raise Exception(f"Health check returned status {resp.status_code}")
|
||||
|
||||
except Exception as e:
|
||||
info.health_check_failures += 1
|
||||
logger.warning(
|
||||
f"Health check failed for '{config.name}'",
|
||||
failures=info.health_check_failures,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
if info.health_check_failures >= config.health_check_retries:
|
||||
logger.error(f"Process '{config.name}' is unhealthy, restarting...")
|
||||
await self._handle_unhealthy_process(info)
|
||||
|
||||
return False
|
||||
|
||||
async def _handle_unhealthy_process(self, info: ProcessInfo) -> None:
|
||||
config = info.config
|
||||
|
||||
if info.restart_count >= config.max_restart_count:
|
||||
logger.error(f"Process '{config.name}' exceeded max restart count, marking as failed")
|
||||
info.state = ProcessState.FAILED
|
||||
return
|
||||
|
||||
info.restart_count += 1
|
||||
info.health_check_failures = 0
|
||||
|
||||
delay = config.restart_delay * (2 ** (info.restart_count - 1))
|
||||
delay = min(delay, 60.0)
|
||||
|
||||
logger.info(
|
||||
f"Restarting process '{config.name}'",
|
||||
restart_count=info.restart_count,
|
||||
delay=delay,
|
||||
)
|
||||
|
||||
await self._stop_process(info)
|
||||
await asyncio.sleep(delay)
|
||||
await self._start_process(info)
|
||||
|
||||
def _build_command(self, config: ProcessConfig, port: int) -> List[str]:
|
||||
if config.app_type == "wsgi":
|
||||
wrapper_app = self._create_wsgi_wrapper_path(config)
|
||||
app_path = wrapper_app
|
||||
else:
|
||||
app_path = config.app_path
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
app_path,
|
||||
"--host",
|
||||
config.host,
|
||||
"--port",
|
||||
str(port),
|
||||
"--workers",
|
||||
str(config.workers),
|
||||
"--log-level",
|
||||
"warning",
|
||||
"--no-access-log",
|
||||
]
|
||||
|
||||
if config.factory and config.app_type != "wsgi":
|
||||
cmd.append("--factory")
|
||||
|
||||
return cmd
|
||||
|
||||
def _create_wsgi_wrapper_path(self, config: ProcessConfig) -> str:
|
||||
"""
|
||||
Since uvicorn can't directly run WSGI apps, we create a wrapper
|
||||
that imports the WSGI app and wraps it with a2wsgi.
|
||||
"""
|
||||
# For WSGI apps, we'll use a special wrapper module
|
||||
# The wrapper is: pyserve._wsgi_wrapper:create_app
|
||||
# It will be called with app_path as environment variable
|
||||
return "pyserve._wsgi_wrapper:app"
|
||||
|
||||
def get_metrics(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"managed_processes": len(self._processes),
|
||||
"running_processes": sum(1 for p in self._processes.values() if p.is_running),
|
||||
"processes": {name: info.to_dict() for name, info in self._processes.items()},
|
||||
}
|
||||
|
||||
|
||||
_process_manager: Optional[ProcessManager] = None
|
||||
|
||||
|
||||
def get_process_manager() -> ProcessManager:
|
||||
global _process_manager
|
||||
if _process_manager is None:
|
||||
_process_manager = ProcessManager()
|
||||
return _process_manager
|
||||
|
||||
|
||||
async def init_process_manager(
|
||||
port_range: tuple[int, int] = (9000, 9999),
|
||||
health_check_enabled: bool = True,
|
||||
) -> ProcessManager:
|
||||
global _process_manager
|
||||
_process_manager = ProcessManager(
|
||||
port_range=port_range,
|
||||
health_check_enabled=health_check_enabled,
|
||||
)
|
||||
await _process_manager.start()
|
||||
return _process_manager
|
||||
|
||||
|
||||
async def shutdown_process_manager() -> None:
|
||||
global _process_manager
|
||||
if _process_manager:
|
||||
await _process_manager.stop()
|
||||
_process_manager = None
|
||||
+16
-10
@@ -1,11 +1,13 @@
|
||||
import re
|
||||
import mimetypes
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Pattern
|
||||
from typing import Any, Dict, Optional, Pattern
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response, FileResponse, PlainTextResponse
|
||||
from starlette.responses import FileResponse, PlainTextResponse, Response
|
||||
|
||||
from .logging_utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -100,8 +102,7 @@ class RequestHandler:
|
||||
text = ""
|
||||
|
||||
content_type = config.get("content_type", "text/plain")
|
||||
return PlainTextResponse(text, status_code=status_code,
|
||||
media_type=content_type)
|
||||
return PlainTextResponse(text, status_code=status_code, media_type=content_type)
|
||||
|
||||
if "proxy_pass" in config:
|
||||
return await self._handle_proxy(request, config, route_match.params)
|
||||
@@ -171,8 +172,7 @@ class RequestHandler:
|
||||
|
||||
return PlainTextResponse("404 Not Found", status_code=404)
|
||||
|
||||
async def _handle_proxy(self, request: Request, config: Dict[str, Any],
|
||||
params: Dict[str, str]) -> Response:
|
||||
async def _handle_proxy(self, request: Request, config: Dict[str, Any], params: Dict[str, str]) -> Response:
|
||||
proxy_url = config["proxy_pass"]
|
||||
|
||||
for key, value in params.items():
|
||||
@@ -197,9 +197,15 @@ class RequestHandler:
|
||||
proxy_headers = dict(request.headers)
|
||||
|
||||
hop_by_hop_headers = [
|
||||
"connection", "keep-alive", "proxy-authenticate",
|
||||
"proxy-authorization", "te", "trailers", "transfer-encoding",
|
||||
"upgrade", "host"
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"host",
|
||||
]
|
||||
for header in hop_by_hop_headers:
|
||||
proxy_headers.pop(header, None)
|
||||
|
||||
+108
-54
@@ -1,18 +1,19 @@
|
||||
import ssl
|
||||
import uvicorn
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import uvicorn
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response, PlainTextResponse
|
||||
from starlette.responses import PlainTextResponse, Response
|
||||
from starlette.routing import Route
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from .config import Config
|
||||
from .extensions import ExtensionManager
|
||||
from .logging_utils import get_logger
|
||||
from . import __version__
|
||||
from .config import Config
|
||||
from .extensions import ASGIExtension, ExtensionManager
|
||||
from .logging_utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -21,7 +22,7 @@ class PyServeMiddleware:
|
||||
def __init__(self, app: ASGIApp, extension_manager: ExtensionManager):
|
||||
self.app = app
|
||||
self.extension_manager = extension_manager
|
||||
self.access_logger = get_logger('pyserve.access')
|
||||
self.access_logger = get_logger("pyserve.access")
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
@@ -30,6 +31,11 @@ class PyServeMiddleware:
|
||||
|
||||
start_time = time.time()
|
||||
request = Request(scope, receive)
|
||||
|
||||
asgi_handled = await self._try_asgi_mount(scope, receive, send, request, start_time)
|
||||
if asgi_handled:
|
||||
return
|
||||
|
||||
response = await self.extension_manager.process_request(request)
|
||||
|
||||
if response is None:
|
||||
@@ -39,6 +45,55 @@ class PyServeMiddleware:
|
||||
response = await self.extension_manager.process_response(request, response)
|
||||
response.headers["Server"] = f"pyserve/{__version__}"
|
||||
|
||||
self._log_access(request, response, start_time)
|
||||
|
||||
await response(scope, receive, send)
|
||||
|
||||
async def _try_asgi_mount(self, scope: Scope, receive: Receive, send: Send, request: Request, start_time: float) -> bool:
|
||||
for extension in self.extension_manager.extensions:
|
||||
if isinstance(extension, ASGIExtension):
|
||||
mount = extension.get_asgi_handler(request)
|
||||
if mount is not None:
|
||||
modified_scope = dict(scope)
|
||||
if mount.strip_path:
|
||||
modified_scope["path"] = mount.get_modified_path(request.url.path)
|
||||
modified_scope["root_path"] = scope.get("root_path", "") + mount.path
|
||||
|
||||
logger.debug(f"Routing to ASGI mount '{mount.name}': " f"{request.url.path} -> {modified_scope['path']}")
|
||||
|
||||
try:
|
||||
response_started = False
|
||||
status_code = 0
|
||||
|
||||
async def send_wrapper(message: Dict[str, Any]) -> None:
|
||||
nonlocal response_started, status_code
|
||||
if message["type"] == "http.response.start":
|
||||
response_started = True
|
||||
status_code = message.get("status", 0)
|
||||
await send(message)
|
||||
|
||||
await mount.app(modified_scope, receive, send_wrapper)
|
||||
|
||||
process_time = round((time.time() - start_time) * 1000, 2)
|
||||
self.access_logger.info(
|
||||
"ASGI request",
|
||||
client_ip=request.client.host if request.client else "unknown",
|
||||
method=request.method,
|
||||
path=str(request.url.path),
|
||||
mount=mount.name,
|
||||
status_code=status_code,
|
||||
process_time_ms=process_time,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error in ASGI mount '{mount.name}': {e}")
|
||||
error_response = PlainTextResponse("500 Internal Server Error", status_code=500)
|
||||
await error_response(scope, receive, send)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _log_access(self, request: Request, response: Response, start_time: float) -> None:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
method = request.method
|
||||
path = str(request.url.path)
|
||||
@@ -55,17 +110,16 @@ class PyServeMiddleware:
|
||||
path=path,
|
||||
status_code=status_code,
|
||||
process_time_ms=process_time,
|
||||
user_agent=request.headers.get("user-agent", "")
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
)
|
||||
|
||||
await response(scope, receive, send)
|
||||
|
||||
|
||||
class PyServeServer:
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.extension_manager = ExtensionManager()
|
||||
self.app: Optional[Starlette] = None
|
||||
self._async_extensions_loaded = False
|
||||
self._setup_logging()
|
||||
self._load_extensions()
|
||||
self._create_app()
|
||||
@@ -80,30 +134,39 @@ class PyServeServer:
|
||||
if ext_config.type == "routing":
|
||||
config.setdefault("default_proxy_timeout", self.config.server.proxy_timeout)
|
||||
|
||||
self.extension_manager.load_extension(
|
||||
ext_config.type,
|
||||
config
|
||||
)
|
||||
if ext_config.type == "process_orchestration":
|
||||
continue
|
||||
|
||||
self.extension_manager.load_extension(ext_config.type, config)
|
||||
|
||||
async def _load_async_extensions(self) -> None:
|
||||
if self._async_extensions_loaded:
|
||||
return
|
||||
|
||||
for ext_config in self.config.extensions:
|
||||
if ext_config.type == "process_orchestration":
|
||||
config = ext_config.config.copy()
|
||||
await self.extension_manager.load_extension_async(ext_config.type, config)
|
||||
|
||||
self._async_extensions_loaded = True
|
||||
|
||||
def _create_app(self) -> None:
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: Starlette) -> AsyncIterator[None]:
|
||||
await self._load_async_extensions()
|
||||
logger.info("Async extensions loaded")
|
||||
yield
|
||||
|
||||
routes = [
|
||||
Route("/health", self._health_check, methods=["GET"]),
|
||||
Route("/metrics", self._metrics, methods=["GET"]),
|
||||
Route(
|
||||
"/{path:path}",
|
||||
self._catch_all,
|
||||
methods=[
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"DELETE",
|
||||
"PATCH",
|
||||
"OPTIONS"
|
||||
]
|
||||
),
|
||||
Route("/{path:path}", self._catch_all, methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]),
|
||||
]
|
||||
|
||||
self.app = Starlette(routes=routes)
|
||||
self.app = Starlette(routes=routes, lifespan=lifespan)
|
||||
self.app.add_middleware(PyServeMiddleware, extension_manager=self.extension_manager)
|
||||
|
||||
async def _health_check(self, request: Request) -> Response:
|
||||
@@ -113,19 +176,16 @@ class PyServeServer:
|
||||
metrics = {}
|
||||
|
||||
for extension in self.extension_manager.extensions:
|
||||
if hasattr(extension, 'get_metrics'):
|
||||
if hasattr(extension, "get_metrics"):
|
||||
try:
|
||||
ext_metrics = getattr(extension, 'get_metrics')()
|
||||
ext_metrics = getattr(extension, "get_metrics")()
|
||||
metrics.update(ext_metrics)
|
||||
except Exception as e:
|
||||
logger.error("Error getting metrics from extension",
|
||||
extension=type(extension).__name__, error=str(e))
|
||||
logger.error("Error getting metrics from extension", extension=type(extension).__name__, error=str(e))
|
||||
|
||||
import json
|
||||
return Response(
|
||||
json.dumps(metrics, ensure_ascii=False, indent=2),
|
||||
media_type="application/json"
|
||||
)
|
||||
|
||||
return Response(json.dumps(metrics, ensure_ascii=False, indent=2), media_type="application/json")
|
||||
|
||||
async def _catch_all(self, request: Request) -> Response:
|
||||
return PlainTextResponse("404 Not Found", status_code=404)
|
||||
@@ -144,10 +204,7 @@ class PyServeServer:
|
||||
|
||||
try:
|
||||
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||||
context.load_cert_chain(
|
||||
self.config.ssl.cert_file,
|
||||
self.config.ssl.key_file
|
||||
)
|
||||
context.load_cert_chain(self.config.ssl.cert_file, self.config.ssl.key_file)
|
||||
logger.info("SSL context created successfully")
|
||||
return context
|
||||
except Exception as e:
|
||||
@@ -172,20 +229,17 @@ class PyServeServer:
|
||||
}
|
||||
|
||||
if ssl_context:
|
||||
uvicorn_config.update({
|
||||
"ssl_keyfile": self.config.ssl.key_file,
|
||||
"ssl_certfile": self.config.ssl.cert_file,
|
||||
})
|
||||
uvicorn_config.update(
|
||||
{
|
||||
"ssl_keyfile": self.config.ssl.key_file,
|
||||
"ssl_certfile": self.config.ssl.cert_file,
|
||||
}
|
||||
)
|
||||
protocol = "https"
|
||||
else:
|
||||
protocol = "http"
|
||||
|
||||
logger.info(
|
||||
"Starting PyServe server",
|
||||
protocol=protocol,
|
||||
host=self.config.server.host,
|
||||
port=self.config.server.port
|
||||
)
|
||||
logger.info("Starting PyServe server", protocol=protocol, host=self.config.server.host, port=self.config.server.port)
|
||||
|
||||
try:
|
||||
assert self.app is not None, "App not initialized"
|
||||
@@ -241,6 +295,7 @@ class PyServeServer:
|
||||
self.extension_manager.cleanup()
|
||||
|
||||
from .logging_utils import shutdown_logging
|
||||
|
||||
shutdown_logging()
|
||||
|
||||
logger.info("Server stopped")
|
||||
@@ -252,13 +307,12 @@ class PyServeServer:
|
||||
metrics = {"server_status": "running"}
|
||||
|
||||
for extension in self.extension_manager.extensions:
|
||||
if hasattr(extension, 'get_metrics'):
|
||||
if hasattr(extension, "get_metrics"):
|
||||
try:
|
||||
ext_metrics = getattr(extension, 'get_metrics')()
|
||||
ext_metrics = getattr(extension, "get_metrics")()
|
||||
metrics.update(ext_metrics)
|
||||
except Exception as e:
|
||||
logger.error("Error getting metrics from extension",
|
||||
extension=type(extension).__name__, error=str(e))
|
||||
logger.error("Error getting metrics from extension", extension=type(extension).__name__, error=str(e))
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
Build script for Cython extensions.
|
||||
|
||||
Usage:
|
||||
python scripts/build_cython.py build_ext --inplace
|
||||
|
||||
Or via make:
|
||||
make build-cython
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def build_extensions():
|
||||
try:
|
||||
from Cython.Build import cythonize
|
||||
except ImportError:
|
||||
print("Cython not installed. Skipping Cython build.")
|
||||
print("Install with: pip install cython")
|
||||
return False
|
||||
|
||||
try:
|
||||
from setuptools import Extension
|
||||
from setuptools.dist import Distribution
|
||||
from setuptools.command.build_ext import build_ext
|
||||
except ImportError:
|
||||
print("setuptools not installed. Skipping Cython build.")
|
||||
print("Install with: pip install setuptools")
|
||||
return False
|
||||
|
||||
extensions = [
|
||||
Extension(
|
||||
"pyserve._path_matcher",
|
||||
sources=["pyserve/_path_matcher.pyx"],
|
||||
extra_compile_args=["-O3", "-ffast-math"],
|
||||
define_macros=[("NPY_NO_DEPRECATED_API", "NPY_1_7_API_VERSION")],
|
||||
),
|
||||
]
|
||||
|
||||
ext_modules = cythonize(
|
||||
extensions,
|
||||
compiler_directives={
|
||||
"language_level": "3",
|
||||
"boundscheck": False,
|
||||
"wraparound": False,
|
||||
"cdivision": True,
|
||||
"embedsignature": True,
|
||||
},
|
||||
annotate=True,
|
||||
)
|
||||
|
||||
dist = Distribution({"ext_modules": ext_modules})
|
||||
dist.package_dir = {"": "."}
|
||||
|
||||
cmd = build_ext(dist)
|
||||
cmd.ensure_finalized()
|
||||
cmd.inplace = True
|
||||
cmd.run()
|
||||
|
||||
print("\nCython extensions built successfully!")
|
||||
print(" - pyserve/_path_matcher" + (".pyd" if sys.platform == "win32" else ".so"))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
project_root = Path(__file__).parent.parent
|
||||
os.chdir(project_root)
|
||||
|
||||
success = build_extensions()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,897 @@
|
||||
"""
|
||||
Integration tests for ASGI mount functionality.
|
||||
|
||||
These tests start PyServe with mounted ASGI applications and verify
|
||||
that requests are correctly routed to the mounted apps.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
import httpx
|
||||
import socket
|
||||
from typing import Dict, Any
|
||||
|
||||
import uvicorn
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, PlainTextResponse, Response
|
||||
from starlette.routing import Route
|
||||
|
||||
from pyserve.config import Config, ServerConfig, HttpConfig, LoggingConfig, ExtensionConfig
|
||||
from pyserve.server import PyServeServer
|
||||
from pyserve.asgi_mount import (
|
||||
ASGIAppLoader,
|
||||
MountedApp,
|
||||
ASGIMountManager,
|
||||
)
|
||||
|
||||
|
||||
def get_free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('', 0))
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
# ============== Test ASGI Applications ==============
|
||||
|
||||
def create_api_v1_app() -> Starlette:
|
||||
"""Create a test API v1 application."""
|
||||
|
||||
async def root(request: Request) -> JSONResponse:
|
||||
return JSONResponse({
|
||||
"app": "api-v1",
|
||||
"message": "Welcome to API v1",
|
||||
"path": request.url.path,
|
||||
"root_path": request.scope.get("root_path", ""),
|
||||
})
|
||||
|
||||
async def health(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"status": "healthy", "app": "api-v1"})
|
||||
|
||||
async def users_list(request: Request) -> JSONResponse:
|
||||
return JSONResponse({
|
||||
"users": [
|
||||
{"id": 1, "name": "Alice"},
|
||||
{"id": 2, "name": "Bob"},
|
||||
],
|
||||
"app": "api-v1",
|
||||
})
|
||||
|
||||
async def user_detail(request: Request) -> JSONResponse:
|
||||
user_id = request.path_params.get("user_id")
|
||||
return JSONResponse({
|
||||
"user": {"id": user_id, "name": f"User {user_id}"},
|
||||
"app": "api-v1",
|
||||
})
|
||||
|
||||
async def create_user(request: Request) -> JSONResponse:
|
||||
body = await request.json()
|
||||
return JSONResponse({
|
||||
"created": body,
|
||||
"app": "api-v1",
|
||||
}, status_code=201)
|
||||
|
||||
async def echo(request: Request) -> Response:
|
||||
body = await request.body()
|
||||
return Response(
|
||||
content=body,
|
||||
media_type=request.headers.get("content-type", "text/plain"),
|
||||
)
|
||||
|
||||
routes = [
|
||||
Route("/", root, methods=["GET"]),
|
||||
Route("/health", health, methods=["GET"]),
|
||||
Route("/users", users_list, methods=["GET"]),
|
||||
Route("/users", create_user, methods=["POST"]),
|
||||
Route("/users/{user_id:int}", user_detail, methods=["GET"]),
|
||||
Route("/echo", echo, methods=["POST"]),
|
||||
]
|
||||
|
||||
return Starlette(routes=routes)
|
||||
|
||||
|
||||
def create_api_v2_app() -> Starlette:
|
||||
"""Create a test API v2 application with different responses."""
|
||||
|
||||
async def root(request: Request) -> JSONResponse:
|
||||
return JSONResponse({
|
||||
"app": "api-v2",
|
||||
"message": "Welcome to API v2 - Enhanced!",
|
||||
"version": "2.0.0",
|
||||
"path": request.url.path,
|
||||
})
|
||||
|
||||
async def health(request: Request) -> JSONResponse:
|
||||
return JSONResponse({
|
||||
"status": "healthy",
|
||||
"app": "api-v2",
|
||||
"version": "2.0.0",
|
||||
})
|
||||
|
||||
async def users_list(request: Request) -> JSONResponse:
|
||||
return JSONResponse({
|
||||
"data": {
|
||||
"users": [
|
||||
{"id": 1, "name": "Alice", "email": "alice@test.com"},
|
||||
{"id": 2, "name": "Bob", "email": "bob@test.com"},
|
||||
],
|
||||
},
|
||||
"meta": {"total": 2, "page": 1},
|
||||
"app": "api-v2",
|
||||
})
|
||||
|
||||
routes = [
|
||||
Route("/", root, methods=["GET"]),
|
||||
Route("/health", health, methods=["GET"]),
|
||||
Route("/users", users_list, methods=["GET"]),
|
||||
]
|
||||
|
||||
return Starlette(routes=routes)
|
||||
|
||||
|
||||
def create_admin_app() -> Starlette:
|
||||
"""Create a test admin application."""
|
||||
|
||||
async def dashboard(request: Request) -> JSONResponse:
|
||||
return JSONResponse({
|
||||
"app": "admin",
|
||||
"page": "dashboard",
|
||||
"path": request.url.path,
|
||||
})
|
||||
|
||||
async def settings(request: Request) -> JSONResponse:
|
||||
return JSONResponse({
|
||||
"app": "admin",
|
||||
"page": "settings",
|
||||
"config": {"debug": True, "theme": "dark"},
|
||||
})
|
||||
|
||||
async def stats(request: Request) -> JSONResponse:
|
||||
return JSONResponse({
|
||||
"app": "admin",
|
||||
"stats": {
|
||||
"requests": 1000,
|
||||
"errors": 5,
|
||||
"uptime": "24h",
|
||||
},
|
||||
})
|
||||
|
||||
routes = [
|
||||
Route("/", dashboard, methods=["GET"]),
|
||||
Route("/settings", settings, methods=["GET"]),
|
||||
Route("/stats", stats, methods=["GET"]),
|
||||
]
|
||||
|
||||
return Starlette(routes=routes)
|
||||
|
||||
|
||||
def create_websocket_test_app() -> Starlette:
|
||||
"""Create a test app that also has websocket endpoint info."""
|
||||
|
||||
async def root(request: Request) -> JSONResponse:
|
||||
return JSONResponse({
|
||||
"app": "ws-app",
|
||||
"message": "WebSocket test app",
|
||||
"ws_endpoint": "/ws",
|
||||
})
|
||||
|
||||
async def info(request: Request) -> JSONResponse:
|
||||
return JSONResponse({
|
||||
"app": "ws-app",
|
||||
"supports": ["http", "websocket"],
|
||||
})
|
||||
|
||||
routes = [
|
||||
Route("/", root, methods=["GET"]),
|
||||
Route("/info", info, methods=["GET"]),
|
||||
]
|
||||
|
||||
return Starlette(routes=routes)
|
||||
|
||||
|
||||
# ============== PyServe Test Server ==============
|
||||
|
||||
class PyServeTestServer:
|
||||
"""Test server wrapper for PyServe with ASGI mounts."""
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.server = PyServeServer(config)
|
||||
self._server_task = None
|
||||
|
||||
async def start(self) -> None:
|
||||
assert self.server.app is not None, "Server app not initialized"
|
||||
config = uvicorn.Config(
|
||||
app=self.server.app,
|
||||
host=self.config.server.host,
|
||||
port=self.config.server.port,
|
||||
log_level="critical",
|
||||
access_log=False,
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
self._server_task = asyncio.create_task(server.serve())
|
||||
|
||||
# Wait for server to be ready
|
||||
for _ in range(50):
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.get(f"http://127.0.0.1:{self.config.server.port}/health")
|
||||
return
|
||||
except httpx.ConnectError:
|
||||
await asyncio.sleep(0.1)
|
||||
raise RuntimeError(f"PyServe server failed to start on port {self.config.server.port}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._server_task:
|
||||
self._server_task.cancel()
|
||||
try:
|
||||
await self._server_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
# ============== Fixtures ==============
|
||||
|
||||
@pytest.fixture
|
||||
def pyserve_port() -> int:
|
||||
"""Get a free port for PyServe."""
|
||||
return get_free_port()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_v1_app() -> Starlette:
|
||||
"""Create API v1 test app."""
|
||||
return create_api_v1_app()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_v2_app() -> Starlette:
|
||||
"""Create API v2 test app."""
|
||||
return create_api_v2_app()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_app() -> Starlette:
|
||||
"""Create admin test app."""
|
||||
return create_admin_app()
|
||||
|
||||
|
||||
# ============== Unit Tests ==============
|
||||
|
||||
class TestMountedApp:
|
||||
"""Unit tests for MountedApp class."""
|
||||
|
||||
def test_matches_exact_path(self, api_v1_app):
|
||||
"""Test exact path matching."""
|
||||
mounted = MountedApp("/api", api_v1_app)
|
||||
assert mounted.matches("/api") is True
|
||||
assert mounted.matches("/api/users") is True
|
||||
assert mounted.matches("/api/users/123") is True
|
||||
assert mounted.matches("/other") is False
|
||||
assert mounted.matches("/apiv2") is False
|
||||
|
||||
def test_matches_empty_path(self, api_v1_app):
|
||||
"""Test root mount matching."""
|
||||
mounted = MountedApp("", api_v1_app)
|
||||
assert mounted.matches("/") is True
|
||||
assert mounted.matches("/anything") is True
|
||||
assert mounted.matches("/nested/path") is True
|
||||
|
||||
def test_get_modified_path_with_strip(self, api_v1_app):
|
||||
"""Test path modification with strip_path=True."""
|
||||
mounted = MountedApp("/api/v1", api_v1_app, strip_path=True)
|
||||
assert mounted.get_modified_path("/api/v1") == "/"
|
||||
assert mounted.get_modified_path("/api/v1/users") == "/users"
|
||||
assert mounted.get_modified_path("/api/v1/users/123") == "/users/123"
|
||||
|
||||
def test_get_modified_path_without_strip(self, api_v1_app):
|
||||
"""Test path modification with strip_path=False."""
|
||||
mounted = MountedApp("/api/v1", api_v1_app, strip_path=False)
|
||||
assert mounted.get_modified_path("/api/v1/users") == "/api/v1/users"
|
||||
|
||||
|
||||
class TestASGIMountManager:
|
||||
"""Unit tests for ASGIMountManager class."""
|
||||
|
||||
def test_mount_direct_app(self, api_v1_app):
|
||||
"""Test mounting a direct ASGI app."""
|
||||
manager = ASGIMountManager()
|
||||
result = manager.mount(
|
||||
path="/api",
|
||||
app=api_v1_app,
|
||||
name="api-v1"
|
||||
)
|
||||
assert result is True
|
||||
assert len(manager.mounts) == 1
|
||||
assert manager.mounts[0].name == "api-v1"
|
||||
assert manager.mounts[0].path == "/api"
|
||||
|
||||
def test_mount_requires_app_or_path(self):
|
||||
"""Test that mount requires either app or app_path."""
|
||||
manager = ASGIMountManager()
|
||||
result = manager.mount(path="/test")
|
||||
assert result is False
|
||||
assert len(manager.mounts) == 0
|
||||
|
||||
def test_mount_ordering_by_path_length(self, api_v1_app, api_v2_app, admin_app):
|
||||
"""Test that mounts are ordered by path length (longest first)."""
|
||||
manager = ASGIMountManager()
|
||||
manager.mount(path="/api", app=api_v1_app, name="short")
|
||||
manager.mount(path="/api/v1", app=api_v2_app, name="medium")
|
||||
manager.mount(path="/api/v1/admin", app=admin_app, name="long")
|
||||
|
||||
# Verify ordering
|
||||
assert manager.mounts[0].name == "long"
|
||||
assert manager.mounts[1].name == "medium"
|
||||
assert manager.mounts[2].name == "short"
|
||||
|
||||
# Should match the longest prefix first
|
||||
mount = manager.get_mount("/api/v1/admin/dashboard")
|
||||
assert mount is not None
|
||||
assert mount.name == "long"
|
||||
|
||||
mount = manager.get_mount("/api/v1/users")
|
||||
assert mount is not None
|
||||
assert mount.name == "medium"
|
||||
|
||||
mount = manager.get_mount("/api/other")
|
||||
assert mount is not None
|
||||
assert mount.name == "short"
|
||||
|
||||
def test_unmount(self, api_v1_app):
|
||||
"""Test unmounting an application."""
|
||||
manager = ASGIMountManager()
|
||||
manager.mount(path="/api", app=api_v1_app)
|
||||
assert len(manager.mounts) == 1
|
||||
|
||||
result = manager.unmount("/api")
|
||||
assert result is True
|
||||
assert len(manager.mounts) == 0
|
||||
|
||||
def test_list_mounts(self, api_v1_app, api_v2_app):
|
||||
"""Test listing all mounts."""
|
||||
manager = ASGIMountManager()
|
||||
manager.mount(path="/api/v1", app=api_v1_app, name="api-v1")
|
||||
manager.mount(path="/api/v2", app=api_v2_app, name="api-v2")
|
||||
|
||||
mounts_info = manager.list_mounts()
|
||||
assert len(mounts_info) == 2
|
||||
|
||||
names = {m["name"] for m in mounts_info}
|
||||
assert "api-v1" in names
|
||||
assert "api-v2" in names
|
||||
|
||||
|
||||
class TestASGIAppLoader:
|
||||
"""Unit tests for ASGIAppLoader class."""
|
||||
|
||||
def test_load_app_invalid_module(self):
|
||||
"""Test loading app with invalid module path."""
|
||||
loader = ASGIAppLoader()
|
||||
app = loader.load_app("nonexistent.module:app")
|
||||
assert app is None
|
||||
|
||||
def test_load_app_invalid_attribute(self):
|
||||
"""Test loading app with invalid attribute."""
|
||||
loader = ASGIAppLoader()
|
||||
app = loader.load_app("starlette.applications:nonexistent")
|
||||
assert app is None
|
||||
|
||||
def test_get_app_cached(self, api_v1_app):
|
||||
"""Test getting a cached app."""
|
||||
loader = ASGIAppLoader()
|
||||
loader._apps["test:app"] = api_v1_app
|
||||
|
||||
app = loader.get_app("test:app")
|
||||
assert app is api_v1_app
|
||||
|
||||
app = loader.get_app("nonexistent:app")
|
||||
assert app is None
|
||||
|
||||
|
||||
# ============== Integration Tests ==============
|
||||
|
||||
class TestASGIMountIntegration:
|
||||
"""Integration tests for ASGIMountManager request handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_request_to_mounted_app(self, api_v1_app):
|
||||
"""Test handling a request through mounted app."""
|
||||
manager = ASGIMountManager()
|
||||
manager.mount(path="/api", app=api_v1_app)
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"http_version": "1.1",
|
||||
"method": "GET",
|
||||
"path": "/api/health",
|
||||
"query_string": b"",
|
||||
"headers": [],
|
||||
"server": ("127.0.0.1", 8000),
|
||||
}
|
||||
|
||||
received_messages = []
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b""}
|
||||
|
||||
async def send(message):
|
||||
received_messages.append(message)
|
||||
|
||||
result = await manager.handle_request(scope, receive, send)
|
||||
|
||||
assert result is True
|
||||
assert len(received_messages) == 2 # response.start + response.body
|
||||
assert received_messages[0]["type"] == "http.response.start"
|
||||
assert received_messages[0]["status"] == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_request_no_match(self):
|
||||
"""Test handling request with no matching mount."""
|
||||
manager = ASGIMountManager()
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"path": "/unmatched",
|
||||
}
|
||||
|
||||
async def receive():
|
||||
return {}
|
||||
|
||||
async def send(message):
|
||||
pass
|
||||
|
||||
result = await manager.handle_request(scope, receive, send)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_non_http_request(self, api_v1_app):
|
||||
"""Test that non-HTTP requests are not handled."""
|
||||
manager = ASGIMountManager()
|
||||
manager.mount(path="/api", app=api_v1_app)
|
||||
|
||||
scope = {
|
||||
"type": "websocket",
|
||||
"path": "/api/ws",
|
||||
}
|
||||
|
||||
async def receive():
|
||||
return {}
|
||||
|
||||
async def send(message):
|
||||
pass
|
||||
|
||||
result = await manager.handle_request(scope, receive, send)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_path_stripping(self, api_v1_app):
|
||||
"""Test that mount path is correctly stripped from request."""
|
||||
manager = ASGIMountManager()
|
||||
manager.mount(path="/api/v1", app=api_v1_app, strip_path=True)
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0"},
|
||||
"http_version": "1.1",
|
||||
"method": "GET",
|
||||
"path": "/api/v1/users",
|
||||
"query_string": b"",
|
||||
"headers": [],
|
||||
"server": ("127.0.0.1", 8000),
|
||||
}
|
||||
|
||||
received_messages = []
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b""}
|
||||
|
||||
async def send(message):
|
||||
received_messages.append(message)
|
||||
|
||||
result = await manager.handle_request(scope, receive, send)
|
||||
|
||||
assert result is True
|
||||
assert received_messages[0]["status"] == 200
|
||||
|
||||
|
||||
# ============== Full Server Integration Tests ==============
|
||||
|
||||
class TestPyServeWithASGIMounts:
|
||||
"""Full integration tests with PyServe server and ASGI mounts."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_asgi_mount(self, pyserve_port, api_v1_app):
|
||||
"""Test basic ASGI app mounting through PyServe."""
|
||||
config = Config(
|
||||
server=ServerConfig(host="127.0.0.1", port=pyserve_port),
|
||||
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
|
||||
logging=LoggingConfig(level="ERROR", console_output=False),
|
||||
extensions=[],
|
||||
)
|
||||
|
||||
# Create server and manually add ASGI extension
|
||||
server = PyServeServer(config)
|
||||
|
||||
# Add ASGI mount directly via extension manager
|
||||
from pyserve.extensions import ASGIExtension
|
||||
asgi_ext = ASGIExtension({"mounts": []})
|
||||
asgi_ext.mount_manager.mount(path="/api", app=api_v1_app, name="api-v1")
|
||||
server.extension_manager.extensions.insert(0, asgi_ext)
|
||||
|
||||
test_server = PyServeTestServer.__new__(PyServeTestServer)
|
||||
test_server.config = config
|
||||
test_server.server = server
|
||||
test_server._server_task = None
|
||||
|
||||
try:
|
||||
await test_server.start()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Test root endpoint of mounted app
|
||||
response = await client.get(f"http://127.0.0.1:{pyserve_port}/api/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["app"] == "api-v1"
|
||||
assert data["message"] == "Welcome to API v1"
|
||||
|
||||
# Test health endpoint
|
||||
response = await client.get(f"http://127.0.0.1:{pyserve_port}/api/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
assert data["app"] == "api-v1"
|
||||
|
||||
# Test users list
|
||||
response = await client.get(f"http://127.0.0.1:{pyserve_port}/api/users")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "users" in data
|
||||
assert len(data["users"]) == 2
|
||||
|
||||
# Test user detail
|
||||
response = await client.get(f"http://127.0.0.1:{pyserve_port}/api/users/1")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user"]["id"] == 1
|
||||
|
||||
finally:
|
||||
await test_server.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_asgi_mounts(self, pyserve_port, api_v1_app, api_v2_app, admin_app):
|
||||
"""Test multiple ASGI apps mounted at different paths."""
|
||||
config = Config(
|
||||
server=ServerConfig(host="127.0.0.1", port=pyserve_port),
|
||||
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
|
||||
logging=LoggingConfig(level="ERROR", console_output=False),
|
||||
extensions=[],
|
||||
)
|
||||
|
||||
server = PyServeServer(config)
|
||||
|
||||
from pyserve.extensions import ASGIExtension
|
||||
asgi_ext = ASGIExtension({"mounts": []})
|
||||
asgi_ext.mount_manager.mount(path="/api/v1", app=api_v1_app, name="api-v1")
|
||||
asgi_ext.mount_manager.mount(path="/api/v2", app=api_v2_app, name="api-v2")
|
||||
asgi_ext.mount_manager.mount(path="/admin", app=admin_app, name="admin")
|
||||
server.extension_manager.extensions.insert(0, asgi_ext)
|
||||
|
||||
test_server = PyServeTestServer.__new__(PyServeTestServer)
|
||||
test_server.config = config
|
||||
test_server.server = server
|
||||
test_server._server_task = None
|
||||
|
||||
try:
|
||||
await test_server.start()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Test API v1
|
||||
response = await client.get(f"http://127.0.0.1:{pyserve_port}/api/v1/")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["app"] == "api-v1"
|
||||
|
||||
# Test API v2
|
||||
response = await client.get(f"http://127.0.0.1:{pyserve_port}/api/v2/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["app"] == "api-v2"
|
||||
assert data["version"] == "2.0.0"
|
||||
|
||||
# Test API v2 users (different response format)
|
||||
response = await client.get(f"http://127.0.0.1:{pyserve_port}/api/v2/users")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "data" in data
|
||||
assert "meta" in data
|
||||
|
||||
# Test Admin
|
||||
response = await client.get(f"http://127.0.0.1:{pyserve_port}/admin/")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["app"] == "admin"
|
||||
assert response.json()["page"] == "dashboard"
|
||||
|
||||
# Test Admin settings
|
||||
response = await client.get(f"http://127.0.0.1:{pyserve_port}/admin/settings")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["config"]["theme"] == "dark"
|
||||
|
||||
finally:
|
||||
await test_server.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asgi_mount_post_request(self, pyserve_port, api_v1_app):
|
||||
"""Test POST requests to mounted ASGI app."""
|
||||
config = Config(
|
||||
server=ServerConfig(host="127.0.0.1", port=pyserve_port),
|
||||
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
|
||||
logging=LoggingConfig(level="ERROR", console_output=False),
|
||||
extensions=[],
|
||||
)
|
||||
|
||||
server = PyServeServer(config)
|
||||
|
||||
from pyserve.extensions import ASGIExtension
|
||||
asgi_ext = ASGIExtension({"mounts": []})
|
||||
asgi_ext.mount_manager.mount(path="/api", app=api_v1_app, name="api")
|
||||
server.extension_manager.extensions.insert(0, asgi_ext)
|
||||
|
||||
test_server = PyServeTestServer.__new__(PyServeTestServer)
|
||||
test_server.config = config
|
||||
test_server.server = server
|
||||
test_server._server_task = None
|
||||
|
||||
try:
|
||||
await test_server.start()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Test POST to create user
|
||||
response = await client.post(
|
||||
f"http://127.0.0.1:{pyserve_port}/api/users",
|
||||
json={"name": "Charlie", "email": "charlie@test.com"}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["created"]["name"] == "Charlie"
|
||||
|
||||
# Test echo endpoint
|
||||
response = await client.post(
|
||||
f"http://127.0.0.1:{pyserve_port}/api/echo",
|
||||
content=b"Hello, World!",
|
||||
headers={"Content-Type": "text/plain"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"Hello, World!"
|
||||
|
||||
finally:
|
||||
await test_server.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asgi_mount_with_routing_extension(self, pyserve_port, api_v1_app):
|
||||
"""Test ASGI mounts working alongside routing extension."""
|
||||
config = Config(
|
||||
server=ServerConfig(host="127.0.0.1", port=pyserve_port),
|
||||
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
|
||||
logging=LoggingConfig(level="ERROR", console_output=False),
|
||||
extensions=[
|
||||
ExtensionConfig(
|
||||
type="routing",
|
||||
config={
|
||||
"regex_locations": {
|
||||
"=/health": {"return": "200 PyServe OK"},
|
||||
"=/status": {"return": "200 Server Running"},
|
||||
}
|
||||
}
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
server = PyServeServer(config)
|
||||
|
||||
# Add ASGI extension BEFORE routing extension
|
||||
from pyserve.extensions import ASGIExtension
|
||||
asgi_ext = ASGIExtension({"mounts": []})
|
||||
asgi_ext.mount_manager.mount(path="/api", app=api_v1_app, name="api")
|
||||
server.extension_manager.extensions.insert(0, asgi_ext)
|
||||
|
||||
test_server = PyServeTestServer.__new__(PyServeTestServer)
|
||||
test_server.config = config
|
||||
test_server.server = server
|
||||
test_server._server_task = None
|
||||
|
||||
try:
|
||||
await test_server.start()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Test ASGI mounted app
|
||||
response = await client.get(f"http://127.0.0.1:{pyserve_port}/api/users")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["app"] == "api-v1"
|
||||
|
||||
# Test routing extension endpoints
|
||||
response = await client.get(f"http://127.0.0.1:{pyserve_port}/status")
|
||||
assert response.status_code == 200
|
||||
assert "Server Running" in response.text
|
||||
|
||||
finally:
|
||||
await test_server.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asgi_mount_path_not_stripped(self, pyserve_port):
|
||||
"""Test ASGI mount with strip_path=False."""
|
||||
|
||||
# Create an app that expects full path
|
||||
async def handler(request: Request) -> JSONResponse:
|
||||
return JSONResponse({
|
||||
"full_path": request.url.path,
|
||||
"received": True,
|
||||
})
|
||||
|
||||
app = Starlette(routes=[
|
||||
Route("/mounted/data", handler, methods=["GET"]),
|
||||
])
|
||||
|
||||
config = Config(
|
||||
server=ServerConfig(host="127.0.0.1", port=pyserve_port),
|
||||
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
|
||||
logging=LoggingConfig(level="ERROR", console_output=False),
|
||||
extensions=[],
|
||||
)
|
||||
|
||||
server = PyServeServer(config)
|
||||
|
||||
from pyserve.extensions import ASGIExtension
|
||||
asgi_ext = ASGIExtension({"mounts": []})
|
||||
asgi_ext.mount_manager.mount(
|
||||
path="/mounted",
|
||||
app=app,
|
||||
name="full-path-app",
|
||||
strip_path=False
|
||||
)
|
||||
server.extension_manager.extensions.insert(0, asgi_ext)
|
||||
|
||||
test_server = PyServeTestServer.__new__(PyServeTestServer)
|
||||
test_server.config = config
|
||||
test_server.server = server
|
||||
test_server._server_task = None
|
||||
|
||||
try:
|
||||
await test_server.start()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(f"http://127.0.0.1:{pyserve_port}/mounted/data")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["full_path"] == "/mounted/data"
|
||||
|
||||
finally:
|
||||
await test_server.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asgi_mount_metrics(self, pyserve_port, api_v1_app):
|
||||
"""Test that ASGI extension reports metrics correctly."""
|
||||
config = Config(
|
||||
server=ServerConfig(host="127.0.0.1", port=pyserve_port),
|
||||
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
|
||||
logging=LoggingConfig(level="ERROR", console_output=False),
|
||||
extensions=[],
|
||||
)
|
||||
|
||||
server = PyServeServer(config)
|
||||
|
||||
from pyserve.extensions import ASGIExtension
|
||||
asgi_ext = ASGIExtension({"mounts": []})
|
||||
asgi_ext.mount_manager.mount(path="/api/v1", app=api_v1_app, name="api-v1")
|
||||
asgi_ext.mount_manager.mount(path="/api/v2", app=create_api_v2_app(), name="api-v2")
|
||||
server.extension_manager.extensions.insert(0, asgi_ext)
|
||||
|
||||
# Check metrics
|
||||
metrics = asgi_ext.get_metrics()
|
||||
assert metrics["asgi_mount_count"] == 2
|
||||
assert len(metrics["asgi_mounts"]) == 2
|
||||
|
||||
mount_names = {m["name"] for m in metrics["asgi_mounts"]}
|
||||
assert "api-v1" in mount_names
|
||||
assert "api-v2" in mount_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asgi_mount_error_handling(self, pyserve_port):
|
||||
"""Test error handling when mounted app raises exception."""
|
||||
|
||||
async def failing_handler(request: Request) -> JSONResponse:
|
||||
raise ValueError("Intentional error for testing")
|
||||
|
||||
async def working_handler(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
app = Starlette(routes=[
|
||||
Route("/fail", failing_handler, methods=["GET"]),
|
||||
Route("/ok", working_handler, methods=["GET"]),
|
||||
])
|
||||
|
||||
config = Config(
|
||||
server=ServerConfig(host="127.0.0.1", port=pyserve_port),
|
||||
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
|
||||
logging=LoggingConfig(level="ERROR", console_output=False),
|
||||
extensions=[],
|
||||
)
|
||||
|
||||
server = PyServeServer(config)
|
||||
|
||||
from pyserve.extensions import ASGIExtension
|
||||
asgi_ext = ASGIExtension({"mounts": []})
|
||||
asgi_ext.mount_manager.mount(path="/test", app=app, name="test-app")
|
||||
server.extension_manager.extensions.insert(0, asgi_ext)
|
||||
|
||||
test_server = PyServeTestServer.__new__(PyServeTestServer)
|
||||
test_server.config = config
|
||||
test_server.server = server
|
||||
test_server._server_task = None
|
||||
|
||||
try:
|
||||
await test_server.start()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Working endpoint should work
|
||||
response = await client.get(f"http://127.0.0.1:{pyserve_port}/test/ok")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Failing endpoint should return 500
|
||||
response = await client.get(f"http://127.0.0.1:{pyserve_port}/test/fail")
|
||||
assert response.status_code == 500
|
||||
|
||||
finally:
|
||||
await test_server.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_requests_to_mounted_apps(self, pyserve_port, api_v1_app, api_v2_app):
|
||||
"""Test concurrent requests to different mounted apps."""
|
||||
config = Config(
|
||||
server=ServerConfig(host="127.0.0.1", port=pyserve_port),
|
||||
http=HttpConfig(static_dir="./static", templates_dir="./templates"),
|
||||
logging=LoggingConfig(level="ERROR", console_output=False),
|
||||
extensions=[],
|
||||
)
|
||||
|
||||
server = PyServeServer(config)
|
||||
|
||||
from pyserve.extensions import ASGIExtension
|
||||
asgi_ext = ASGIExtension({"mounts": []})
|
||||
asgi_ext.mount_manager.mount(path="/v1", app=api_v1_app, name="v1")
|
||||
asgi_ext.mount_manager.mount(path="/v2", app=api_v2_app, name="v2")
|
||||
server.extension_manager.extensions.insert(0, asgi_ext)
|
||||
|
||||
test_server = PyServeTestServer.__new__(PyServeTestServer)
|
||||
test_server.config = config
|
||||
test_server.server = server
|
||||
test_server._server_task = None
|
||||
|
||||
try:
|
||||
await test_server.start()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Send concurrent requests
|
||||
tasks = [
|
||||
client.get(f"http://127.0.0.1:{pyserve_port}/v1/health"),
|
||||
client.get(f"http://127.0.0.1:{pyserve_port}/v2/health"),
|
||||
client.get(f"http://127.0.0.1:{pyserve_port}/v1/users"),
|
||||
client.get(f"http://127.0.0.1:{pyserve_port}/v2/users"),
|
||||
client.get(f"http://127.0.0.1:{pyserve_port}/v1/"),
|
||||
client.get(f"http://127.0.0.1:{pyserve_port}/v2/"),
|
||||
]
|
||||
|
||||
responses = await asyncio.gather(*tasks)
|
||||
|
||||
# All requests should succeed
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify correct app responded
|
||||
assert responses[0].json()["app"] == "api-v1"
|
||||
assert responses[1].json()["app"] == "api-v2"
|
||||
|
||||
finally:
|
||||
await test_server.stop()
|
||||
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Tests for path_matcher module.
|
||||
|
||||
Run with: pytest tests/test_path_matcher.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pyserve.path_matcher import (
|
||||
FastMountedPath,
|
||||
FastMountManager,
|
||||
path_matches_prefix,
|
||||
strip_path_prefix,
|
||||
match_and_modify_path,
|
||||
CYTHON_AVAILABLE,
|
||||
)
|
||||
|
||||
|
||||
class TestFastMountedPath:
|
||||
def test_root_mount_matches_everything(self):
|
||||
"""Root mount should match all paths."""
|
||||
mount = FastMountedPath("")
|
||||
|
||||
assert mount.matches("/") is True
|
||||
assert mount.matches("/api") is True
|
||||
assert mount.matches("/api/users") is True
|
||||
assert mount.matches("/anything/at/all") is True
|
||||
|
||||
def test_slash_root_mount_matches_everything(self):
|
||||
"""'/' mount should match all paths."""
|
||||
mount = FastMountedPath("/")
|
||||
|
||||
assert mount.matches("/") is True
|
||||
assert mount.matches("/api") is True
|
||||
assert mount.matches("/api/users") is True
|
||||
|
||||
def test_exact_path_match(self):
|
||||
"""Exact path should match."""
|
||||
mount = FastMountedPath("/api")
|
||||
|
||||
assert mount.matches("/api") is True
|
||||
assert mount.matches("/api/") is True
|
||||
assert mount.matches("/api/users") is True
|
||||
|
||||
def test_no_false_prefix_match(self):
|
||||
"""/api should not match /api-v2."""
|
||||
mount = FastMountedPath("/api")
|
||||
|
||||
assert mount.matches("/api-v2") is False
|
||||
assert mount.matches("/api2") is False
|
||||
assert mount.matches("/apiv2") is False
|
||||
|
||||
def test_shorter_path_no_match(self):
|
||||
"""Request path shorter than mount path should not match."""
|
||||
mount = FastMountedPath("/api/v1")
|
||||
|
||||
assert mount.matches("/api") is False
|
||||
assert mount.matches("/ap") is False
|
||||
assert mount.matches("/") is False
|
||||
|
||||
def test_trailing_slash_normalized(self):
|
||||
"""Trailing slashes should be normalized."""
|
||||
mount1 = FastMountedPath("/api/")
|
||||
mount2 = FastMountedPath("/api")
|
||||
|
||||
assert mount1.path == "/api"
|
||||
assert mount2.path == "/api"
|
||||
assert mount1.matches("/api/users") is True
|
||||
assert mount2.matches("/api/users") is True
|
||||
|
||||
def test_get_modified_path_strips_prefix(self):
|
||||
"""Modified path should have prefix stripped."""
|
||||
mount = FastMountedPath("/api")
|
||||
|
||||
assert mount.get_modified_path("/api") == "/"
|
||||
assert mount.get_modified_path("/api/") == "/"
|
||||
assert mount.get_modified_path("/api/users") == "/users"
|
||||
assert mount.get_modified_path("/api/users/123") == "/users/123"
|
||||
|
||||
def test_get_modified_path_no_strip(self):
|
||||
"""With strip_path=False, path should not be modified."""
|
||||
mount = FastMountedPath("/api", strip_path=False)
|
||||
|
||||
assert mount.get_modified_path("/api/users") == "/api/users"
|
||||
assert mount.get_modified_path("/api") == "/api"
|
||||
|
||||
def test_root_mount_modified_path(self):
|
||||
"""Root mount should return original path."""
|
||||
mount = FastMountedPath("")
|
||||
|
||||
assert mount.get_modified_path("/api/users") == "/api/users"
|
||||
assert mount.get_modified_path("/") == "/"
|
||||
|
||||
def test_name_property(self):
|
||||
"""Name should be set correctly."""
|
||||
mount1 = FastMountedPath("/api")
|
||||
mount2 = FastMountedPath("/api", name="API Mount")
|
||||
|
||||
assert mount1.name == "/api"
|
||||
assert mount2.name == "API Mount"
|
||||
|
||||
def test_repr(self):
|
||||
"""Repr should be informative."""
|
||||
mount = FastMountedPath("/api", name="API")
|
||||
assert "FastMountedPath" in repr(mount)
|
||||
assert "/api" in repr(mount)
|
||||
|
||||
|
||||
class TestFastMountManager:
|
||||
def test_empty_manager(self):
|
||||
"""Empty manager should return None."""
|
||||
manager = FastMountManager()
|
||||
|
||||
assert manager.get_mount("/api") is None
|
||||
assert manager.mount_count == 0
|
||||
|
||||
def test_add_mount(self):
|
||||
"""Adding mounts should work."""
|
||||
manager = FastMountManager()
|
||||
mount = FastMountedPath("/api")
|
||||
|
||||
manager.add_mount(mount)
|
||||
|
||||
assert manager.mount_count == 1
|
||||
assert manager.get_mount("/api/users") is mount
|
||||
|
||||
def test_longest_prefix_matching(self):
|
||||
"""Longer prefixes should match first."""
|
||||
manager = FastMountManager()
|
||||
|
||||
api_mount = FastMountedPath("/api", name="api")
|
||||
api_v1_mount = FastMountedPath("/api/v1", name="api_v1")
|
||||
api_v2_mount = FastMountedPath("/api/v2", name="api_v2")
|
||||
|
||||
manager.add_mount(api_mount)
|
||||
manager.add_mount(api_v2_mount)
|
||||
manager.add_mount(api_v1_mount)
|
||||
|
||||
assert manager.get_mount("/api/v1/users").name == "api_v1"
|
||||
assert manager.get_mount("/api/v2/items").name == "api_v2"
|
||||
assert manager.get_mount("/api/v3/other").name == "api"
|
||||
assert manager.get_mount("/api").name == "api"
|
||||
|
||||
def test_remove_mount(self):
|
||||
"""Removing mounts should work."""
|
||||
manager = FastMountManager()
|
||||
|
||||
manager.add_mount(FastMountedPath("/api"))
|
||||
manager.add_mount(FastMountedPath("/admin"))
|
||||
|
||||
assert manager.mount_count == 2
|
||||
|
||||
result = manager.remove_mount("/api")
|
||||
|
||||
assert result is True
|
||||
assert manager.mount_count == 1
|
||||
assert manager.get_mount("/api/users") is None
|
||||
assert manager.get_mount("/admin/users") is not None
|
||||
|
||||
def test_remove_nonexistent_mount(self):
|
||||
"""Removing nonexistent mount should return False."""
|
||||
manager = FastMountManager()
|
||||
|
||||
result = manager.remove_mount("/api")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_list_mounts(self):
|
||||
"""list_mounts should return mount info."""
|
||||
manager = FastMountManager()
|
||||
|
||||
manager.add_mount(FastMountedPath("/api", name="API"))
|
||||
manager.add_mount(FastMountedPath("/admin", name="Admin"))
|
||||
|
||||
mounts = manager.list_mounts()
|
||||
|
||||
assert len(mounts) == 2
|
||||
assert all("path" in m and "name" in m and "strip_path" in m for m in mounts)
|
||||
|
||||
def test_mounts_property_returns_copy(self):
|
||||
"""mounts property should return a copy."""
|
||||
manager = FastMountManager()
|
||||
manager.add_mount(FastMountedPath("/api"))
|
||||
|
||||
mounts1 = manager.mounts
|
||||
mounts2 = manager.mounts
|
||||
|
||||
assert mounts1 is not mounts2
|
||||
assert mounts1 == mounts2
|
||||
|
||||
|
||||
class TestUtilityFunctions:
|
||||
"""Tests for standalone utility functions."""
|
||||
|
||||
def test_path_matches_prefix_basic(self):
|
||||
"""Basic prefix matching."""
|
||||
assert path_matches_prefix("/api/users", "/api") is True
|
||||
assert path_matches_prefix("/api", "/api") is True
|
||||
assert path_matches_prefix("/api-v2", "/api") is False
|
||||
assert path_matches_prefix("/ap", "/api") is False
|
||||
|
||||
def test_path_matches_prefix_root(self):
|
||||
"""Root prefix matches everything."""
|
||||
assert path_matches_prefix("/anything", "") is True
|
||||
assert path_matches_prefix("/anything", "/") is True
|
||||
|
||||
def test_strip_path_prefix_basic(self):
|
||||
"""Basic path stripping."""
|
||||
assert strip_path_prefix("/api/users", "/api") == "/users"
|
||||
assert strip_path_prefix("/api", "/api") == "/"
|
||||
assert strip_path_prefix("/api/", "/api") == "/"
|
||||
|
||||
def test_strip_path_prefix_root(self):
|
||||
"""Root prefix doesn't strip anything."""
|
||||
assert strip_path_prefix("/api/users", "") == "/api/users"
|
||||
assert strip_path_prefix("/api/users", "/") == "/api/users"
|
||||
|
||||
def test_match_and_modify_combined(self):
|
||||
"""Combined match and modify operation."""
|
||||
matches, path = match_and_modify_path("/api/users", "/api")
|
||||
assert matches is True
|
||||
assert path == "/users"
|
||||
|
||||
matches, path = match_and_modify_path("/api", "/api")
|
||||
assert matches is True
|
||||
assert path == "/"
|
||||
|
||||
matches, path = match_and_modify_path("/other", "/api")
|
||||
assert matches is False
|
||||
assert path is None
|
||||
|
||||
def test_match_and_modify_no_strip(self):
|
||||
"""Combined operation with strip_path=False."""
|
||||
matches, path = match_and_modify_path("/api/users", "/api", strip_path=False)
|
||||
assert matches is True
|
||||
assert path == "/api/users"
|
||||
|
||||
|
||||
class TestCythonAvailability:
|
||||
def test_cython_available_is_bool(self):
|
||||
"""CYTHON_AVAILABLE should be a boolean."""
|
||||
assert isinstance(CYTHON_AVAILABLE, bool)
|
||||
|
||||
def test_module_works_regardless(self):
|
||||
"""Module should work whether Cython is available or not."""
|
||||
mount = FastMountedPath("/test")
|
||||
assert mount.matches("/test/path") is True
|
||||
|
||||
|
||||
class TestPerformance:
|
||||
def test_many_matches(self):
|
||||
"""Should handle many match operations."""
|
||||
mount = FastMountedPath("/api/v1/users")
|
||||
|
||||
for _ in range(10000):
|
||||
assert mount.matches("/api/v1/users/123/posts") is True
|
||||
assert mount.matches("/other/path") is False
|
||||
|
||||
def test_many_mounts(self):
|
||||
"""Should handle many mounts."""
|
||||
manager = FastMountManager()
|
||||
|
||||
for i in range(100):
|
||||
manager.add_mount(FastMountedPath(f"/api/v{i}"))
|
||||
|
||||
assert manager.mount_count == 100
|
||||
|
||||
mount = manager.get_mount("/api/v50/users")
|
||||
assert mount is not None
|
||||
assert mount.path == "/api/v50"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user