diff --git a/README.md.backup2 b/README.md.backup2 deleted file mode 100644 index 9677a78..0000000 --- a/README.md.backup2 +++ /dev/null @@ -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. diff --git a/app.py.backup b/app.py.backup new file mode 100644 index 0000000..b256904 --- /dev/null +++ b/app.py.backup @@ -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() diff --git a/app.py b/app/core/app.py similarity index 83% rename from app.py rename to app/core/app.py index 53ae31b..c645df7 100644 --- a/app.py +++ b/app/core/app.py @@ -6,13 +6,13 @@ 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 +from app.core.config import settings +from app.core.database import init_db, AsyncSessionLocal +from app.modules.endpoints.repositories.endpoint_repository import EndpointRepository +from app.modules.endpoints.services.route_service import RouteManager +from app.core.middleware.auth_middleware import AuthMiddleware +from app.modules.admin.controller import router as admin_router +from app.modules.oauth2 import oauth_router logging.basicConfig( @@ -73,7 +73,7 @@ def create_app() -> FastAPI: # 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 @app.get("/health") @@ -95,4 +95,4 @@ def create_app() -> FastAPI: # Create the application instance -app = create_app() +app = create_app() \ No newline at end of file diff --git a/app/core/database.py b/app/core/database.py index ad545df..4ea9966 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -30,8 +30,9 @@ AsyncSessionLocal = sessionmaker( Base = declarative_base() # Import models to ensure they are registered with Base.metadata -from app.modules.endpoints.models.endpoint_model import Endpoint -from app.modules.oauth2.models import OAuthClient, OAuthToken, OAuthUser +# Models are imported elsewhere (route_service, oauth2 module) to avoid circular imports +# from app.modules.endpoints.models.endpoint_model import Endpoint +# from app.modules.oauth2.models import OAuthClient, OAuthToken, OAuthUser async def get_db() -> AsyncSession: diff --git a/controllers/__init__.py b/controllers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/controllers/admin_controller.py b/controllers/admin_controller.py deleted file mode 100644 index aa7d915..0000000 --- a/controllers/admin_controller.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/ARCHITECTURE_OAUTH2_CONTROLLERS.md b/docs/ARCHITECTURE_OAUTH2_CONTROLLERS.md similarity index 100% rename from ARCHITECTURE_OAUTH2_CONTROLLERS.md rename to docs/ARCHITECTURE_OAUTH2_CONTROLLERS.md diff --git a/CLEANUP_REPORT.md b/docs/CLEANUP_REPORT.md similarity index 100% rename from CLEANUP_REPORT.md rename to docs/CLEANUP_REPORT.md diff --git a/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md similarity index 100% rename from PROJECT_PLAN.md rename to docs/PROJECT_PLAN.md diff --git a/TECH_SPEC_OAUTH2_CONTROLLERS.md b/docs/TECH_SPEC_OAUTH2_CONTROLLERS.md similarity index 100% rename from TECH_SPEC_OAUTH2_CONTROLLERS.md rename to docs/TECH_SPEC_OAUTH2_CONTROLLERS.md diff --git a/main.py b/main.py new file mode 100644 index 0000000..19a8afb --- /dev/null +++ b/main.py @@ -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" + ) \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py deleted file mode 100644 index 46e3693..0000000 --- a/models/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .endpoint_model import Endpoint -from .oauth_models import OAuthClient, OAuthToken, OAuthUser - -__all__ = ["Endpoint", "OAuthClient", "OAuthToken", "OAuthUser"] \ No newline at end of file diff --git a/models/endpoint_model.py b/models/endpoint_model.py deleted file mode 100644 index 4981a62..0000000 --- a/models/endpoint_model.py +++ /dev/null @@ -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"" diff --git a/repositories/__init__.py b/repositories/__init__.py deleted file mode 100644 index 0fb6df2..0000000 --- a/repositories/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .endpoint_repository import EndpointRepository - -__all__ = ["EndpointRepository"] \ No newline at end of file diff --git a/repositories/endpoint_repository.py b/repositories/endpoint_repository.py deleted file mode 100644 index 8610935..0000000 --- a/repositories/endpoint_repository.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/run.py b/run.py deleted file mode 100644 index 7d05dc1..0000000 --- a/run.py +++ /dev/null @@ -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" - ) \ No newline at end of file diff --git a/schemas/__init__.py b/schemas/__init__.py deleted file mode 100644 index e745e59..0000000 --- a/schemas/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from .endpoint_schema import ( - EndpointBase, - EndpointCreate, - EndpointUpdate, - EndpointResponse, -) - -__all__ = [ - "EndpointBase", - "EndpointCreate", - "EndpointUpdate", - "EndpointResponse", -] \ No newline at end of file diff --git a/schemas/endpoint_schema.py b/schemas/endpoint_schema.py deleted file mode 100644 index ca0d26e..0000000 --- a/schemas/endpoint_schema.py +++ /dev/null @@ -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`) \ No newline at end of file diff --git a/debug_wsgi.py b/scripts/debug_wsgi.py similarity index 96% rename from debug_wsgi.py rename to scripts/debug_wsgi.py index db96a7c..d593cb5 100644 --- a/debug_wsgi.py +++ b/scripts/debug_wsgi.py @@ -1,6 +1,6 @@ import inspect from asgiref.wsgi import WsgiToAsgi -from app import app +from app.core.app import app print("app callable?", callable(app)) print("app signature:", inspect.signature(app.__call__)) diff --git a/example_usage.py b/scripts/example_usage.py similarity index 98% rename from example_usage.py rename to scripts/example_usage.py index 5ca9f36..4c5c3ee 100644 --- a/example_usage.py +++ b/scripts/example_usage.py @@ -33,9 +33,9 @@ os.environ['SECRET_KEY'] = 'test-secret-key' # Add current directory to path sys.path.insert(0, '.') -from app import app +from app.core.app import app from fastapi.testclient import TestClient -from database import init_db +from app.core.database import init_db async def setup_database(): diff --git a/run_example.sh b/scripts/run_example.sh similarity index 100% rename from run_example.sh rename to scripts/run_example.sh diff --git a/examples/setup-test-client.py b/scripts/setup-test-client.py similarity index 98% rename from examples/setup-test-client.py rename to scripts/setup-test-client.py index e614626..8593c1d 100755 --- a/examples/setup-test-client.py +++ b/scripts/setup-test-client.py @@ -16,7 +16,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Import only what we need try: - from middleware.auth_middleware import get_password_hash + from app.core.middleware.auth_middleware import get_password_hash HAS_AUTH = True except ImportError: # Fallback to simple hash function if middleware not available diff --git a/services/__init__.py b/services/__init__.py deleted file mode 100644 index 8f466ee..0000000 --- a/services/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .route_service import RouteManager -from .template_service import TemplateService - -__all__ = ["RouteManager", "TemplateService"] \ No newline at end of file diff --git a/services/route_service.py b/services/route_service.py deleted file mode 100644 index c9b3f2c..0000000 --- a/services/route_service.py +++ /dev/null @@ -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 ", - 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, - } \ No newline at end of file diff --git a/services/template_service.py b/services/template_service.py deleted file mode 100644 index 23c8d7b..0000000 --- a/services/template_service.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 880b293..ca51eca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,9 +7,9 @@ import pytest_asyncio from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker from sqlalchemy.pool import StaticPool from fastapi.testclient import TestClient -import database -from config import settings -from app import create_app +from app.core import database +from app.core.config import settings +from app.core.app import create_app @pytest_asyncio.fixture(scope="function") @@ -80,7 +80,7 @@ async def test_app(test_db): app = create_app() # 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 with test_session_factory() as session: yield session diff --git a/tests/integration/test_oauth2_integration.py b/tests/integration/test_oauth2_integration.py index 6ab723a..a029591 100644 --- a/tests/integration/test_oauth2_integration.py +++ b/tests/integration/test_oauth2_integration.py @@ -6,12 +6,12 @@ from urllib.parse import urlparse, parse_qs from fastapi import status from sqlalchemy.ext.asyncio import AsyncSession -from oauth2.repositories import OAuthClientRepository, OAuthTokenRepository -from oauth2.services import OAuthService -from models.oauth_models import OAuthClient -from services.route_service import RouteManager -from middleware.auth_middleware import get_password_hash -from repositories.endpoint_repository import EndpointRepository +from app.modules.oauth2.repositories import OAuthClientRepository, OAuthTokenRepository +from app.modules.oauth2.services import OAuthService +from app.modules.oauth2.models import OAuthClient +from app.modules.endpoints.services.route_service import RouteManager +from app.core.middleware.auth_middleware import get_password_hash +from app.modules.endpoints.repositories.endpoint_repository import EndpointRepository @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. """ # 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) client_secret_plain = "test_secret_123" 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"] # Verify token exists in database - from oauth2.repositories import OAuthTokenRepository + from app.modules.oauth2.repositories import OAuthTokenRepository token_repo = OAuthTokenRepository(test_session) token_record = await token_repo.get_by_access_token(access_token) assert token_record is not None diff --git a/tests/test_admin.py b/tests/test_admin.py index cb0a7e6..8b39a8d 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -3,7 +3,7 @@ Tests for admin interface authentication and endpoints. """ import pytest from fastapi.testclient import TestClient -from app import app +from app.core.app import app @pytest.fixture diff --git a/tests/test_auth_code_store.py b/tests/test_auth_code_store.py index 90d695c..f337cec 100644 --- a/tests/test_auth_code_store.py +++ b/tests/test_auth_code_store.py @@ -4,7 +4,7 @@ Unit tests for AuthorizationCodeStore. import asyncio import pytest from datetime import datetime, timedelta -from oauth2.auth_code_store import AuthorizationCodeStore +from app.modules.oauth2.auth_code_store import AuthorizationCodeStore @pytest.fixture @@ -162,9 +162,9 @@ async def test_thread_safety_simulation(store): @pytest.mark.asyncio async def test_singleton_global_instance(): """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 - 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 diff --git a/tests/test_endpoint_repository.py b/tests/test_endpoint_repository.py index 641adac..84477f4 100644 --- a/tests/test_endpoint_repository.py +++ b/tests/test_endpoint_repository.py @@ -4,7 +4,7 @@ Unit tests for EndpointRepository. import pytest # TODO: Implement tests -# from repositories.endpoint_repository import EndpointRepository +# from app.modules.endpoints.repositories.endpoint_repository import EndpointRepository def test_placeholder(): diff --git a/tests/test_oauth2_controller.py b/tests/test_oauth2_controller.py index b60eed3..fffbc23 100644 --- a/tests/test_oauth2_controller.py +++ b/tests/test_oauth2_controller.py @@ -7,7 +7,7 @@ from fastapi.testclient import TestClient from fastapi import FastAPI, status 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: @@ -29,7 +29,7 @@ def mock_db_session(): @pytest.fixture def client(mock_db_session): """Test client with mocked database session.""" - from database import get_db + from app.core.database import get_db def override_get_db(): yield mock_db_session 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): """Successful authorization returns redirect with code.""" # 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.authorize_code_flow.return_value = { "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): """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.exchange_code_for_tokens.return_value = { "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): """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.client_credentials_flow.return_value = { "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): """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.refresh_token_flow.return_value = { "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): """UserInfo returns claims from token payload.""" # 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(): 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): """Introspection returns active token metadata.""" # Mock ClientService.validate_client and TokenService.verify_token - with patch('oauth2.controller.ClientService') as MockClientService, \ - patch('oauth2.controller.TokenService') as MockTokenService: + with patch('app.modules.oauth2.controller.ClientService') as MockClientService, \ + patch('app.modules.oauth2.controller.TokenService') as MockTokenService: mock_client_service = AsyncMock() mock_client_service.validate_client.return_value = True MockClientService.return_value = mock_client_service @@ -306,8 +306,8 @@ def test_revoke_missing_authentication(client): def test_revoke_success(client, mock_db_session): """Successful revocation returns 200.""" - with patch('oauth2.controller.ClientService') as MockClientService, \ - patch('oauth2.controller.TokenService') as MockTokenService: + with patch('app.modules.oauth2.controller.ClientService') as MockClientService, \ + patch('app.modules.oauth2.controller.TokenService') as MockTokenService: mock_client_service = AsyncMock() mock_client_service.validate_client.return_value = True MockClientService.return_value = mock_client_service diff --git a/tests/test_route_manager_fix.py b/tests/test_route_manager_fix.py index 9d33f4b..b2b5722 100644 --- a/tests/test_route_manager_fix.py +++ b/tests/test_route_manager_fix.py @@ -3,7 +3,7 @@ Test that route_manager is attached to app.state before first request. """ import pytest from fastapi.testclient import TestClient -from app import create_app +from app.core.app import create_app def test_route_manager_attached(): """Ensure route_manager is attached after app creation.""" @@ -31,7 +31,7 @@ def test_admin_dashboard_with_route_manager(): def test_route_manager_dependency(): """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 unittest.mock import Mock # Create mock request with app.state.route_manager diff --git a/wsgi.py b/wsgi.py deleted file mode 100644 index 3c512c8..0000000 --- a/wsgi.py +++ /dev/null @@ -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) \ No newline at end of file