chore: auto-commit 2026-03-16 10:49
This commit is contained in:
parent
02603aedd3
commit
9a586d0955
33 changed files with 207 additions and 2129 deletions
|
|
@ -1,573 +0,0 @@
|
||||||
# Configurable Mock API with Admin Interface and OAuth2 Provider
|
|
||||||
|
|
||||||
A lightweight, configurable mock API application in Python that allows dynamic endpoint management via an admin interface. The API serves customizable responses stored in a SQLite database with template variable support. Includes a full OAuth2 provider for securing endpoints with token-based authentication.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Dynamic Endpoint Configuration**: Create, read, update, and delete API endpoints through a web-based admin interface.
|
|
||||||
- **Template Variable Support**: Response bodies can include Jinja2 template variables (e.g., `{{ user_id }}`, `{{ timestamp }}`) populated from path parameters, query strings, headers, request body, system variables, and endpoint defaults.
|
|
||||||
- **Dynamic Route Registration**: Endpoints are registered/unregistered at runtime without restarting the server.
|
|
||||||
- **Admin Interface**: Secure web UI with session-based authentication for managing endpoints.
|
|
||||||
- **OAuth2 Provider**: Full OAuth2 implementation supporting authorization code, client credentials, and refresh token grant types.
|
|
||||||
- **Endpoint‑Level OAuth Protection**: Individual endpoints can require OAuth2 tokens with configurable scopes.
|
|
||||||
- **Admin OAuth2 Management**: Web UI for managing OAuth clients, tokens, and users.
|
|
||||||
- **OpenID Connect Discovery**: Standards‑compliant discovery endpoint.
|
|
||||||
- **Production Ready**: Uses Waitress WSGI server, SQLAlchemy async, and FastAPI with proper error handling and security measures.
|
|
||||||
|
|
||||||
## Technology Stack
|
|
||||||
|
|
||||||
- **Framework**: FastAPI (with automatic OpenAPI documentation)
|
|
||||||
- **Server**: Waitress (production WSGI server)
|
|
||||||
- **Database**: SQLite with SQLAlchemy 2.0 async ORM
|
|
||||||
- **Templating**: Jinja2 with sandboxed environment
|
|
||||||
- **Authentication**: Session‑based admin authentication with bcrypt password hashing
|
|
||||||
- **OAuth2**: JWT‑based tokens with configurable scopes, client validation, and token revocation
|
|
||||||
- **Frontend**: Bootstrap 5 (CDN) for admin UI
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
mockapi/
|
|
||||||
├── app.py # FastAPI application factory & lifespan
|
|
||||||
├── config.py # Configuration (Pydantic Settings)
|
|
||||||
├── database.py # SQLAlchemy async database setup
|
|
||||||
├── dependencies.py # FastAPI dependencies
|
|
||||||
├── example_usage.py # Integration test & demonstration script
|
|
||||||
├── middleware/
|
|
||||||
│ └── auth_middleware.py # Admin authentication middleware
|
|
||||||
├── models/
|
|
||||||
│ ├── endpoint_model.py # Endpoint SQLAlchemy model
|
|
||||||
│ └── oauth_models.py # OAuth2 client, token, and user models
|
|
||||||
├── observers/
|
|
||||||
│ └── __init__.py # Observer pattern placeholder
|
|
||||||
├── repositories/
|
|
||||||
│ ├── endpoint_repository.py # Repository pattern for endpoints
|
|
||||||
│ └── oauth2/ # OAuth2 repositories
|
|
||||||
├── run.py # Development runner script (with auto-reload)
|
|
||||||
├── services/
|
|
||||||
│ ├── route_service.py # Dynamic route registration/management
|
|
||||||
│ └── template_service.py # Jinja2 template rendering
|
|
||||||
├── controllers/
|
|
||||||
│ ├── admin_controller.py # Admin UI routes
|
|
||||||
│ └── oauth2/ # OAuth2 controllers and services
|
|
||||||
├── schemas/
|
|
||||||
│ ├── endpoint_schema.py # Pydantic schemas for validation
|
|
||||||
│ └── oauth2/ # OAuth2 schemas
|
|
||||||
├── templates/ # Jinja2 HTML templates
|
|
||||||
│ ├── base.html # Base layout
|
|
||||||
│ └── admin/
|
|
||||||
│ ├── login.html # Login page
|
|
||||||
│ ├── dashboard.html # Admin dashboard
|
|
||||||
│ ├── endpoints.html # Endpoint list
|
|
||||||
│ ├── endpoint_form.html # Create/edit endpoint
|
|
||||||
│ └── oauth/ # OAuth2 management pages
|
|
||||||
├── static/
|
|
||||||
│ └── css/ # Static CSS (optional)
|
|
||||||
├── tests/ # Test suite
|
|
||||||
│ ├── test_admin.py # Admin authentication tests
|
|
||||||
│ ├── test_endpoint_repository.py
|
|
||||||
│ ├── test_route_manager_fix.py
|
|
||||||
│ ├── test_oauth2_controller.py
|
|
||||||
│ └── integration/ # Integration tests
|
|
||||||
├── utils/ # Utility modules
|
|
||||||
│ └── __init__.py
|
|
||||||
├── requirements.txt # Python dependencies
|
|
||||||
├── .env.example # Example environment variables
|
|
||||||
├── .env # Local environment variables (create from .env.example)
|
|
||||||
├── run_example.sh # Script to run the integration test
|
|
||||||
├── LICENSE # MIT License
|
|
||||||
└── README.md # This file
|
|
||||||
```
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
1. **Navigate to project directory**:
|
|
||||||
```bash
|
|
||||||
cd ~/GitLab/customer-engineering/mockapi
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create a virtual environment** (recommended):
|
|
||||||
```bash
|
|
||||||
python3 -m venv venv
|
|
||||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Install dependencies**:
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Configure environment variables**:
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env with your settings
|
|
||||||
```
|
|
||||||
|
|
||||||
Example `.env`:
|
|
||||||
```ini
|
|
||||||
DATABASE_URL=sqlite+aiosqlite:///./mockapi.db
|
|
||||||
ADMIN_USERNAME=admin
|
|
||||||
ADMIN_PASSWORD=admin123 # Change this in production!
|
|
||||||
SECRET_KEY=your-secret-key-here # Change this!
|
|
||||||
DEBUG=True # Set to False in production
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Initialize the database** (tables are created automatically on first run).
|
|
||||||
|
|
||||||
## Running the Application
|
|
||||||
|
|
||||||
### Development (with auto‑reload)
|
|
||||||
|
|
||||||
Make sure your virtual environment is activated:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source venv/bin/activate # Linux/macOS
|
|
||||||
# venv\Scripts\activate # Windows
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run with auto-reload for development:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Using run.py (convenience script)
|
|
||||||
python run.py
|
|
||||||
|
|
||||||
# Or directly with uvicorn
|
|
||||||
uvicorn app:app --reload --host 0.0.0.0 --port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production (with Waitress)
|
|
||||||
|
|
||||||
For production deployment, use Waitress WSGI server with the provided WSGI adapter (a2wsgi):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
waitress-serve --host=0.0.0.0 --port=8000 --threads=4 wsgi:wsgi_app
|
|
||||||
```
|
|
||||||
|
|
||||||
The server will start on `http://localhost:8000` (or your configured host/port).
|
|
||||||
|
|
||||||
**Note:** Waitress is a WSGI server, but FastAPI is an ASGI framework. The `wsgi.py` file uses `a2wsgi` to wrap the ASGI application into a WSGI-compatible interface. Routes are automatically refreshed from the database on server startup.
|
|
||||||
|
|
||||||
## Quick Start with cURL Examples
|
|
||||||
|
|
||||||
### 1. Create a Mock Endpoint via Admin API
|
|
||||||
|
|
||||||
First, log in to the admin interface (default credentials: `admin` / `admin123`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Simulate login and create session (use browser for UI, but you can also use curl)
|
|
||||||
curl -c cookies.txt -X POST http://localhost:8000/admin/login \
|
|
||||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
||||||
-d "username=admin&password=admin123"
|
|
||||||
```
|
|
||||||
|
|
||||||
Then create a mock endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -b cookies.txt -X POST http://localhost:8000/admin/endpoints \
|
|
||||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
||||||
-d "route=/api/greeting/{name}&method=GET&response_body={\"message\": \"Hello, {{ name }}!\"}&response_code=200&content_type=application/json&is_active=true"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Call the Mock Endpoint
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8000/api/greeting/World
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{ "message": "Hello, World!" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Use Template Variables
|
|
||||||
|
|
||||||
Create an endpoint that uses multiple variable sources:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -b cookies.txt -X POST http://localhost:8000/admin/endpoints \
|
|
||||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
||||||
-d "route=/api/user/{id}&method=GET&response_body={\"id\": {{ id }}, \"name\": \"{{ query.name }}\", \"timestamp\": \"{{ timestamp }}\"}&response_code=200&content_type=application/json&is_active=true"
|
|
||||||
```
|
|
||||||
|
|
||||||
Then call it with query parameters:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl "http://localhost:8000/api/user/123?name=John"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{ "id": 123, "name": "John", "timestamp": "2026-03-16T06:14:12.345678" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. OAuth2 Client Credentials Flow
|
|
||||||
|
|
||||||
First, create an OAuth client via the admin UI or using the admin API. Then obtain a token:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Get an access token using client credentials
|
|
||||||
curl -X POST http://localhost:8000/oauth/token \
|
|
||||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
||||||
-d "grant_type=client_credentials&client_id=your_client_id&client_secret=your_client_secret&scope=api:read"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"expires_in": 1800,
|
|
||||||
"scope": "api:read"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Protect an Endpoint with OAuth2
|
|
||||||
|
|
||||||
Create an endpoint that requires OAuth2:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -b cookies.txt -X POST http://localhost:8000/admin/endpoints \
|
|
||||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
||||||
-d "route=/api/protected&method=GET&response_body={\"status\": \"authorized\"}&response_code=200&content_type=application/json&is_active=true&requires_oauth=true&oauth_scopes=[\"api:read\"]"
|
|
||||||
```
|
|
||||||
|
|
||||||
Call it with a valid token:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..." \
|
|
||||||
http://localhost:8000/api/protected
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{ "status": "authorized" }
|
|
||||||
```
|
|
||||||
> **Note**: The `requires_oauth` and `oauth_scopes` fields are not yet exposed in the admin UI. To set OAuth protection, update the endpoint directly in the database or use the repository API.
|
|
||||||
|
|
||||||
### 6. OAuth2 Authorization Code Flow
|
|
||||||
|
|
||||||
For interactive applications:
|
|
||||||
|
|
||||||
1. **Authorization request** (user redirects to):
|
|
||||||
```
|
|
||||||
http://localhost:8000/oauth/authorize?response_type=code&client_id=your_client_id&redirect_uri=http://localhost:8080/callback&scope=api:read&state=xyz123
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Exchange code for token**:
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8000/oauth/token \
|
|
||||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
||||||
-d "grant_type=authorization_code&code=AUTH_CODE_HERE&redirect_uri=http://localhost:8080/callback&client_id=your_client_id&client_secret=your_client_secret"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. OAuth2 Token Introspection
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -u client_id:client_secret -X POST http://localhost:8000/oauth/introspect \
|
|
||||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
||||||
-d "token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. OpenID Connect Discovery
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8000/.well-known/openid-configuration
|
|
||||||
```
|
|
||||||
|
|
||||||
## OAuth2 Authentication
|
|
||||||
|
|
||||||
The application includes a full OAuth2 provider implementing RFC 6749 and OpenID Connect Discovery.
|
|
||||||
|
|
||||||
### Supported Grant Types
|
|
||||||
|
|
||||||
- **Authorization Code**: For web applications with server‑side components.
|
|
||||||
- **Client Credentials**: For machine‑to‑machine communication.
|
|
||||||
- **Refresh Token**: To obtain new access tokens without user interaction.
|
|
||||||
|
|
||||||
### Endpoints
|
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
|
||||||
|----------|--------|-------------|
|
|
||||||
| `/oauth/authorize` | GET, POST | Authorization endpoint (interactive user consent) |
|
|
||||||
| `/oauth/token` | POST | Token issuance and refresh |
|
|
||||||
| `/oauth/userinfo` | GET | OpenID Connect user claims |
|
|
||||||
| `/oauth/introspect` | POST | Token introspection (RFC 7662) |
|
|
||||||
| `/oauth/revoke` | POST | Token revocation (RFC 7009) |
|
|
||||||
| `/.well‑known/openid‑configuration` | GET | OpenID Connect discovery document |
|
|
||||||
|
|
||||||
### Scope Management
|
|
||||||
|
|
||||||
Default scopes include:
|
|
||||||
- `openid` – OpenID Connect support
|
|
||||||
- `profile` – Basic profile information
|
|
||||||
- `email` – Email address claim
|
|
||||||
- `api:read` – Read access to protected endpoints
|
|
||||||
- `api:write` – Write access to protected endpoints
|
|
||||||
|
|
||||||
### Admin OAuth2 Management
|
|
||||||
|
|
||||||
Access OAuth2 management via the admin interface:
|
|
||||||
|
|
||||||
- **Clients**: `http://localhost:8000/admin/oauth/clients` – Register and manage OAuth clients
|
|
||||||
- **Tokens**: `http://localhost:8000/admin/oauth/tokens` – View and revoke issued tokens
|
|
||||||
- **Users**: `http://localhost:8000/admin/oauth/users` – Manage OAuth user records
|
|
||||||
|
|
||||||
## Production Deployment Considerations
|
|
||||||
|
|
||||||
### 1. **Environment Configuration**
|
|
||||||
- Set `DEBUG=False` in production
|
|
||||||
- Use strong, unique values for `ADMIN_PASSWORD` and `SECRET_KEY`
|
|
||||||
- Consider using a more robust database (PostgreSQL) by changing `DATABASE_URL`
|
|
||||||
- Store sensitive values in environment variables or a secrets manager
|
|
||||||
|
|
||||||
### 2. **Process Management**
|
|
||||||
Use a process manager like systemd (Linux) or Supervisor to keep the application running:
|
|
||||||
|
|
||||||
**Example systemd service (`/etc/systemd/system/mockapi.service`)**:
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=Mock API Service
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
User=www-data
|
|
||||||
Group=www-data
|
|
||||||
WorkingDirectory=/path/to/mockapi
|
|
||||||
Environment="PATH=/path/to/mockapi/venv/bin"
|
|
||||||
ExecStart=/path/to/mockapi/venv/bin/waitress-serve --host=0.0.0.0 --port=8000 wsgi:wsgi_app
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **Reverse Proxy (Recommended)**
|
|
||||||
Use Nginx or Apache as a reverse proxy for SSL termination, load balancing, and static file serving:
|
|
||||||
|
|
||||||
**Example Nginx configuration**:
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name api.yourdomain.com;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:8000;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. **Database Backups**
|
|
||||||
For SQLite, regularly backup the `mockapi.db` file. For production, consider migrating to PostgreSQL.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### 1. Access the Admin Interface
|
|
||||||
- Open `http://localhost:8000/admin/login`
|
|
||||||
- Log in with the credentials set in `.env` (default: `admin` / `admin123`)
|
|
||||||
|
|
||||||
### 2. Create a Mock Endpoint
|
|
||||||
1. Navigate to **Endpoints** → **Create New**.
|
|
||||||
2. Fill in the form:
|
|
||||||
- **Route**: `/api/greeting/{name}` (supports path parameters)
|
|
||||||
- **Method**: GET
|
|
||||||
- **Response Body**: `{ "message": "Hello, {{ name }}!" }`
|
|
||||||
- **Response Code**: 200
|
|
||||||
- **Content-Type**: `application/json`
|
|
||||||
- **Variables**: `{ "server": "mock-api" }` (optional defaults)
|
|
||||||
3. Click **Create**.
|
|
||||||
|
|
||||||
### 3. Call the Mock Endpoint
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8000/api/greeting/World
|
|
||||||
```
|
|
||||||
Response:
|
|
||||||
```json
|
|
||||||
{ "message": "Hello, World!" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Template Variables
|
|
||||||
The following variable sources are available in response templates:
|
|
||||||
|
|
||||||
| Source | Example variable | Usage in template |
|
|
||||||
|--------|------------------|-------------------|
|
|
||||||
| Path parameters | `{{ name }}` | `/users/{id}` → `{{ id }}` |
|
|
||||||
| Query parameters | `{{ query.page }}` | `?page=1` → `{{ page }}` |
|
|
||||||
| Request headers | `{{ header.authorization }}` | `Authorization: Bearer token` |
|
|
||||||
| Request body | `{{ body.user.email }}` | JSON request body |
|
|
||||||
| System variables | `{{ timestamp }}`, `{{ request_id }}` | Automatically injected |
|
|
||||||
| Endpoint defaults | `{{ server }}` | Defined in endpoint variables |
|
|
||||||
|
|
||||||
### 5. Admin Functions
|
|
||||||
- **List endpoints** with pagination and filtering
|
|
||||||
- **Edit** existing endpoints (changes take effect immediately)
|
|
||||||
- **Activate/deactivate** endpoints without deletion
|
|
||||||
- **Delete** endpoints (removes route)
|
|
||||||
- **Dashboard** with statistics (total endpoints, active routes, etc.)
|
|
||||||
- **OAuth2 management** – clients, tokens, users
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
- **Admin authentication**: Uses bcrypt password hashing. Store a strong password hash in production.
|
|
||||||
- **Session management**: Signed cookies with configurable secret key.
|
|
||||||
- **Template sandboxing**: Jinja2 environment restricted with `SandboxedEnvironment` and `StrictUndefined`.
|
|
||||||
- **Request size limits**: Maximum body size of 1MB to prevent DoS.
|
|
||||||
- **Route validation**: Prevents path traversal (`..`) and other unsafe patterns.
|
|
||||||
- **SQL injection protection**: All queries use SQLAlchemy ORM.
|
|
||||||
- **OAuth2 security**: Client secret hashing, token revocation, scope validation, secure token storage.
|
|
||||||
|
|
||||||
## Configuration Options
|
|
||||||
|
|
||||||
See `config.py` for all available settings. Key environment variables:
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `DATABASE_URL` | `sqlite+aiosqlite:///./mockapi.db` | SQLAlchemy database URL |
|
|
||||||
| `ADMIN_USERNAME` | `admin` | Admin login username |
|
|
||||||
| `ADMIN_PASSWORD` | `admin123` | Admin login password (plaintext) |
|
|
||||||
| `SECRET_KEY` | `your‑secret‑key‑here‑change‑me` | Session signing secret |
|
|
||||||
| `DEBUG` | `False` | Enable debug mode (more logging, relaxed validation) |
|
|
||||||
| `OAUTH2_ISSUER` | `http://localhost:8000` | OAuth2 issuer URL for discovery |
|
|
||||||
| `OAUTH2_ACCESS_TOKEN_EXPIRE_MINUTES` | `30` | Access token lifetime |
|
|
||||||
| `OAUTH2_REFRESH_TOKEN_EXPIRE_DAYS` | `7` | Refresh token lifetime |
|
|
||||||
| `OAUTH2_SUPPORTED_SCOPES` | `["openid","profile","email","api:read","api:write"]` | Available OAuth2 scopes |
|
|
||||||
|
|
||||||
**Warning**: In production (`DEBUG=False`), the default `ADMIN_PASSWORD` and `SECRET_KEY` will cause validation errors. You must set unique values via environment variables.
|
|
||||||
|
|
||||||
## API Documentation
|
|
||||||
|
|
||||||
FastAPI automatically provides OpenAPI documentation at:
|
|
||||||
- Swagger UI: `http://localhost:8000/docs`
|
|
||||||
- ReDoc: `http://localhost:8000/redoc`
|
|
||||||
|
|
||||||
The root URL (/) automatically redirects to the Swagger documentation at /docs.
|
|
||||||
|
|
||||||
The dynamic mock endpoints are not listed in the OpenAPI schema (they are registered at runtime).
|
|
||||||
|
|
||||||
## Development & Testing
|
|
||||||
|
|
||||||
## API Testing with Bruno
|
|
||||||
|
|
||||||
A ready-to-use [Bruno](https://www.usebruno.com/) API collection is available in the `examples/` directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Set up test OAuth client and view instructions
|
|
||||||
./examples/setup.sh
|
|
||||||
|
|
||||||
# Or import directly:
|
|
||||||
# examples/mockapi-collection.bru
|
|
||||||
```
|
|
||||||
|
|
||||||
The collection includes:
|
|
||||||
- Global variables for base URL and credentials
|
|
||||||
- Pre-configured requests for all endpoints
|
|
||||||
- OAuth2 flow examples (client credentials, authorization code)
|
|
||||||
- Admin authentication
|
|
||||||
- Mock endpoint creation and testing
|
|
||||||
- Protected endpoint examples
|
|
||||||
|
|
||||||
See [examples/README.md](examples/README.md) for detailed usage instructions.
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
|
|
||||||
Run tests with pytest:
|
|
||||||
```bash
|
|
||||||
pytest tests/
|
|
||||||
```
|
|
||||||
|
|
||||||
The test suite includes:
|
|
||||||
- Unit tests for repository and service layers
|
|
||||||
- Integration tests for admin authentication
|
|
||||||
- Template rendering tests
|
|
||||||
- OAuth2 unit and integration tests (21+ tests)
|
|
||||||
|
|
||||||
### Example Integration Test
|
|
||||||
|
|
||||||
A ready‑to‑run integration test demonstrates the core functionality:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Make the script executable (Linux/macOS)
|
|
||||||
chmod +x run_example.sh
|
|
||||||
|
|
||||||
# Run the example
|
|
||||||
./run_example.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Or directly with Python:
|
|
||||||
```bash
|
|
||||||
python example_usage.py
|
|
||||||
```
|
|
||||||
|
|
||||||
The example script will:
|
|
||||||
1. Start the FastAPI app (via TestClient)
|
|
||||||
2. Log in as admin
|
|
||||||
3. Create a mock endpoint with template variables
|
|
||||||
4. Call the endpoint and verify the response
|
|
||||||
5. Report success or failure
|
|
||||||
|
|
||||||
This is a great way to verify that the API is working correctly after installation.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
1. **"no such table: endpoints" error**
|
|
||||||
- The database hasn't been initialized
|
|
||||||
- Restart the application - tables are created on first startup
|
|
||||||
- Or run `python -c "from database import init_db; import asyncio; asyncio.run(init_db())"`
|
|
||||||
|
|
||||||
2. **Login fails even with correct credentials**
|
|
||||||
- Check that `DEBUG=True` is set in `.env` (or provide unique credentials)
|
|
||||||
- The default credentials only work when `DEBUG=True`
|
|
||||||
- In production, you must set unique `ADMIN_PASSWORD` and `SECRET_KEY`
|
|
||||||
|
|
||||||
3. **Routes not being registered**
|
|
||||||
- Check that the endpoint is marked as active (`is_active=True`)
|
|
||||||
- Refresh the page - routes are registered immediately after creation
|
|
||||||
- Check application logs for errors
|
|
||||||
|
|
||||||
4. **Template variables not rendering**
|
|
||||||
- Ensure you're using double curly braces: `{{ variable }}`
|
|
||||||
- Check variable names match the context (use path_, query_, header_ prefixes as needed)
|
|
||||||
- View the rendered template in the admin edit form preview
|
|
||||||
|
|
||||||
5. **OAuth2 token validation fails**
|
|
||||||
- Verify the token hasn't expired
|
|
||||||
- Check that the client is active
|
|
||||||
- Confirm the token has required scopes for the endpoint
|
|
||||||
- Ensure the token hasn't been revoked
|
|
||||||
|
|
||||||
### Logging
|
|
||||||
Enable debug logging by setting `DEBUG=True` in `.env`. Check the console output for detailed error messages.
|
|
||||||
|
|
||||||
## Limitations & Future Enhancements
|
|
||||||
|
|
||||||
- **Current limitations**:
|
|
||||||
- SQLite only (but can be extended to PostgreSQL via `DATABASE_URL`)
|
|
||||||
- Single admin user (no multi‑user support)
|
|
||||||
- No request logging/history
|
|
||||||
- OAuth2 user authentication uses placeholder user IDs (integration with external identity providers pending)
|
|
||||||
|
|
||||||
- **Possible extensions**:
|
|
||||||
- Import/export endpoints as JSON/YAML
|
|
||||||
- Request logging and analytics
|
|
||||||
- WebSocket notifications for admin actions
|
|
||||||
- Multiple admin users with roles
|
|
||||||
- Rate limiting per endpoint
|
|
||||||
- CORS configuration
|
|
||||||
- PKCE support for public OAuth2 clients
|
|
||||||
- Integration with external identity providers (SAML, LDAP)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
||||||
|
|
||||||
## Acknowledgments
|
|
||||||
|
|
||||||
- Built with [FastAPI](https://fastapi.tiangolo.com/), [SQLAlchemy](https://www.sqlalchemy.org/), and [Jinja2](https://jinja.palletsprojects.com/).
|
|
||||||
- Admin UI uses [Bootstrap 5](https://getbootstrap.com/) via CDN.
|
|
||||||
- OAuth2 implementation follows RFC 6749, RFC 7662, and OpenID Connect standards.
|
|
||||||
98
app.py.backup
Normal file
98
app.py.backup
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, status
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from starlette.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from database import init_db, AsyncSessionLocal
|
||||||
|
from repositories.endpoint_repository import EndpointRepository
|
||||||
|
from services.route_service import RouteManager
|
||||||
|
from middleware.auth_middleware import AuthMiddleware
|
||||||
|
from controllers.admin_controller import router as admin_router
|
||||||
|
from oauth2 import oauth_router
|
||||||
|
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG if settings.debug else logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""
|
||||||
|
Lifespan context manager for startup and shutdown events.
|
||||||
|
"""
|
||||||
|
# Startup
|
||||||
|
logger.info("Initializing database...")
|
||||||
|
await init_db()
|
||||||
|
|
||||||
|
# Use the route manager already attached to app.state
|
||||||
|
route_manager = app.state.route_manager
|
||||||
|
logger.info("Refreshing routes...")
|
||||||
|
await route_manager.refresh_routes()
|
||||||
|
|
||||||
|
logger.info("Application startup complete.")
|
||||||
|
yield
|
||||||
|
# Shutdown
|
||||||
|
logger.info("Application shutting down...")
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
"""
|
||||||
|
Factory function to create and configure the FastAPI application.
|
||||||
|
"""
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.title,
|
||||||
|
version=settings.version,
|
||||||
|
debug=settings.debug,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach route manager and session factory to app.state before any request
|
||||||
|
route_manager = RouteManager(app, AsyncSessionLocal)
|
||||||
|
app.state.route_manager = route_manager
|
||||||
|
app.state.session_factory = AsyncSessionLocal
|
||||||
|
|
||||||
|
# Add authentication middleware for admin routes (must be after SessionMiddleware)
|
||||||
|
app.add_middleware(AuthMiddleware)
|
||||||
|
# Add session middleware (must be before AuthMiddleware, but add_middleware prepends)
|
||||||
|
app.add_middleware(
|
||||||
|
SessionMiddleware,
|
||||||
|
secret_key=settings.secret_key,
|
||||||
|
session_cookie=settings.session_cookie_name,
|
||||||
|
max_age=settings.session_max_age,
|
||||||
|
https_only=False,
|
||||||
|
same_site="lax",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Mount static files (optional, for future)
|
||||||
|
# app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
|
|
||||||
|
# Add a simple health check endpoint
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "healthy", "service": "mock-api"}
|
||||||
|
|
||||||
|
# Redirect root to Swagger documentation
|
||||||
|
@app.get("/")
|
||||||
|
async def root_redirect():
|
||||||
|
"""Redirect the root URL to Swagger documentation."""
|
||||||
|
return RedirectResponse(url="/docs", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
|
# Include admin controller routes
|
||||||
|
app.include_router(admin_router)
|
||||||
|
# Include OAuth2 routes
|
||||||
|
app.include_router(oauth_router)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
# Create the application instance
|
||||||
|
app = create_app()
|
||||||
|
|
@ -6,13 +6,13 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from starlette.staticfiles import StaticFiles
|
from starlette.staticfiles import StaticFiles
|
||||||
|
|
||||||
from config import settings
|
from app.core.config import settings
|
||||||
from database import init_db, AsyncSessionLocal
|
from app.core.database import init_db, AsyncSessionLocal
|
||||||
from repositories.endpoint_repository import EndpointRepository
|
from app.modules.endpoints.repositories.endpoint_repository import EndpointRepository
|
||||||
from services.route_service import RouteManager
|
from app.modules.endpoints.services.route_service import RouteManager
|
||||||
from middleware.auth_middleware import AuthMiddleware
|
from app.core.middleware.auth_middleware import AuthMiddleware
|
||||||
from controllers.admin_controller import router as admin_router
|
from app.modules.admin.controller import router as admin_router
|
||||||
from oauth2 import oauth_router
|
from app.modules.oauth2 import oauth_router
|
||||||
|
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|
@ -73,7 +73,7 @@ def create_app() -> FastAPI:
|
||||||
|
|
||||||
|
|
||||||
# Mount static files (optional, for future)
|
# Mount static files (optional, for future)
|
||||||
# app.mount("/static", StaticFiles(directory="static"), name="static")
|
# app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
|
|
||||||
# Add a simple health check endpoint
|
# Add a simple health check endpoint
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
@ -30,8 +30,9 @@ AsyncSessionLocal = sessionmaker(
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
# Import models to ensure they are registered with Base.metadata
|
# Import models to ensure they are registered with Base.metadata
|
||||||
from app.modules.endpoints.models.endpoint_model import Endpoint
|
# Models are imported elsewhere (route_service, oauth2 module) to avoid circular imports
|
||||||
from app.modules.oauth2.models import OAuthClient, OAuthToken, OAuthUser
|
# from app.modules.endpoints.models.endpoint_model import Endpoint
|
||||||
|
# from app.modules.oauth2.models import OAuthClient, OAuthToken, OAuthUser
|
||||||
|
|
||||||
|
|
||||||
async def get_db() -> AsyncSession:
|
async def get_db() -> AsyncSession:
|
||||||
|
|
|
||||||
|
|
@ -1,704 +0,0 @@
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
from datetime import datetime
|
|
||||||
from fastapi import APIRouter, Request, Form, Depends, HTTPException, status
|
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from config import settings
|
|
||||||
from middleware.auth_middleware import verify_password, get_password_hash
|
|
||||||
from database import get_db
|
|
||||||
from repositories.endpoint_repository import EndpointRepository
|
|
||||||
from schemas.endpoint_schema import EndpointCreate, EndpointUpdate, EndpointResponse
|
|
||||||
from services.route_service import RouteManager
|
|
||||||
from oauth2.repositories import OAuthClientRepository, OAuthTokenRepository, OAuthUserRepository
|
|
||||||
from oauth2.schemas import OAuthClientCreate, OAuthClientUpdate
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
||||||
templates = Jinja2Templates(directory="templates")
|
|
||||||
|
|
||||||
# Helper to get route manager from app state
|
|
||||||
def get_route_manager(request: Request) -> RouteManager:
|
|
||||||
return request.app.state.route_manager
|
|
||||||
|
|
||||||
# Helper to get repository
|
|
||||||
async def get_repository(db: AsyncSession = Depends(get_db)) -> EndpointRepository:
|
|
||||||
return EndpointRepository(db)
|
|
||||||
|
|
||||||
# Helper to get OAuth client repository
|
|
||||||
async def get_oauth_client_repository(db: AsyncSession = Depends(get_db)) -> OAuthClientRepository:
|
|
||||||
return OAuthClientRepository(db)
|
|
||||||
|
|
||||||
# Helper to get OAuth token repository
|
|
||||||
async def get_oauth_token_repository(db: AsyncSession = Depends(get_db)) -> OAuthTokenRepository:
|
|
||||||
return OAuthTokenRepository(db)
|
|
||||||
|
|
||||||
# Helper to get OAuth user repository
|
|
||||||
async def get_oauth_user_repository(db: AsyncSession = Depends(get_db)) -> OAuthUserRepository:
|
|
||||||
return OAuthUserRepository(db)
|
|
||||||
|
|
||||||
def prepare_client_data(
|
|
||||||
client_name: str,
|
|
||||||
redirect_uris: str,
|
|
||||||
grant_types: str,
|
|
||||||
scopes: str,
|
|
||||||
is_active: bool = True,
|
|
||||||
) -> dict:
|
|
||||||
"""Convert form data to client creation dict."""
|
|
||||||
import secrets
|
|
||||||
from middleware.auth_middleware import get_password_hash
|
|
||||||
|
|
||||||
client_id = secrets.token_urlsafe(16)
|
|
||||||
client_secret_plain = secrets.token_urlsafe(32)
|
|
||||||
|
|
||||||
# Hash the secret
|
|
||||||
client_secret_hash = get_password_hash(client_secret_plain)
|
|
||||||
|
|
||||||
# Parse comma-separated strings, strip whitespace
|
|
||||||
redirect_uris_list = [uri.strip() for uri in redirect_uris.split(",") if uri.strip()]
|
|
||||||
grant_types_list = [gt.strip() for gt in grant_types.split(",") if gt.strip()]
|
|
||||||
scopes_list = [scope.strip() for scope in scopes.split(",") if scope.strip()]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"client_id": client_id,
|
|
||||||
"client_secret": client_secret_hash,
|
|
||||||
"name": client_name,
|
|
||||||
"redirect_uris": redirect_uris_list,
|
|
||||||
"grant_types": grant_types_list,
|
|
||||||
"scopes": scopes_list,
|
|
||||||
"is_active": is_active,
|
|
||||||
"_plain_secret": client_secret_plain, # temporary for display
|
|
||||||
}
|
|
||||||
|
|
||||||
# Pagination constants
|
|
||||||
PAGE_SIZE = 20
|
|
||||||
|
|
||||||
# Pre‑computed hash of admin password (bcrypt)
|
|
||||||
admin_password_hash = get_password_hash(settings.admin_password)
|
|
||||||
|
|
||||||
# ---------- Authentication Routes ----------
|
|
||||||
@router.get("/login", response_class=HTMLResponse)
|
|
||||||
async def login_page(request: Request, error: Optional[str] = None):
|
|
||||||
"""Display login form."""
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"admin/login.html",
|
|
||||||
{"request": request, "error": error, "session": request.session}
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.post("/login", response_class=RedirectResponse)
|
|
||||||
async def login(
|
|
||||||
request: Request,
|
|
||||||
username: str = Form(...),
|
|
||||||
password: str = Form(...),
|
|
||||||
):
|
|
||||||
"""Process login credentials and set session."""
|
|
||||||
if username != settings.admin_username:
|
|
||||||
logger.warning(f"Failed login attempt: invalid username '{username}'")
|
|
||||||
return RedirectResponse(
|
|
||||||
url="/admin/login?error=Invalid+credentials",
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify password against pre‑computed bcrypt hash
|
|
||||||
if not verify_password(password, admin_password_hash):
|
|
||||||
logger.warning(f"Failed login attempt: invalid password for '{username}'")
|
|
||||||
return RedirectResponse(
|
|
||||||
url="/admin/login?error=Invalid+credentials",
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
# Authentication successful, set session
|
|
||||||
request.session["username"] = username
|
|
||||||
logger.info(f"User '{username}' logged in")
|
|
||||||
return RedirectResponse(url="/admin", status_code=status.HTTP_302_FOUND)
|
|
||||||
|
|
||||||
@router.get("/logout")
|
|
||||||
async def logout(request: Request):
|
|
||||||
"""Clear session and redirect to login."""
|
|
||||||
request.session.clear()
|
|
||||||
return RedirectResponse(url="/admin/login", status_code=status.HTTP_302_FOUND)
|
|
||||||
|
|
||||||
# ---------- Dashboard ----------
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
|
||||||
async def dashboard(
|
|
||||||
request: Request,
|
|
||||||
repository: EndpointRepository = Depends(get_repository),
|
|
||||||
route_manager: RouteManager = Depends(get_route_manager),
|
|
||||||
):
|
|
||||||
"""Admin dashboard with statistics."""
|
|
||||||
async with repository.session as session:
|
|
||||||
# Total endpoints
|
|
||||||
total_endpoints = await repository.get_all(limit=1000)
|
|
||||||
total_count = len(total_endpoints)
|
|
||||||
# Active endpoints
|
|
||||||
active_endpoints = await repository.get_active()
|
|
||||||
active_count = len(active_endpoints)
|
|
||||||
# Methods count (unique)
|
|
||||||
methods = set(e.method for e in total_endpoints)
|
|
||||||
methods_count = len(methods)
|
|
||||||
# Registered routes count
|
|
||||||
total_routes = len(route_manager.registered_routes)
|
|
||||||
|
|
||||||
stats = {
|
|
||||||
"total_endpoints": total_count,
|
|
||||||
"active_endpoints": active_count,
|
|
||||||
"methods_count": methods_count,
|
|
||||||
"total_routes": total_routes,
|
|
||||||
}
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"admin/dashboard.html",
|
|
||||||
{"request": request, "stats": stats, "session": request.session}
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------- Endpoints CRUD ----------
|
|
||||||
@router.get("/endpoints", response_class=HTMLResponse)
|
|
||||||
async def list_endpoints(
|
|
||||||
request: Request,
|
|
||||||
page: int = 1,
|
|
||||||
repository: EndpointRepository = Depends(get_repository),
|
|
||||||
):
|
|
||||||
"""List all endpoints with pagination."""
|
|
||||||
skip = (page - 1) * PAGE_SIZE
|
|
||||||
endpoints = await repository.get_all(skip=skip, limit=PAGE_SIZE)
|
|
||||||
total = len(await repository.get_all(limit=1000)) # naive count
|
|
||||||
total_pages = (total + PAGE_SIZE - 1) // PAGE_SIZE if total > 0 else 1
|
|
||||||
|
|
||||||
# Ensure page is within bounds
|
|
||||||
if page < 1 or (total_pages > 0 and page > total_pages):
|
|
||||||
return RedirectResponse(url="/admin/endpoints?page=1")
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"admin/endpoints.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"session": request.session,
|
|
||||||
"endpoints": endpoints,
|
|
||||||
"page": page,
|
|
||||||
"total_pages": total_pages,
|
|
||||||
"error": request.query_params.get("error"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get("/endpoints/new", response_class=HTMLResponse)
|
|
||||||
async def new_endpoint_form(request: Request):
|
|
||||||
"""Display form to create a new endpoint."""
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"admin/endpoint_form.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"session": request.session,
|
|
||||||
"action": "Create",
|
|
||||||
"form_action": "/admin/endpoints",
|
|
||||||
"endpoint": None,
|
|
||||||
"errors": {},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.post("/endpoints", response_class=RedirectResponse)
|
|
||||||
async def create_endpoint(
|
|
||||||
request: Request,
|
|
||||||
route: str = Form(...),
|
|
||||||
method: str = Form(...),
|
|
||||||
response_body: str = Form(...),
|
|
||||||
response_code: int = Form(200),
|
|
||||||
content_type: str = Form("application/json"),
|
|
||||||
is_active: bool = Form(True),
|
|
||||||
variables: str = Form("{}"),
|
|
||||||
headers: str = Form("{}"),
|
|
||||||
delay_ms: int = Form(0),
|
|
||||||
repository: EndpointRepository = Depends(get_repository),
|
|
||||||
route_manager: RouteManager = Depends(get_route_manager),
|
|
||||||
):
|
|
||||||
"""Create a new endpoint."""
|
|
||||||
# Parse JSON fields
|
|
||||||
try:
|
|
||||||
variables_dict = json.loads(variables) if variables else {}
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return RedirectResponse(
|
|
||||||
url="/admin/endpoints/new?error=Invalid+JSON+in+variables",
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
headers_dict = json.loads(headers) if headers else {}
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return RedirectResponse(
|
|
||||||
url="/admin/endpoints/new?error=Invalid+JSON+in+headers",
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate using Pydantic schema
|
|
||||||
try:
|
|
||||||
endpoint_data = EndpointCreate(
|
|
||||||
route=route,
|
|
||||||
method=method,
|
|
||||||
response_body=response_body,
|
|
||||||
response_code=response_code,
|
|
||||||
content_type=content_type,
|
|
||||||
is_active=is_active,
|
|
||||||
variables=variables_dict,
|
|
||||||
headers=headers_dict,
|
|
||||||
delay_ms=delay_ms,
|
|
||||||
).dict()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Validation error: {e}")
|
|
||||||
# Could pass errors to form, but for simplicity redirect with error
|
|
||||||
return RedirectResponse(
|
|
||||||
url="/admin/endpoints/new?error=" + str(e).replace(" ", "+"),
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create endpoint
|
|
||||||
endpoint = await repository.create(endpoint_data)
|
|
||||||
if not endpoint:
|
|
||||||
return RedirectResponse(
|
|
||||||
url="/admin/endpoints/new?error=Failed+to+create+endpoint",
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Created endpoint {endpoint.id}: {method} {route}")
|
|
||||||
# Refresh routes to include new endpoint
|
|
||||||
await route_manager.refresh_routes()
|
|
||||||
|
|
||||||
return RedirectResponse(url="/admin/endpoints", status_code=status.HTTP_302_FOUND)
|
|
||||||
|
|
||||||
@router.get("/endpoints/{endpoint_id}", response_class=HTMLResponse)
|
|
||||||
async def edit_endpoint_form(
|
|
||||||
request: Request,
|
|
||||||
endpoint_id: int,
|
|
||||||
repository: EndpointRepository = Depends(get_repository),
|
|
||||||
):
|
|
||||||
"""Display form to edit an existing endpoint."""
|
|
||||||
endpoint = await repository.get_by_id(endpoint_id)
|
|
||||||
if not endpoint:
|
|
||||||
raise HTTPException(status_code=404, detail="Endpoint not found")
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"admin/endpoint_form.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"session": request.session,
|
|
||||||
"action": "Edit",
|
|
||||||
"form_action": f"/admin/endpoints/{endpoint_id}",
|
|
||||||
"endpoint": endpoint,
|
|
||||||
"errors": {},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.post("/endpoints/{endpoint_id}", response_class=RedirectResponse)
|
|
||||||
async def update_endpoint(
|
|
||||||
request: Request,
|
|
||||||
endpoint_id: int,
|
|
||||||
route: Optional[str] = Form(None),
|
|
||||||
method: Optional[str] = Form(None),
|
|
||||||
response_body: Optional[str] = Form(None),
|
|
||||||
response_code: Optional[int] = Form(None),
|
|
||||||
content_type: Optional[str] = Form(None),
|
|
||||||
is_active: Optional[bool] = Form(None),
|
|
||||||
variables: Optional[str] = Form(None),
|
|
||||||
headers: Optional[str] = Form(None),
|
|
||||||
delay_ms: Optional[int] = Form(None),
|
|
||||||
repository: EndpointRepository = Depends(get_repository),
|
|
||||||
route_manager: RouteManager = Depends(get_route_manager),
|
|
||||||
):
|
|
||||||
"""Update an existing endpoint."""
|
|
||||||
# Parse JSON fields if provided
|
|
||||||
variables_dict = None
|
|
||||||
if variables is not None:
|
|
||||||
try:
|
|
||||||
variables_dict = json.loads(variables) if variables else {}
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return RedirectResponse(
|
|
||||||
url=f"/admin/endpoints/{endpoint_id}?error=Invalid+JSON+in+variables",
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
headers_dict = None
|
|
||||||
if headers is not None:
|
|
||||||
try:
|
|
||||||
headers_dict = json.loads(headers) if headers else {}
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return RedirectResponse(
|
|
||||||
url=f"/admin/endpoints/{endpoint_id}?error=Invalid+JSON+in+headers",
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build update dict (only include fields that are not None)
|
|
||||||
update_data = {}
|
|
||||||
if route is not None:
|
|
||||||
update_data["route"] = route
|
|
||||||
if method is not None:
|
|
||||||
update_data["method"] = method
|
|
||||||
if response_body is not None:
|
|
||||||
update_data["response_body"] = response_body
|
|
||||||
if response_code is not None:
|
|
||||||
update_data["response_code"] = response_code
|
|
||||||
if content_type is not None:
|
|
||||||
update_data["content_type"] = content_type
|
|
||||||
if is_active is not None:
|
|
||||||
update_data["is_active"] = is_active
|
|
||||||
if variables_dict is not None:
|
|
||||||
update_data["variables"] = variables_dict
|
|
||||||
if headers_dict is not None:
|
|
||||||
update_data["headers"] = headers_dict
|
|
||||||
if delay_ms is not None:
|
|
||||||
update_data["delay_ms"] = delay_ms
|
|
||||||
|
|
||||||
# Validate using Pydantic schema (optional fields)
|
|
||||||
try:
|
|
||||||
validated = EndpointUpdate(**update_data).dict(exclude_unset=True)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Validation error: {e}")
|
|
||||||
return RedirectResponse(
|
|
||||||
url=f"/admin/endpoints/{endpoint_id}?error=" + str(e).replace(" ", "+"),
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update endpoint
|
|
||||||
endpoint = await repository.update(endpoint_id, validated)
|
|
||||||
if not endpoint:
|
|
||||||
return RedirectResponse(
|
|
||||||
url=f"/admin/endpoints/{endpoint_id}?error=Failed+to+update+endpoint",
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Updated endpoint {endpoint_id}")
|
|
||||||
# Refresh routes to reflect changes
|
|
||||||
await route_manager.refresh_routes()
|
|
||||||
|
|
||||||
return RedirectResponse(url="/admin/endpoints", status_code=status.HTTP_302_FOUND)
|
|
||||||
|
|
||||||
@router.post("/endpoints/{endpoint_id}", response_class=RedirectResponse, include_in_schema=False)
|
|
||||||
async def delete_endpoint(
|
|
||||||
request: Request,
|
|
||||||
endpoint_id: int,
|
|
||||||
repository: EndpointRepository = Depends(get_repository),
|
|
||||||
route_manager: RouteManager = Depends(get_route_manager),
|
|
||||||
):
|
|
||||||
"""Delete an endpoint (handled via POST with _method=DELETE)."""
|
|
||||||
# Check if method override is present (HTML forms can't send DELETE)
|
|
||||||
form = await request.form()
|
|
||||||
if form.get("_method") != "DELETE":
|
|
||||||
# Fallback to update
|
|
||||||
return await update_endpoint(request, endpoint_id, repository=repository, route_manager=route_manager)
|
|
||||||
|
|
||||||
success = await repository.delete(endpoint_id)
|
|
||||||
if not success:
|
|
||||||
return RedirectResponse(
|
|
||||||
url=f"/admin/endpoints?error=Failed+to+delete+endpoint",
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Deleted endpoint {endpoint_id}")
|
|
||||||
# Refresh routes to remove deleted endpoint
|
|
||||||
await route_manager.refresh_routes()
|
|
||||||
|
|
||||||
return RedirectResponse(url="/admin/endpoints", status_code=status.HTTP_302_FOUND)
|
|
||||||
|
|
||||||
# ---------- OAuth2 Management Routes ----------
|
|
||||||
@router.get("/oauth/clients", response_class=HTMLResponse, tags=["admin-oauth"])
|
|
||||||
async def list_oauth_clients(
|
|
||||||
request: Request,
|
|
||||||
page: int = 1,
|
|
||||||
repository: OAuthClientRepository = Depends(get_oauth_client_repository),
|
|
||||||
):
|
|
||||||
"""List all OAuth clients with pagination."""
|
|
||||||
skip = (page - 1) * PAGE_SIZE
|
|
||||||
clients = await repository.get_all(skip=skip, limit=PAGE_SIZE)
|
|
||||||
total = len(await repository.get_all(limit=1000)) # naive count
|
|
||||||
total_pages = (total + PAGE_SIZE - 1) // PAGE_SIZE if total > 0 else 1
|
|
||||||
|
|
||||||
# Ensure page is within bounds
|
|
||||||
if page < 1 or (total_pages > 0 and page > total_pages):
|
|
||||||
return RedirectResponse(url="/admin/oauth/clients?page=1")
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"admin/oauth/clients.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"session": request.session,
|
|
||||||
"clients": clients,
|
|
||||||
"page": page,
|
|
||||||
"total_pages": total_pages,
|
|
||||||
"error": request.query_params.get("error"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get("/oauth/clients/new", response_class=HTMLResponse, tags=["admin-oauth"])
|
|
||||||
async def new_oauth_client_form(request: Request):
|
|
||||||
"""Display form to create a new OAuth client."""
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"admin/oauth/client_form.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"session": request.session,
|
|
||||||
"action": "Create",
|
|
||||||
"form_action": "/admin/oauth/clients",
|
|
||||||
"client": None,
|
|
||||||
"errors": {},
|
|
||||||
"error": request.query_params.get("error"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.post("/oauth/clients", response_class=RedirectResponse, tags=["admin-oauth"])
|
|
||||||
async def create_oauth_client(
|
|
||||||
request: Request,
|
|
||||||
client_name: str = Form(...),
|
|
||||||
redirect_uris: str = Form(...),
|
|
||||||
grant_types: str = Form(...),
|
|
||||||
scopes: str = Form(...),
|
|
||||||
is_active: bool = Form(True),
|
|
||||||
repository: OAuthClientRepository = Depends(get_oauth_client_repository),
|
|
||||||
):
|
|
||||||
"""Create a new OAuth client."""
|
|
||||||
try:
|
|
||||||
# Prepare client data with generated credentials
|
|
||||||
data = prepare_client_data(
|
|
||||||
client_name=client_name,
|
|
||||||
redirect_uris=redirect_uris,
|
|
||||||
grant_types=grant_types,
|
|
||||||
scopes=scopes,
|
|
||||||
is_active=is_active,
|
|
||||||
)
|
|
||||||
plain_secret = data.pop("_plain_secret")
|
|
||||||
|
|
||||||
# Validate using Pydantic schema
|
|
||||||
client_data = OAuthClientCreate(**data).dict()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Validation error: {e}")
|
|
||||||
return RedirectResponse(
|
|
||||||
url="/admin/oauth/clients/new?error=" + str(e).replace(" ", "+"),
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create client
|
|
||||||
client = await repository.create(client_data)
|
|
||||||
if not client:
|
|
||||||
return RedirectResponse(
|
|
||||||
url="/admin/oauth/clients/new?error=Failed+to+create+client",
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Created OAuth client {client.client_id}")
|
|
||||||
# TODO: Display client secret only once (store in flash message)
|
|
||||||
# For now, redirect to list with success message
|
|
||||||
return RedirectResponse(url="/admin/oauth/clients", status_code=status.HTTP_302_FOUND)
|
|
||||||
|
|
||||||
@router.get("/oauth/clients/{client_id}/edit", response_class=HTMLResponse, tags=["admin-oauth"])
|
|
||||||
async def edit_oauth_client_form(
|
|
||||||
request: Request,
|
|
||||||
client_id: int,
|
|
||||||
repository: OAuthClientRepository = Depends(get_oauth_client_repository),
|
|
||||||
):
|
|
||||||
"""Display form to edit an existing OAuth client."""
|
|
||||||
client = await repository.get_by_id(client_id)
|
|
||||||
if not client:
|
|
||||||
raise HTTPException(status_code=404, detail="Client not found")
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"admin/oauth/client_form.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"session": request.session,
|
|
||||||
"action": "Edit",
|
|
||||||
"form_action": f"/admin/oauth/clients/{client_id}",
|
|
||||||
"client": client,
|
|
||||||
"errors": {},
|
|
||||||
"error": request.query_params.get("error"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.post("/oauth/clients/{client_id}", response_class=RedirectResponse, tags=["admin-oauth"])
|
|
||||||
async def update_oauth_client(
|
|
||||||
request: Request,
|
|
||||||
client_id: int,
|
|
||||||
client_name: Optional[str] = Form(None),
|
|
||||||
redirect_uris: Optional[str] = Form(None),
|
|
||||||
grant_types: Optional[str] = Form(None),
|
|
||||||
scopes: Optional[str] = Form(None),
|
|
||||||
is_active: Optional[bool] = Form(None),
|
|
||||||
repository: OAuthClientRepository = Depends(get_oauth_client_repository),
|
|
||||||
):
|
|
||||||
"""Update an existing OAuth client."""
|
|
||||||
# Build update dict
|
|
||||||
update_data = {}
|
|
||||||
if client_name is not None:
|
|
||||||
update_data["name"] = client_name
|
|
||||||
if redirect_uris is not None:
|
|
||||||
update_data["redirect_uris"] = [uri.strip() for uri in redirect_uris.split(",") if uri.strip()]
|
|
||||||
if grant_types is not None:
|
|
||||||
update_data["grant_types"] = [gt.strip() for gt in grant_types.split(",") if gt.strip()]
|
|
||||||
if scopes is not None:
|
|
||||||
update_data["scopes"] = [scope.strip() for scope in scopes.split(",") if scope.strip()]
|
|
||||||
if is_active is not None:
|
|
||||||
update_data["is_active"] = is_active
|
|
||||||
|
|
||||||
if not update_data:
|
|
||||||
return RedirectResponse(url=f"/admin/oauth/clients/{client_id}/edit", status_code=status.HTTP_302_FOUND)
|
|
||||||
|
|
||||||
# Validate using Pydantic schema (optional fields)
|
|
||||||
try:
|
|
||||||
validated = OAuthClientUpdate(**update_data).dict(exclude_unset=True)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Validation error: {e}")
|
|
||||||
return RedirectResponse(
|
|
||||||
url=f"/admin/oauth/clients/{client_id}/edit?error=" + str(e).replace(" ", "+"),
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update client
|
|
||||||
client = await repository.update(client_id, validated)
|
|
||||||
if not client:
|
|
||||||
return RedirectResponse(
|
|
||||||
url=f"/admin/oauth/clients/{client_id}/edit?error=Failed+to+update+client",
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Updated OAuth client {client_id}")
|
|
||||||
return RedirectResponse(url="/admin/oauth/clients", status_code=status.HTTP_302_FOUND)
|
|
||||||
|
|
||||||
@router.post("/oauth/clients/{client_id}/delete", response_class=RedirectResponse, tags=["admin-oauth"])
|
|
||||||
async def delete_oauth_client(
|
|
||||||
request: Request,
|
|
||||||
client_id: int,
|
|
||||||
repository: OAuthClientRepository = Depends(get_oauth_client_repository),
|
|
||||||
):
|
|
||||||
"""Delete a client (soft delete via is_active=False)."""
|
|
||||||
client = await repository.update(client_id, {"is_active": False})
|
|
||||||
if not client:
|
|
||||||
return RedirectResponse(
|
|
||||||
url="/admin/oauth/clients?error=Failed+to+delete+client",
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Soft-deleted OAuth client {client_id}")
|
|
||||||
return RedirectResponse(url="/admin/oauth/clients", status_code=status.HTTP_302_FOUND)
|
|
||||||
|
|
||||||
@router.get("/oauth/tokens", response_class=HTMLResponse, tags=["admin-oauth"])
|
|
||||||
async def list_oauth_tokens(
|
|
||||||
request: Request,
|
|
||||||
page: int = 1,
|
|
||||||
client_id: Optional[str] = None,
|
|
||||||
user_id: Optional[int] = None,
|
|
||||||
active: Optional[bool] = None,
|
|
||||||
repository: OAuthTokenRepository = Depends(get_oauth_token_repository),
|
|
||||||
):
|
|
||||||
"""List OAuth tokens with filtering (client, user, active/expired)."""
|
|
||||||
# Fetch all tokens (limited to reasonable count) for filtering
|
|
||||||
all_tokens = await repository.get_all(limit=1000)
|
|
||||||
|
|
||||||
# Apply filters
|
|
||||||
filtered = []
|
|
||||||
for token in all_tokens:
|
|
||||||
if client_id is not None and token.client_id != client_id:
|
|
||||||
continue
|
|
||||||
if user_id is not None and token.user_id != user_id:
|
|
||||||
continue
|
|
||||||
if active is not None:
|
|
||||||
is_expired = token.expires_at < datetime.utcnow()
|
|
||||||
if active and is_expired:
|
|
||||||
continue
|
|
||||||
if not active and not is_expired:
|
|
||||||
continue
|
|
||||||
filtered.append(token)
|
|
||||||
|
|
||||||
# Pagination after filtering
|
|
||||||
total = len(filtered)
|
|
||||||
total_pages = (total + PAGE_SIZE - 1) // PAGE_SIZE if total > 0 else 1
|
|
||||||
|
|
||||||
# Ensure page is within bounds
|
|
||||||
if page < 1 or (total_pages > 0 and page > total_pages):
|
|
||||||
return RedirectResponse(url="/admin/oauth/tokens?page=1")
|
|
||||||
|
|
||||||
skip = (page - 1) * PAGE_SIZE
|
|
||||||
tokens = filtered[skip:skip + PAGE_SIZE]
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"admin/oauth/tokens.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"session": request.session,
|
|
||||||
"tokens": tokens,
|
|
||||||
"page": page,
|
|
||||||
"total_pages": total_pages,
|
|
||||||
"client_id": client_id,
|
|
||||||
"user_id": user_id,
|
|
||||||
"active": active,
|
|
||||||
"now": datetime.utcnow(),
|
|
||||||
"error": request.query_params.get("error"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.post("/oauth/tokens/{token_id}/revoke", response_class=RedirectResponse, tags=["admin-oauth"])
|
|
||||||
async def revoke_oauth_token(
|
|
||||||
request: Request,
|
|
||||||
token_id: int,
|
|
||||||
repository: OAuthTokenRepository = Depends(get_oauth_token_repository),
|
|
||||||
):
|
|
||||||
"""Revoke token (delete from database)."""
|
|
||||||
success = await repository.delete(token_id)
|
|
||||||
if not success:
|
|
||||||
return RedirectResponse(
|
|
||||||
url="/admin/oauth/tokens?error=Failed+to+revoke+token",
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Revoked OAuth token {token_id}")
|
|
||||||
return RedirectResponse(url="/admin/oauth/tokens", status_code=status.HTTP_302_FOUND)
|
|
||||||
|
|
||||||
@router.get("/oauth/users", response_class=HTMLResponse, tags=["admin-oauth"])
|
|
||||||
async def list_oauth_users(
|
|
||||||
request: Request,
|
|
||||||
page: int = 1,
|
|
||||||
repository: OAuthUserRepository = Depends(get_oauth_user_repository),
|
|
||||||
):
|
|
||||||
"""List OAuth users."""
|
|
||||||
skip = (page - 1) * PAGE_SIZE
|
|
||||||
users = await repository.get_all(skip=skip, limit=PAGE_SIZE)
|
|
||||||
total = len(await repository.get_all(limit=1000)) # naive count
|
|
||||||
total_pages = (total + PAGE_SIZE - 1) // PAGE_SIZE if total > 0 else 1
|
|
||||||
|
|
||||||
# Ensure page is within bounds
|
|
||||||
if page < 1 or (total_pages > 0 and page > total_pages):
|
|
||||||
return RedirectResponse(url="/admin/oauth/users?page=1")
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"admin/oauth/users.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"session": request.session,
|
|
||||||
"users": users,
|
|
||||||
"page": page,
|
|
||||||
"total_pages": total_pages,
|
|
||||||
"error": request.query_params.get("error"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.post("/oauth/users/{user_id}/toggle", response_class=RedirectResponse, tags=["admin-oauth"])
|
|
||||||
async def toggle_oauth_user(
|
|
||||||
request: Request,
|
|
||||||
user_id: int,
|
|
||||||
repository: OAuthUserRepository = Depends(get_oauth_user_repository),
|
|
||||||
):
|
|
||||||
"""Toggle user active status."""
|
|
||||||
user = await repository.get_by_id(user_id)
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse(
|
|
||||||
url="/admin/oauth/users?error=User+not+found",
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
new_status = not user.is_active
|
|
||||||
updated = await repository.update(user_id, {"is_active": new_status})
|
|
||||||
if not updated:
|
|
||||||
return RedirectResponse(
|
|
||||||
url="/admin/oauth/users?error=Failed+to+toggle+user",
|
|
||||||
status_code=status.HTTP_302_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Toggled OAuth user {user_id} active status to {new_status}")
|
|
||||||
return RedirectResponse(url="/admin/oauth/users", status_code=status.HTTP_302_FOUND)
|
|
||||||
63
main.py
Normal file
63
main.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Unified entry point for the Mock API application.
|
||||||
|
|
||||||
|
This module serves as both the development entry point (via uvicorn) and the
|
||||||
|
production WSGI entry point (via Waitress or other WSGI servers).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from a2wsgi import ASGIMiddleware
|
||||||
|
from app.core.app import create_app
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Create the FastAPI application instance
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def create_wsgi_app():
|
||||||
|
"""
|
||||||
|
Create a WSGI application with route refresh on startup.
|
||||||
|
|
||||||
|
This function is intended for production WSGI servers (e.g., Waitress).
|
||||||
|
Since WSGI does not support ASGI lifespan events, we manually refresh
|
||||||
|
routes from the database once when the WSGI app is created.
|
||||||
|
"""
|
||||||
|
loop = None
|
||||||
|
try:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
route_manager = app.state.route_manager
|
||||||
|
logger.info("Refreshing routes from database (WSGI startup)...")
|
||||||
|
loop.run_until_complete(route_manager.refresh_routes())
|
||||||
|
logger.info(f"Registered {len(route_manager.registered_routes)} routes")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to refresh routes on startup: {e}")
|
||||||
|
# Continue anyway; routes can be refreshed later via admin interface
|
||||||
|
finally:
|
||||||
|
if loop is not None:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
# Wrap FastAPI ASGI app with WSGI adapter
|
||||||
|
wsgi_app = ASGIMiddleware(app)
|
||||||
|
return wsgi_app
|
||||||
|
|
||||||
|
|
||||||
|
# Expose the WSGI application for production servers
|
||||||
|
wsgi_app = create_wsgi_app()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Development entry point: run uvicorn with auto‑reload
|
||||||
|
import uvicorn
|
||||||
|
logger.info("Starting development server on http://0.0.0.0:8000")
|
||||||
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8000,
|
||||||
|
reload=True,
|
||||||
|
log_level="info"
|
||||||
|
)
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
from .endpoint_model import Endpoint
|
|
||||||
from .oauth_models import OAuthClient, OAuthToken, OAuthUser
|
|
||||||
|
|
||||||
__all__ = ["Endpoint", "OAuthClient", "OAuthToken", "OAuthUser"]
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
from sqlalchemy import Column, Integer, String, Boolean, Text, TIMESTAMP, UniqueConstraint
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
from sqlalchemy.dialects.sqlite import JSON
|
|
||||||
from database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Endpoint(Base):
|
|
||||||
__tablename__ = "endpoints"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
route = Column(String(500), nullable=False)
|
|
||||||
method = Column(String(10), nullable=False) # GET, POST, etc.
|
|
||||||
response_body = Column(Text, nullable=False)
|
|
||||||
response_code = Column(Integer, nullable=False, default=200)
|
|
||||||
content_type = Column(String(100), nullable=False, default="application/json")
|
|
||||||
is_active = Column(Boolean, nullable=False, default=True)
|
|
||||||
variables = Column(JSON, default=dict) # Default template variables
|
|
||||||
headers = Column(JSON, default=dict) # Custom response headers
|
|
||||||
delay_ms = Column(Integer, default=0) # Artificial delay in milliseconds
|
|
||||||
requires_oauth = Column(Boolean, default=False)
|
|
||||||
oauth_scopes = Column(JSON, default=list) # List of required OAuth scopes
|
|
||||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
|
||||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint('route', 'method', name='uq_endpoint_route_method'),
|
|
||||||
{"sqlite_autoincrement": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"<Endpoint {self.method} {self.route}>"
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
from .endpoint_repository import EndpointRepository
|
|
||||||
|
|
||||||
__all__ = ["EndpointRepository"]
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
from typing import List, Optional
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy import select, update, delete
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from models.endpoint_model import Endpoint
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class EndpointRepository:
|
|
||||||
"""Repository for performing CRUD operations on Endpoint model."""
|
|
||||||
|
|
||||||
def __init__(self, session: AsyncSession):
|
|
||||||
self.session = session
|
|
||||||
|
|
||||||
async def create(self, endpoint_data: dict) -> Optional[Endpoint]:
|
|
||||||
"""
|
|
||||||
Create a new endpoint.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
endpoint_data: Dictionary with endpoint fields.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Endpoint instance if successful, None otherwise.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
endpoint = Endpoint(**endpoint_data)
|
|
||||||
self.session.add(endpoint)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(endpoint)
|
|
||||||
return endpoint
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
logger.error(f"Failed to create endpoint: {e}")
|
|
||||||
await self.session.rollback()
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_by_id(self, endpoint_id: int) -> Optional[Endpoint]:
|
|
||||||
"""
|
|
||||||
Retrieve an endpoint by its ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
endpoint_id: The endpoint ID.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Endpoint if found, None otherwise.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
stmt = select(Endpoint).where(Endpoint.id == endpoint_id)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
logger.error(f"Failed to fetch endpoint by id {endpoint_id}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_all(self, skip: int = 0, limit: int = 100) -> List[Endpoint]:
|
|
||||||
"""
|
|
||||||
Retrieve all endpoints with pagination.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
skip: Number of records to skip.
|
|
||||||
limit: Maximum number of records to return.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of Endpoint objects.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
stmt = select(Endpoint).offset(skip).limit(limit)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return list(result.scalars().all())
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
logger.error(f"Failed to fetch all endpoints: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def get_active(self) -> List[Endpoint]:
|
|
||||||
"""
|
|
||||||
Retrieve all active endpoints.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of active Endpoint objects.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
stmt = select(Endpoint).where(Endpoint.is_active == True)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return list(result.scalars().all())
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
logger.error(f"Failed to fetch active endpoints: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def update(self, endpoint_id: int, endpoint_data: dict) -> Optional[Endpoint]:
|
|
||||||
"""
|
|
||||||
Update an existing endpoint.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
endpoint_id: The endpoint ID.
|
|
||||||
endpoint_data: Dictionary of fields to update.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Updated Endpoint if successful, None otherwise.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
stmt = (
|
|
||||||
update(Endpoint)
|
|
||||||
.where(Endpoint.id == endpoint_id)
|
|
||||||
.values(**endpoint_data)
|
|
||||||
.returning(Endpoint)
|
|
||||||
)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
await self.session.commit()
|
|
||||||
endpoint = result.scalar_one_or_none()
|
|
||||||
if endpoint:
|
|
||||||
await self.session.refresh(endpoint)
|
|
||||||
return endpoint
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
logger.error(f"Failed to update endpoint {endpoint_id}: {e}")
|
|
||||||
await self.session.rollback()
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def delete(self, endpoint_id: int) -> bool:
|
|
||||||
"""
|
|
||||||
Delete an endpoint by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
endpoint_id: The endpoint ID.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if deletion succeeded, False otherwise.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
stmt = delete(Endpoint).where(Endpoint.id == endpoint_id)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
await self.session.commit()
|
|
||||||
return result.rowcount > 0
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
logger.error(f"Failed to delete endpoint {endpoint_id}: {e}")
|
|
||||||
await self.session.rollback()
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def get_by_route_and_method(self, route: str, method: str) -> Optional[Endpoint]:
|
|
||||||
"""
|
|
||||||
Retrieve an endpoint by route and HTTP method.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
route: The endpoint route (path).
|
|
||||||
method: HTTP method (GET, POST, etc.).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Endpoint if found, None otherwise.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
stmt = select(Endpoint).where(
|
|
||||||
Endpoint.route == route,
|
|
||||||
Endpoint.method == method.upper()
|
|
||||||
)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
logger.error(f"Failed to fetch endpoint {method} {route}: {e}")
|
|
||||||
return None
|
|
||||||
15
run.py
15
run.py
|
|
@ -1,15 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Run the Mock API Server.
|
|
||||||
"""
|
|
||||||
import uvicorn
|
|
||||||
from app import app
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
uvicorn.run(
|
|
||||||
"app:app",
|
|
||||||
host="0.0.0.0",
|
|
||||||
port=8000,
|
|
||||||
reload=True,
|
|
||||||
log_level="info"
|
|
||||||
)
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
from .endpoint_schema import (
|
|
||||||
EndpointBase,
|
|
||||||
EndpointCreate,
|
|
||||||
EndpointUpdate,
|
|
||||||
EndpointResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"EndpointBase",
|
|
||||||
"EndpointCreate",
|
|
||||||
"EndpointUpdate",
|
|
||||||
"EndpointResponse",
|
|
||||||
]
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
import json
|
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
from datetime import datetime
|
|
||||||
from pydantic import BaseModel, Field, field_validator, ConfigDict, Json
|
|
||||||
|
|
||||||
|
|
||||||
HTTP_METHODS = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE"}
|
|
||||||
|
|
||||||
|
|
||||||
class EndpointBase(BaseModel):
|
|
||||||
"""Base schema with common fields."""
|
|
||||||
route: str = Field(..., description="Endpoint route (must start with '/')", max_length=500)
|
|
||||||
method: str = Field(..., description="HTTP method", max_length=10)
|
|
||||||
response_body: str = Field(..., description="Response body (supports Jinja2 templating)")
|
|
||||||
response_code: int = Field(200, description="HTTP status code", ge=100, le=599)
|
|
||||||
content_type: str = Field("application/json", description="Content-Type header", max_length=100)
|
|
||||||
is_active: bool = Field(True, description="Whether endpoint is active")
|
|
||||||
variables: Dict[str, Any] = Field(default_factory=dict, description="Default template variables")
|
|
||||||
headers: Dict[str, str] = Field(default_factory=dict, description="Custom response headers")
|
|
||||||
delay_ms: int = Field(0, description="Artificial delay in milliseconds", ge=0, le=30000)
|
|
||||||
|
|
||||||
@field_validator("route")
|
|
||||||
def route_must_start_with_slash(cls, v):
|
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("Route must start with '/'")
|
|
||||||
# Prevent path traversal
|
|
||||||
if ".." in v:
|
|
||||||
raise ValueError("Route must not contain '..'")
|
|
||||||
# Prevent consecutive slashes (simplifies routing)
|
|
||||||
if "//" in v:
|
|
||||||
raise ValueError("Route must not contain consecutive slashes '//'")
|
|
||||||
# Prevent backslashes
|
|
||||||
if "\\" in v:
|
|
||||||
raise ValueError("Route must not contain backslashes")
|
|
||||||
# Ensure path is not empty after slash
|
|
||||||
if v == "/":
|
|
||||||
return v
|
|
||||||
# Ensure no trailing slash? We'll allow.
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("method")
|
|
||||||
def method_must_be_valid(cls, v):
|
|
||||||
method = v.upper()
|
|
||||||
if method not in HTTP_METHODS:
|
|
||||||
raise ValueError(f"Method must be one of {HTTP_METHODS}")
|
|
||||||
return method
|
|
||||||
|
|
||||||
|
|
||||||
@field_validator('variables', 'headers')
|
|
||||||
def validate_json_serializable(cls, v):
|
|
||||||
# Ensure the value is JSON serializable
|
|
||||||
try:
|
|
||||||
json.dumps(v)
|
|
||||||
except (TypeError, ValueError) as e:
|
|
||||||
raise ValueError(f"Value must be JSON serializable: {e}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class EndpointCreate(EndpointBase):
|
|
||||||
"""Schema for creating a new endpoint."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class EndpointUpdate(BaseModel):
|
|
||||||
"""Schema for updating an existing endpoint (all fields optional)."""
|
|
||||||
route: Optional[str] = Field(None, description="Endpoint route (must start with '/')", max_length=500)
|
|
||||||
method: Optional[str] = Field(None, description="HTTP method", max_length=10)
|
|
||||||
response_body: Optional[str] = Field(None, description="Response body (supports Jinja2 templating)")
|
|
||||||
response_code: Optional[int] = Field(None, description="HTTP status code", ge=100, le=599)
|
|
||||||
content_type: Optional[str] = Field(None, description="Content-Type header", max_length=100)
|
|
||||||
is_active: Optional[bool] = Field(None, description="Whether endpoint is active")
|
|
||||||
variables: Optional[Dict[str, Any]] = Field(None, description="Default template variables")
|
|
||||||
headers: Optional[Dict[str, str]] = Field(None, description="Custom response headers")
|
|
||||||
delay_ms: Optional[int] = Field(None, description="Artificial delay in milliseconds", ge=0, le=30000)
|
|
||||||
|
|
||||||
@field_validator("route")
|
|
||||||
def route_must_start_with_slash(cls, v):
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("Route must start with '/'")
|
|
||||||
# Prevent path traversal
|
|
||||||
if ".." in v:
|
|
||||||
raise ValueError("Route must not contain '..'")
|
|
||||||
# Prevent consecutive slashes (simplifies routing)
|
|
||||||
if "//" in v:
|
|
||||||
raise ValueError("Route must not contain consecutive slashes '//'")
|
|
||||||
# Prevent backslashes
|
|
||||||
if "\\" in v:
|
|
||||||
raise ValueError("Route must not contain backslashes")
|
|
||||||
# Ensure path is not empty after slash
|
|
||||||
if v == "/":
|
|
||||||
return v
|
|
||||||
# Ensure no trailing slash? We'll allow.
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("method")
|
|
||||||
def method_must_be_valid(cls, v):
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
method = v.upper()
|
|
||||||
if method not in HTTP_METHODS:
|
|
||||||
raise ValueError(f"Method must be one of {HTTP_METHODS}")
|
|
||||||
return method
|
|
||||||
|
|
||||||
@field_validator('variables', 'headers')
|
|
||||||
def validate_json_serializable(cls, v):
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
# Ensure the value is JSON serializable
|
|
||||||
try:
|
|
||||||
json.dumps(v)
|
|
||||||
except (TypeError, ValueError) as e:
|
|
||||||
raise ValueError(f"Value must be JSON serializable: {e}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class EndpointResponse(EndpointBase):
|
|
||||||
"""Schema for returning an endpoint (includes ID and timestamps)."""
|
|
||||||
id: int
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True) # Enables ORM mode (formerly `orm_mode`)
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import inspect
|
import inspect
|
||||||
from asgiref.wsgi import WsgiToAsgi
|
from asgiref.wsgi import WsgiToAsgi
|
||||||
from app import app
|
from app.core.app import app
|
||||||
|
|
||||||
print("app callable?", callable(app))
|
print("app callable?", callable(app))
|
||||||
print("app signature:", inspect.signature(app.__call__))
|
print("app signature:", inspect.signature(app.__call__))
|
||||||
|
|
@ -33,9 +33,9 @@ os.environ['SECRET_KEY'] = 'test-secret-key'
|
||||||
# Add current directory to path
|
# Add current directory to path
|
||||||
sys.path.insert(0, '.')
|
sys.path.insert(0, '.')
|
||||||
|
|
||||||
from app import app
|
from app.core.app import app
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from database import init_db
|
from app.core.database import init_db
|
||||||
|
|
||||||
|
|
||||||
async def setup_database():
|
async def setup_database():
|
||||||
|
|
@ -16,7 +16,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
# Import only what we need
|
# Import only what we need
|
||||||
try:
|
try:
|
||||||
from middleware.auth_middleware import get_password_hash
|
from app.core.middleware.auth_middleware import get_password_hash
|
||||||
HAS_AUTH = True
|
HAS_AUTH = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Fallback to simple hash function if middleware not available
|
# Fallback to simple hash function if middleware not available
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
from .route_service import RouteManager
|
|
||||||
from .template_service import TemplateService
|
|
||||||
|
|
||||||
__all__ = ["RouteManager", "TemplateService"]
|
|
||||||
|
|
@ -1,370 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import Dict, Any, Optional, Tuple, Callable
|
|
||||||
from uuid import uuid4
|
|
||||||
import jinja2
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Response, status, HTTPException
|
|
||||||
from fastapi.routing import APIRoute
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from config import settings
|
|
||||||
|
|
||||||
from models.endpoint_model import Endpoint
|
|
||||||
from repositories.endpoint_repository import EndpointRepository
|
|
||||||
from services.template_service import TemplateService
|
|
||||||
from oauth2.services import TokenService, ScopeService
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class RouteManager:
|
|
||||||
"""
|
|
||||||
Manages dynamic route registration and removal for the FastAPI application.
|
|
||||||
"""
|
|
||||||
__slots__ = ('app', 'async_session_factory', 'template_service', 'registered_routes', '_routes_lock')
|
|
||||||
MAX_BODY_SIZE = 1024 * 1024 # 1 MB
|
|
||||||
|
|
||||||
def __init__(self, app: FastAPI, async_session_factory: Callable[[], AsyncSession]):
|
|
||||||
self.app = app
|
|
||||||
self.async_session_factory = async_session_factory
|
|
||||||
self.template_service = TemplateService()
|
|
||||||
self.registered_routes: Dict[Tuple[str, str], str] = {}
|
|
||||||
self._routes_lock = asyncio.Lock()
|
|
||||||
# Maps (route, method) to route_id (used by FastAPI for removal)
|
|
||||||
|
|
||||||
async def register_endpoint(self, endpoint: Endpoint) -> bool:
|
|
||||||
"""
|
|
||||||
Register a single endpoint as a route in the FastAPI app.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
endpoint: The Endpoint model instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if registration succeeded, False otherwise.
|
|
||||||
"""
|
|
||||||
async with self._routes_lock:
|
|
||||||
try:
|
|
||||||
# Create a unique route identifier for FastAPI
|
|
||||||
method = endpoint.method.upper()
|
|
||||||
route_id = f"{method}_{endpoint.route}_{uuid4().hex[:8]}"
|
|
||||||
|
|
||||||
# Create handler closure with endpoint data
|
|
||||||
async def endpoint_handler(request: Request) -> Response:
|
|
||||||
return await self._handle_request(request, endpoint)
|
|
||||||
|
|
||||||
# Add route to FastAPI
|
|
||||||
self.app.add_api_route(
|
|
||||||
endpoint.route,
|
|
||||||
endpoint_handler,
|
|
||||||
methods=[method],
|
|
||||||
name=route_id,
|
|
||||||
response_model=None, # We'll return raw Response
|
|
||||||
)
|
|
||||||
|
|
||||||
self.registered_routes[(endpoint.route, method)] = route_id
|
|
||||||
logger.info(f"Registered endpoint {method} {endpoint.route}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except (ValueError, RuntimeError, TypeError, AttributeError) as e:
|
|
||||||
logger.error(f"Failed to register endpoint {endpoint}: {e}", exc_info=settings.debug)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def unregister_endpoint(self, route: str, method: str) -> bool:
|
|
||||||
"""
|
|
||||||
Remove a previously registered route.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
route: The endpoint route.
|
|
||||||
method: HTTP method.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if removal succeeded, False otherwise.
|
|
||||||
"""
|
|
||||||
async with self._routes_lock:
|
|
||||||
method = method.upper()
|
|
||||||
key = (route, method)
|
|
||||||
if key not in self.registered_routes:
|
|
||||||
logger.warning(f"Route {method} {route} not registered")
|
|
||||||
return False
|
|
||||||
|
|
||||||
route_id = self.registered_routes[key]
|
|
||||||
found = False
|
|
||||||
# Find the route in the app's router and remove it
|
|
||||||
for r in list(self.app.routes):
|
|
||||||
if isinstance(r, APIRoute) and r.name == route_id:
|
|
||||||
self.app.routes.remove(r)
|
|
||||||
found = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if found:
|
|
||||||
logger.info(f"Unregistered endpoint {method} {route}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"Route with ID {route_id} not found in FastAPI routes")
|
|
||||||
# Always remove from registered_routes (cleanup)
|
|
||||||
del self.registered_routes[key]
|
|
||||||
return found
|
|
||||||
|
|
||||||
async def refresh_routes(self) -> int:
|
|
||||||
"""
|
|
||||||
Reload all active endpoints from repository and register them.
|
|
||||||
Removes any previously registered routes that are no longer active.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of active routes after refresh.
|
|
||||||
"""
|
|
||||||
# Fetch active endpoints using a fresh session
|
|
||||||
async with self.async_session_factory() as session:
|
|
||||||
repository = EndpointRepository(session)
|
|
||||||
active_endpoints = await repository.get_active()
|
|
||||||
active_keys = {(e.route, e.method.upper()) for e in active_endpoints}
|
|
||||||
|
|
||||||
async with self._routes_lock:
|
|
||||||
# Unregister routes that are no longer active
|
|
||||||
# Create a copy of items to avoid modification during iteration
|
|
||||||
to_unregister = []
|
|
||||||
for (route, method), route_id in list(self.registered_routes.items()):
|
|
||||||
if (route, method) not in active_keys:
|
|
||||||
to_unregister.append((route, method))
|
|
||||||
|
|
||||||
# Register new active endpoints
|
|
||||||
to_register = []
|
|
||||||
for endpoint in active_endpoints:
|
|
||||||
key = (endpoint.route, endpoint.method.upper())
|
|
||||||
if key not in self.registered_routes:
|
|
||||||
to_register.append(endpoint)
|
|
||||||
|
|
||||||
# Now perform unregistration and registration without holding the lock
|
|
||||||
# (each submethod will acquire its own lock)
|
|
||||||
for route, method in to_unregister:
|
|
||||||
await self.unregister_endpoint(route, method)
|
|
||||||
|
|
||||||
registered_count = 0
|
|
||||||
for endpoint in to_register:
|
|
||||||
success = await self.register_endpoint(endpoint)
|
|
||||||
if success:
|
|
||||||
registered_count += 1
|
|
||||||
|
|
||||||
logger.info(f"Routes refreshed. Total active routes: {len(self.registered_routes)}")
|
|
||||||
return len(self.registered_routes)
|
|
||||||
|
|
||||||
async def _handle_request(self, request: Request, endpoint: Endpoint) -> Response:
|
|
||||||
"""
|
|
||||||
Generic request handler for a registered endpoint.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: FastAPI Request object.
|
|
||||||
endpoint: Endpoint configuration.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
FastAPI Response object.
|
|
||||||
"""
|
|
||||||
# OAuth2 token validation if endpoint requires it
|
|
||||||
if endpoint.requires_oauth:
|
|
||||||
await self._validate_oauth_token(request, endpoint)
|
|
||||||
|
|
||||||
# Apply artificial delay if configured
|
|
||||||
if endpoint.delay_ms > 0:
|
|
||||||
await asyncio.sleep(endpoint.delay_ms / 1000.0)
|
|
||||||
|
|
||||||
# Gather variable sources
|
|
||||||
context = await self._build_template_context(request, endpoint)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Render response body using Jinja2 template
|
|
||||||
rendered_body = self.template_service.render(
|
|
||||||
endpoint.response_body,
|
|
||||||
context
|
|
||||||
)
|
|
||||||
except jinja2.TemplateError as e:
|
|
||||||
logger.error(f"Template rendering failed for endpoint {endpoint.id}: {e}", exc_info=settings.debug)
|
|
||||||
return Response(
|
|
||||||
content=json.dumps({"error": "Template rendering failed"}),
|
|
||||||
status_code=500,
|
|
||||||
media_type="application/json"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build response with custom headers
|
|
||||||
headers = dict(endpoint.headers or {})
|
|
||||||
response = Response(
|
|
||||||
content=rendered_body,
|
|
||||||
status_code=endpoint.response_code,
|
|
||||||
headers=headers,
|
|
||||||
media_type=endpoint.content_type
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
async def _build_template_context(self, request: Request, endpoint: Endpoint) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Build the template context from all variable sources.
|
|
||||||
|
|
||||||
Sources:
|
|
||||||
- Path parameters (from request.path_params)
|
|
||||||
- Query parameters (from request.query_params)
|
|
||||||
- Request headers
|
|
||||||
- Request body (JSON or raw text)
|
|
||||||
- System variables (timestamp, request_id, etc.)
|
|
||||||
- Endpoint default variables
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: FastAPI Request object.
|
|
||||||
endpoint: Endpoint configuration.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary of template variables.
|
|
||||||
"""
|
|
||||||
context = {}
|
|
||||||
|
|
||||||
# Path parameters
|
|
||||||
context.update({f"path_{k}": v for k, v in request.path_params.items()})
|
|
||||||
context.update(request.path_params)
|
|
||||||
|
|
||||||
# Query parameters
|
|
||||||
query_params = dict(request.query_params)
|
|
||||||
context.update({f"query_{k}": v for k, v in query_params.items()})
|
|
||||||
context.update(query_params)
|
|
||||||
|
|
||||||
# Request headers
|
|
||||||
headers = dict(request.headers)
|
|
||||||
context.update({f"header_{k.lower()}": v for k, v in headers.items()})
|
|
||||||
context.update({k.lower(): v for k, v in headers.items()})
|
|
||||||
|
|
||||||
# Request body
|
|
||||||
body = await self._extract_request_body(request)
|
|
||||||
if body is not None:
|
|
||||||
if isinstance(body, dict):
|
|
||||||
context.update({f"body_{k}": v for k, v in body.items()})
|
|
||||||
context.update(body)
|
|
||||||
else:
|
|
||||||
context["body"] = body
|
|
||||||
|
|
||||||
# System variables
|
|
||||||
context.update(self._get_system_variables(request))
|
|
||||||
|
|
||||||
# Endpoint default variables
|
|
||||||
if endpoint.variables:
|
|
||||||
context.update(endpoint.variables)
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
async def _extract_request_body(self, request: Request) -> Optional[Any]:
|
|
||||||
"""
|
|
||||||
Extract request body as JSON if possible, otherwise as text.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Parsed JSON (dict/list) or raw string, or None if no body.
|
|
||||||
"""
|
|
||||||
# Check content-length header
|
|
||||||
content_length = request.headers.get("content-length")
|
|
||||||
if content_length:
|
|
||||||
try:
|
|
||||||
if int(content_length) > self.MAX_BODY_SIZE:
|
|
||||||
raise HTTPException(status_code=413, detail="Request body too large")
|
|
||||||
except ValueError:
|
|
||||||
pass # Ignore malformed content-length
|
|
||||||
|
|
||||||
content_type = request.headers.get("content-type", "")
|
|
||||||
|
|
||||||
# Read body bytes once
|
|
||||||
body_bytes = await request.body()
|
|
||||||
if not body_bytes:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check actual body size
|
|
||||||
if len(body_bytes) > self.MAX_BODY_SIZE:
|
|
||||||
raise HTTPException(status_code=413, detail="Request body too large")
|
|
||||||
|
|
||||||
if "application/json" in content_type:
|
|
||||||
try:
|
|
||||||
return json.loads(body_bytes.decode("utf-8"))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# Fallback to raw text
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Return raw body as string
|
|
||||||
return body_bytes.decode("utf-8", errors="ignore")
|
|
||||||
|
|
||||||
async def _validate_oauth_token(self, request: Request, endpoint: Endpoint) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Validate OAuth2 Bearer token for endpoints that require authentication.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: FastAPI Request object.
|
|
||||||
endpoint: Endpoint configuration.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Validated token payload.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException with status 401/403 for missing, invalid, or insufficient scope tokens.
|
|
||||||
"""
|
|
||||||
# Extract Bearer token from Authorization header
|
|
||||||
auth_header = request.headers.get("Authorization")
|
|
||||||
if not auth_header:
|
|
||||||
logger.warning(f"OAuth2 token missing for endpoint {endpoint.method} {endpoint.route}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Missing Authorization header",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check Bearer scheme
|
|
||||||
parts = auth_header.split()
|
|
||||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid Authorization header format. Expected: Bearer <token>",
|
|
||||||
headers={"WWW-Authenticate": "Bearer error=\"invalid_token\""},
|
|
||||||
)
|
|
||||||
|
|
||||||
token = parts[1]
|
|
||||||
|
|
||||||
# Create a database session and validate token
|
|
||||||
async with self.async_session_factory() as session:
|
|
||||||
token_service = TokenService(session)
|
|
||||||
try:
|
|
||||||
payload = await token_service.verify_token(token)
|
|
||||||
except HTTPException:
|
|
||||||
raise # Re-raise token validation errors
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Unexpected error during token validation: {e}", exc_info=settings.debug)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Internal server error",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check scopes if endpoint specifies required scopes
|
|
||||||
if endpoint.oauth_scopes:
|
|
||||||
scope_service = ScopeService(session)
|
|
||||||
token_scopes = payload.get("scopes", [])
|
|
||||||
if not scope_service.check_scope_access(token_scopes, endpoint.oauth_scopes):
|
|
||||||
logger.warning(
|
|
||||||
f"Insufficient scopes for endpoint {endpoint.method} {endpoint.route}. "
|
|
||||||
f"Token scopes: {token_scopes}, required: {endpoint.oauth_scopes}"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Insufficient scope",
|
|
||||||
headers={"WWW-Authenticate": f"Bearer error=\"insufficient_scope\", scope=\"{' '.join(endpoint.oauth_scopes)}\""},
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(f"OAuth2 token validated for endpoint {endpoint.method} {endpoint.route}, client_id: {payload.get('client_id')}, scopes: {payload.get('scopes')}")
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def _get_system_variables(self, request: Request) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Generate system variables (e.g., timestamp, request ID).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary of system variables.
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"timestamp": time.time(),
|
|
||||||
"datetime": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
"request_id": str(uuid4()),
|
|
||||||
"method": request.method,
|
|
||||||
"url": str(request.url),
|
|
||||||
"client_host": request.client.host if request.client else None,
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import jinja2
|
|
||||||
from jinja2.sandbox import SandboxedEnvironment
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
|
|
||||||
class TemplateService:
|
|
||||||
"""
|
|
||||||
Service for rendering Jinja2 templates with variable resolution.
|
|
||||||
|
|
||||||
Uses a sandboxed environment with StrictUndefined to prevent security issues
|
|
||||||
and raise errors on undefined variables.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.env = SandboxedEnvironment(
|
|
||||||
undefined=jinja2.StrictUndefined,
|
|
||||||
autoescape=False, # We're not rendering HTML
|
|
||||||
trim_blocks=True,
|
|
||||||
lstrip_blocks=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def render(self, template: str, context: Dict[str, Any]) -> str:
|
|
||||||
"""
|
|
||||||
Render a Jinja2 template with the provided context.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
template: Jinja2 template string.
|
|
||||||
context: Dictionary of variables to make available in the template.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered string.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
jinja2.TemplateError: If template syntax is invalid or rendering fails.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
jinja_template = self.env.from_string(template)
|
|
||||||
return jinja_template.render(**context)
|
|
||||||
except jinja2.TemplateError as e:
|
|
||||||
# Re-raise with additional context
|
|
||||||
raise jinja2.TemplateError(f"Failed to render template: {e}") from e
|
|
||||||
|
|
@ -7,9 +7,9 @@ import pytest_asyncio
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
import database
|
from app.core import database
|
||||||
from config import settings
|
from app.core.config import settings
|
||||||
from app import create_app
|
from app.core.app import create_app
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="function")
|
@pytest_asyncio.fixture(scope="function")
|
||||||
|
|
@ -80,7 +80,7 @@ async def test_app(test_db):
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
# Override get_db dependency to use our test session
|
# Override get_db dependency to use our test session
|
||||||
from database import get_db
|
from app.core.database import get_db
|
||||||
async def override_get_db():
|
async def override_get_db():
|
||||||
async with test_session_factory() as session:
|
async with test_session_factory() as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ from urllib.parse import urlparse, parse_qs
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from oauth2.repositories import OAuthClientRepository, OAuthTokenRepository
|
from app.modules.oauth2.repositories import OAuthClientRepository, OAuthTokenRepository
|
||||||
from oauth2.services import OAuthService
|
from app.modules.oauth2.services import OAuthService
|
||||||
from models.oauth_models import OAuthClient
|
from app.modules.oauth2.models import OAuthClient
|
||||||
from services.route_service import RouteManager
|
from app.modules.endpoints.services.route_service import RouteManager
|
||||||
from middleware.auth_middleware import get_password_hash
|
from app.core.middleware.auth_middleware import get_password_hash
|
||||||
from repositories.endpoint_repository import EndpointRepository
|
from app.modules.endpoints.repositories.endpoint_repository import EndpointRepository
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -57,7 +57,7 @@ async def test_authorization_code_grant_flow(test_client, test_session):
|
||||||
Complete authorization code grant flow with a real client.
|
Complete authorization code grant flow with a real client.
|
||||||
"""
|
"""
|
||||||
# Create an OAuth client with authorization_code grant type directly via repository
|
# Create an OAuth client with authorization_code grant type directly via repository
|
||||||
from oauth2.repositories import OAuthClientRepository
|
from app.modules.oauth2.repositories import OAuthClientRepository
|
||||||
repo = OAuthClientRepository(test_session)
|
repo = OAuthClientRepository(test_session)
|
||||||
client_secret_plain = "test_secret_123"
|
client_secret_plain = "test_secret_123"
|
||||||
client_secret_hash = get_password_hash(client_secret_plain)
|
client_secret_hash = get_password_hash(client_secret_plain)
|
||||||
|
|
@ -119,7 +119,7 @@ async def test_authorization_code_grant_flow(test_client, test_session):
|
||||||
refresh_token = token_data["refresh_token"]
|
refresh_token = token_data["refresh_token"]
|
||||||
|
|
||||||
# Verify token exists in database
|
# Verify token exists in database
|
||||||
from oauth2.repositories import OAuthTokenRepository
|
from app.modules.oauth2.repositories import OAuthTokenRepository
|
||||||
token_repo = OAuthTokenRepository(test_session)
|
token_repo = OAuthTokenRepository(test_session)
|
||||||
token_record = await token_repo.get_by_access_token(access_token)
|
token_record = await token_repo.get_by_access_token(access_token)
|
||||||
assert token_record is not None
|
assert token_record is not None
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ Tests for admin interface authentication and endpoints.
|
||||||
"""
|
"""
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from app import app
|
from app.core.app import app
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ Unit tests for AuthorizationCodeStore.
|
||||||
import asyncio
|
import asyncio
|
||||||
import pytest
|
import pytest
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from oauth2.auth_code_store import AuthorizationCodeStore
|
from app.modules.oauth2.auth_code_store import AuthorizationCodeStore
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -162,9 +162,9 @@ async def test_thread_safety_simulation(store):
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_singleton_global_instance():
|
async def test_singleton_global_instance():
|
||||||
"""The global instance authorization_code_store is a singleton."""
|
"""The global instance authorization_code_store is a singleton."""
|
||||||
from oauth2.auth_code_store import authorization_code_store
|
from app.modules.oauth2.auth_code_store import authorization_code_store
|
||||||
# Import again to ensure it's the same object
|
# Import again to ensure it's the same object
|
||||||
from oauth2.auth_code_store import authorization_code_store as same_instance
|
from app.modules.oauth2.auth_code_store import authorization_code_store as same_instance
|
||||||
assert authorization_code_store is same_instance
|
assert authorization_code_store is same_instance
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ Unit tests for EndpointRepository.
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
# TODO: Implement tests
|
# TODO: Implement tests
|
||||||
# from repositories.endpoint_repository import EndpointRepository
|
# from app.modules.endpoints.repositories.endpoint_repository import EndpointRepository
|
||||||
|
|
||||||
|
|
||||||
def test_placeholder():
|
def test_placeholder():
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from fastapi.testclient import TestClient
|
||||||
from fastapi import FastAPI, status
|
from fastapi import FastAPI, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from oauth2.controller import router as oauth_router
|
from app.modules.oauth2.controller import router as oauth_router
|
||||||
|
|
||||||
|
|
||||||
def create_test_app(override_dependency=None) -> FastAPI:
|
def create_test_app(override_dependency=None) -> FastAPI:
|
||||||
|
|
@ -29,7 +29,7 @@ def mock_db_session():
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(mock_db_session):
|
def client(mock_db_session):
|
||||||
"""Test client with mocked database session."""
|
"""Test client with mocked database session."""
|
||||||
from database import get_db
|
from app.core.database import get_db
|
||||||
def override_get_db():
|
def override_get_db():
|
||||||
yield mock_db_session
|
yield mock_db_session
|
||||||
app = create_test_app({get_db: override_get_db})
|
app = create_test_app({get_db: override_get_db})
|
||||||
|
|
@ -63,7 +63,7 @@ def test_authorize_unsupported_response_type(client):
|
||||||
def test_authorize_success(client, mock_db_session):
|
def test_authorize_success(client, mock_db_session):
|
||||||
"""Successful authorization returns redirect with code."""
|
"""Successful authorization returns redirect with code."""
|
||||||
# Mock OAuthService.authorize_code_flow
|
# Mock OAuthService.authorize_code_flow
|
||||||
with patch('oauth2.controller.OAuthService') as MockOAuthService:
|
with patch('app.modules.oauth2.controller.OAuthService') as MockOAuthService:
|
||||||
mock_service = AsyncMock()
|
mock_service = AsyncMock()
|
||||||
mock_service.authorize_code_flow.return_value = {
|
mock_service.authorize_code_flow.return_value = {
|
||||||
"code": "auth_code_123",
|
"code": "auth_code_123",
|
||||||
|
|
@ -137,7 +137,7 @@ def test_token_authorization_code_missing_code(client):
|
||||||
|
|
||||||
def test_token_authorization_code_success(client, mock_db_session):
|
def test_token_authorization_code_success(client, mock_db_session):
|
||||||
"""Successful authorization_code exchange returns tokens."""
|
"""Successful authorization_code exchange returns tokens."""
|
||||||
with patch('oauth2.controller.OAuthService') as MockOAuthService:
|
with patch('app.modules.oauth2.controller.OAuthService') as MockOAuthService:
|
||||||
mock_service = AsyncMock()
|
mock_service = AsyncMock()
|
||||||
mock_service.exchange_code_for_tokens.return_value = {
|
mock_service.exchange_code_for_tokens.return_value = {
|
||||||
"access_token": "access_token_123",
|
"access_token": "access_token_123",
|
||||||
|
|
@ -172,7 +172,7 @@ def test_token_authorization_code_success(client, mock_db_session):
|
||||||
|
|
||||||
def test_token_client_credentials_success(client, mock_db_session):
|
def test_token_client_credentials_success(client, mock_db_session):
|
||||||
"""Client credentials grant returns access token."""
|
"""Client credentials grant returns access token."""
|
||||||
with patch('oauth2.controller.OAuthService') as MockOAuthService:
|
with patch('app.modules.oauth2.controller.OAuthService') as MockOAuthService:
|
||||||
mock_service = AsyncMock()
|
mock_service = AsyncMock()
|
||||||
mock_service.client_credentials_flow.return_value = {
|
mock_service.client_credentials_flow.return_value = {
|
||||||
"access_token": "client_token",
|
"access_token": "client_token",
|
||||||
|
|
@ -203,7 +203,7 @@ def test_token_client_credentials_success(client, mock_db_session):
|
||||||
|
|
||||||
def test_token_refresh_token_success(client, mock_db_session):
|
def test_token_refresh_token_success(client, mock_db_session):
|
||||||
"""Refresh token grant returns new tokens."""
|
"""Refresh token grant returns new tokens."""
|
||||||
with patch('oauth2.controller.OAuthService') as MockOAuthService:
|
with patch('app.modules.oauth2.controller.OAuthService') as MockOAuthService:
|
||||||
mock_service = AsyncMock()
|
mock_service = AsyncMock()
|
||||||
mock_service.refresh_token_flow.return_value = {
|
mock_service.refresh_token_flow.return_value = {
|
||||||
"access_token": "new_access_token",
|
"access_token": "new_access_token",
|
||||||
|
|
@ -242,7 +242,7 @@ def test_userinfo_missing_token(client):
|
||||||
def test_userinfo_with_valid_token(client, mock_db_session):
|
def test_userinfo_with_valid_token(client, mock_db_session):
|
||||||
"""UserInfo returns claims from token payload."""
|
"""UserInfo returns claims from token payload."""
|
||||||
# Mock get_current_token_payload dependency
|
# Mock get_current_token_payload dependency
|
||||||
from oauth2.dependencies import get_current_token_payload
|
from app.modules.oauth2.dependencies import get_current_token_payload
|
||||||
async def mock_payload():
|
async def mock_payload():
|
||||||
return {"sub": "user1", "client_id": "client1", "scopes": ["profile"]}
|
return {"sub": "user1", "client_id": "client1", "scopes": ["profile"]}
|
||||||
|
|
||||||
|
|
@ -267,8 +267,8 @@ def test_introspect_missing_authentication(client):
|
||||||
def test_introspect_success(client, mock_db_session):
|
def test_introspect_success(client, mock_db_session):
|
||||||
"""Introspection returns active token metadata."""
|
"""Introspection returns active token metadata."""
|
||||||
# Mock ClientService.validate_client and TokenService.verify_token
|
# Mock ClientService.validate_client and TokenService.verify_token
|
||||||
with patch('oauth2.controller.ClientService') as MockClientService, \
|
with patch('app.modules.oauth2.controller.ClientService') as MockClientService, \
|
||||||
patch('oauth2.controller.TokenService') as MockTokenService:
|
patch('app.modules.oauth2.controller.TokenService') as MockTokenService:
|
||||||
mock_client_service = AsyncMock()
|
mock_client_service = AsyncMock()
|
||||||
mock_client_service.validate_client.return_value = True
|
mock_client_service.validate_client.return_value = True
|
||||||
MockClientService.return_value = mock_client_service
|
MockClientService.return_value = mock_client_service
|
||||||
|
|
@ -306,8 +306,8 @@ def test_revoke_missing_authentication(client):
|
||||||
|
|
||||||
def test_revoke_success(client, mock_db_session):
|
def test_revoke_success(client, mock_db_session):
|
||||||
"""Successful revocation returns 200."""
|
"""Successful revocation returns 200."""
|
||||||
with patch('oauth2.controller.ClientService') as MockClientService, \
|
with patch('app.modules.oauth2.controller.ClientService') as MockClientService, \
|
||||||
patch('oauth2.controller.TokenService') as MockTokenService:
|
patch('app.modules.oauth2.controller.TokenService') as MockTokenService:
|
||||||
mock_client_service = AsyncMock()
|
mock_client_service = AsyncMock()
|
||||||
mock_client_service.validate_client.return_value = True
|
mock_client_service.validate_client.return_value = True
|
||||||
MockClientService.return_value = mock_client_service
|
MockClientService.return_value = mock_client_service
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ Test that route_manager is attached to app.state before first request.
|
||||||
"""
|
"""
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from app import create_app
|
from app.core.app import create_app
|
||||||
|
|
||||||
def test_route_manager_attached():
|
def test_route_manager_attached():
|
||||||
"""Ensure route_manager is attached after app creation."""
|
"""Ensure route_manager is attached after app creation."""
|
||||||
|
|
@ -31,7 +31,7 @@ def test_admin_dashboard_with_route_manager():
|
||||||
|
|
||||||
def test_route_manager_dependency():
|
def test_route_manager_dependency():
|
||||||
"""Test get_route_manager dependency returns the attached route_manager."""
|
"""Test get_route_manager dependency returns the attached route_manager."""
|
||||||
from controllers.admin_controller import get_route_manager
|
from app.modules.admin.controller import get_route_manager
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
# Create mock request with app.state.route_manager
|
# Create mock request with app.state.route_manager
|
||||||
|
|
|
||||||
41
wsgi.py
41
wsgi.py
|
|
@ -1,41 +0,0 @@
|
||||||
"""
|
|
||||||
WSGI entry point for production deployment with Waitress.
|
|
||||||
Wraps the FastAPI ASGI application with ASGI-to-WSGI adapter using a2wsgi.
|
|
||||||
Also triggers route refresh on startup since WSGI doesn't support ASGI lifespan events.
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from a2wsgi import ASGIMiddleware
|
|
||||||
from app import app
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Refresh routes on startup (since WSGI doesn't call ASGI lifespan)
|
|
||||||
loop = None
|
|
||||||
try:
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
route_manager = app.state.route_manager
|
|
||||||
logger.info("Refreshing routes from database...")
|
|
||||||
loop.run_until_complete(route_manager.refresh_routes())
|
|
||||||
logger.info(f"Registered {len(route_manager.registered_routes)} routes")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to refresh routes on startup: {e}")
|
|
||||||
# Continue anyway; routes can be refreshed later via admin interface
|
|
||||||
finally:
|
|
||||||
if loop is not None:
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
# Wrap FastAPI ASGI app with WSGI adapter
|
|
||||||
wsgi_app = ASGIMiddleware(app)
|
|
||||||
|
|
||||||
# Function that returns the WSGI application (for --call)
|
|
||||||
def create_wsgi_app():
|
|
||||||
return wsgi_app
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# This block is for running directly with python wsgi.py (development)
|
|
||||||
from waitress import serve
|
|
||||||
logger.info("Starting Waitress server on http://0.0.0.0:8000")
|
|
||||||
serve(wsgi_app, host="0.0.0.0", port=8000, threads=4)
|
|
||||||
Loading…
Reference in a new issue