diff --git a/.gitignore b/.gitignore index 6d30cff..45c7856 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ mockapi.db server.log # IDE +.opencode/ .vscode/ .idea/ *.swp diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..10b5aac --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 the mockapi project authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 44541e6..b457f64 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -144,8 +144,8 @@ See `requirements.txt` in architect spec. - ✅ Phase 6.5: Configuration & Integration completed - ✅ Phase 6.6: Testing completed -## Next Steps -1. Update documentation with OAuth2 usage examples. +## Next Steps (Updated 2026-03-16) +1. ✅ Documentation updated with OAuth2 usage examples and Bruno API collection. 2. Deploy to production environment (if needed). 3. Consider adding PKCE support for public clients. 4. Add more advanced OAuth2 features (e.g., token introspection, JWKS endpoint). diff --git a/PROJECT_PLAN.md.backup b/PROJECT_PLAN.md.backup deleted file mode 100644 index 70b0ba4..0000000 --- a/PROJECT_PLAN.md.backup +++ /dev/null @@ -1,108 +0,0 @@ -# Configurable Mock API with Admin Interface - Project Plan - -## Architecture Decisions (from @architect) - -### Technology Stack -- **Framework**: FastAPI (over Flask) for automatic API documentation, async support, type safety. -- **Server**: Waitress as production WSGI server. -- **Database**: SQLite with SQLAlchemy ORM, aiosqlite for async. -- **Template Engine**: Jinja2 with sandboxed environment. -- **Admin UI**: Custom Jinja2 templates with Bootstrap 5 CDN, session-based authentication. - -### Database Schema -- **endpoints** table: id, route (VARCHAR), method (VARCHAR), response_body (TEXT), response_code (INTEGER), content_type (VARCHAR), is_active (BOOLEAN), variables (JSON), headers (JSON), delay_ms (INTEGER), created_at, updated_at. -- Unique constraint on (route, method). - -### Application Architecture -- Repository-Service-Controller pattern with Observer pattern for dynamic route updates. -- Modules: database, models, repositories, services, controllers, observers, schemas, middleware, utils. - -### Dynamic Route Registration -- RouteManager service registers/unregisters endpoints at runtime via FastAPI's `add_api_route`. -- Observer pattern triggers route refresh on CRUD operations. - -### Template Variable Rendering -- Variable sources: path params, query params, request headers, request body, system variables, endpoint defaults. -- Jinja2 with StrictUndefined to prevent silent failures. - -### Admin Interface -- Simple credential store (admin username/password hash from env vars). -- Session-based authentication with middleware protecting `/admin/*` routes. -- Pages: Login, Dashboard, Endpoint List, Endpoint Editor, Request Logs (optional). - -## Project Structure -``` -mock_api_app/ # Align with user request -├── app.py -├── config.py -├── database.py -├── dependencies.py -├── middleware/ -├── models/ -├── repositories/ -├── services/ -├── controllers/ -├── observers/ -├── schemas/ -├── static/ -├── templates/ -├── utils/ -├── requirements.txt -├── README.md -└── .env.example -``` - -## Roadmap - -### Phase 1: Foundation -- [x] Create project directory and structure -- [x] Set up SQLAlchemy model `Endpoint` -- [x] Configure FastAPI app with Jinja2 templates -- [x] Write `requirements.txt` - -### Phase 2: Core Services -- [x] Implement `EndpointRepository` -- [x] Implement `RouteManager` service -- [x] Implement `TemplateService` with variable resolution - -### Phase 3: Admin Interface -- [x] Authentication middleware -- [x] Admin controller routes -- [x] HTML templates (Bootstrap 5 CDN) - -### Phase 4: Integration -- [x] Connect route observer -- [x] Add request logging (optional) -- [x] Health check endpoints - -### Phase 5: Production Ready -- [x] Waitress configuration -- [x] Environment variable configuration -- [x] Comprehensive README - -## Dependencies -See `requirements.txt` in architect spec. - -## Security Considerations -- Template sandboxing -- SQL injection prevention via ORM -- Admin authentication with bcrypt -- Route validation to prevent path traversal - -## Status Log -- 2025-03-13: Architectural specification completed by @architect. -- 2025-03-13: Project plan created. -- 2026-03-14: Project cleanup and optimization completed. All phases implemented. Integration tests passing. Project ready for demonstration/testing. - -## Current Status (2026-03-14) -- ✅ Phase 1: Foundation completed -- ✅ Phase 2: Core Services completed -- ✅ Security fixes applied (critical issues resolved) -- ✅ Phase 3: Admin Interface completed -- ✅ Phase 4: Integration completed -- ✅ Phase 5: Production Ready completed - -## Next Steps -1. Deploy to production environment (if needed). -2. Add advanced features: request logging, analytics, multi-user support. -3. Expand test coverage for repository and service layers. diff --git a/README.md b/README.md index e10f864..b76d779 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Configurable Mock API with Admin Interface +# 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. +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 @@ -8,6 +8,10 @@ A lightweight, configurable mock API application in Python that allows dynamic e - **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 @@ -16,7 +20,8 @@ A lightweight, configurable mock API application in Python that allows dynamic e - **Server**: Waitress (production WSGI server) - **Database**: SQLite with SQLAlchemy 2.0 async ORM - **Templating**: Jinja2 with sandboxed environment -- **Authentication**: Session-based with bcrypt password hashing +- **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 @@ -31,38 +36,46 @@ mockapi/ ├── middleware/ │ └── auth_middleware.py # Admin authentication middleware ├── models/ -│ └── endpoint_model.py # Endpoint SQLAlchemy model +│ ├── 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 +│ ├── 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 +│ ├── admin_controller.py # Admin UI routes +│ └── oauth2/ # OAuth2 controllers and services ├── schemas/ -│ └── endpoint_schema.py # Pydantic schemas for validation +│ ├── 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 +│ ├── 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_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 ``` @@ -134,6 +147,128 @@ 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 + +### 1. Start the Server + +```bash +# Development (auto-reload) +python run.py + +# Or directly with uvicorn +uvicorn app:app --reload --host 0.0.0.0 --port 8000 + +# Production (with Waitress) +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). + +### 2. Test Basic Functionality + +```bash +# Health check +curl http://localhost:8000/health + +# Access Swagger UI (auto-generated docs) +open http://localhost:8000/docs +``` + +### 3. Use API Testing Collections + +Ready-to-use API collections are available in the `examples/` directory: + +| Collection | Format | Description | +|------------|--------|-------------| +| **Bruno** | `mockapi-collection.bru` | Bruno collection with scripting support | +| **Postman** | `mockapi-postman-collection.json` | Postman Collection v2.1 | + +Both collections include: +- Global variables (base URL, credentials, tokens) +- Full OAuth2 flow testing (client credentials, authorization code, refresh) +- Mock endpoint CRUD operations +- Admin authentication +- Protected endpoint examples + +**Setup:** +```bash +# Create test OAuth client +./examples/setup.sh + +# Or manually +python examples/setup-test-client.py +``` + +**Import:** +- **Bruno**: Drag and drop `.bru` file or use "Import Collection" +- **Postman**: Use "Import" button and select JSON file + +See [examples/README.md](examples/README.md) for detailed usage. + +### 4. Basic cURL Examples (Quick Reference) + +```bash +# Admin login (sets session cookie) +curl -c cookies.txt -X POST http://localhost:8000/admin/login \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=admin&password=admin123" + +# Create a simple mock endpoint +curl -b cookies.txt -X POST http://localhost:8000/admin/endpoints \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "route=/api/test&method=GET&response_body={\"message\":\"test\"}&response_code=200&content_type=application/json&is_active=true" + +# Call the endpoint +curl http://localhost:8000/api/test + +# OAuth2 client credentials grant (using test client) +curl -X POST http://localhost:8000/oauth/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials&client_id=test_client&client_secret=test_secret&scope=api:read" +``` + +For comprehensive testing with all OAuth2 flows and examples, use the provided API collections. + + + +## 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** @@ -230,6 +365,7 @@ The following variable sources are available in response templates: - **Activate/deactivate** endpoints without deletion - **Delete** endpoints (removes route) - **Dashboard** with statistics (total endpoints, active routes, etc.) +- **OAuth2 management** – clients, tokens, users ## Security Considerations @@ -239,6 +375,7 @@ The following variable sources are available in response templates: - **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 @@ -251,6 +388,10 @@ See `config.py` for all available settings. Key environment variables: | `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. @@ -266,6 +407,42 @@ The dynamic mock endpoints are not listed in the OpenAPI schema (they are regist ## Development & Testing +## API Testing Collections + +Ready-to-use API collections are available in the `examples/` directory: + +### Bruno Collection (`mockapi-collection.bru`) +- **Format**: Bruno native format (`.bru`) +- **Features**: Scripting support, environment variables, folder organization +- **Import**: Drag and drop into Bruno or use "Import Collection" + +### Postman Collection (`mockapi-postman-collection.json`) +- **Format**: Postman Collection v2.1 +- **Features**: Pre-request scripts, tests, environment variables +- **Import**: Import into Postman via "Import" button + +**Quick setup:** +```bash +# Create test OAuth client and view instructions +./examples/setup.sh + +# Or import directly: +# examples/mockapi-collection.bru (Bruno) +# examples/mockapi-postman-collection.json (Postman) +``` + +Both collections include: +- Global variables for base URL and credentials +- Pre-configured requests for all endpoints +- OAuth2 flow examples (client credentials, authorization code, refresh) +- 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: @@ -277,6 +454,7 @@ 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 @@ -328,6 +506,12 @@ This is a great way to verify that the API is working correctly after installati - 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. @@ -337,6 +521,8 @@ Enable debug logging by setting `DEBUG=True` in `.env`. Check the console output - SQLite only (but can be extended to PostgreSQL via `DATABASE_URL`) - Single admin user (no multi‑user support) - No request logging/history + - OAuth2 protection fields (requires_oauth, oauth_scopes) not exposed in admin UI + - OAuth2 user authentication uses placeholder user IDs (integration with external identity providers pending) - **Possible extensions**: - Import/export endpoints as JSON/YAML @@ -345,12 +531,15 @@ Enable debug logging by setting `DEBUG=True` in `.env`. Check the console output - 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 provided as-is for demonstration purposes. Use at your own risk. +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/README.md.backup b/README.md.backup deleted file mode 100644 index bff5809..0000000 --- a/README.md.backup +++ /dev/null @@ -1,257 +0,0 @@ -# Configurable Mock API with Admin Interface - -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. - -## 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. -- **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 with bcrypt password hashing -- **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 -├── observers/ -│ └── __init__.py # Observer pattern placeholder -├── repositories/ -│ └── endpoint_repository.py # Repository pattern for endpoints -├── run.py # Application entry point (production) -├── services/ -│ ├── route_service.py # Dynamic route registration/management -│ └── template_service.py # Jinja2 template rendering -├── controllers/ -│ └── admin_controller.py # Admin UI routes -├── schemas/ -│ └── endpoint_schema.py # Pydantic schemas for validation -├── 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 -├── static/ -│ └── css/ # Static CSS (optional) -├── tests/ # Test suite -│ ├── test_admin.py # Admin authentication tests -│ ├── test_endpoint_repository.py -│ └── test_route_manager_fix.py -├── 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 -└── README.md # This file -``` - -## Installation - -1. **Clone or extract the project**: - ```bash - cd mockapi - ``` - -2. **Create a virtual environment** (optional but 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 (admin password, secret key, etc.) - ``` - - 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) -```bash -uvicorn app:app --reload --host 0.0.0.0 --port 8000 -``` - -### Production (with Waitress) -```bash -waitress-serve --host=0.0.0.0 --port=8000 --threads=4 app:app -``` - -The server will start on `http://localhost:8000`. - -## 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.) - -## 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. - -## 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) | - -**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 dynamic mock endpoints are not listed in the OpenAPI schema (they are registered at runtime). - -## Development & Testing - -### 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 - -### 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. - -## 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 - -- **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 - -## License - -This project is provided as-is for demonstration purposes. Use at your own risk. - -## 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. diff --git a/README.md.backup2 b/README.md.backup2 new file mode 100644 index 0000000..9677a78 --- /dev/null +++ b/README.md.backup2 @@ -0,0 +1,573 @@ +# 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/middleware/__init__.py b/app/__init__.py similarity index 100% rename from middleware/__init__.py rename to app/__init__.py diff --git a/observers/__init__.py b/app/core/__init__.py similarity index 100% rename from observers/__init__.py rename to app/core/__init__.py diff --git a/config.py b/app/core/config.py similarity index 100% rename from config.py rename to app/core/config.py diff --git a/database.py b/app/core/database.py similarity index 95% rename from database.py rename to app/core/database.py index c8e4b8f..ad545df 100644 --- a/database.py +++ b/app/core/database.py @@ -1,7 +1,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker, declarative_base from sqlalchemy import text, event -from config import settings +from app.core.config import settings import logging logger = logging.getLogger(__name__) @@ -30,7 +30,8 @@ AsyncSessionLocal = sessionmaker( Base = declarative_base() # Import models to ensure they are registered with Base.metadata -from models import Endpoint, 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: diff --git a/dependencies.py b/app/core/dependencies.py similarity index 94% rename from dependencies.py rename to app/core/dependencies.py index e94309a..669015a 100644 --- a/dependencies.py +++ b/app/core/dependencies.py @@ -1,7 +1,7 @@ from fastapi import Depends, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.ext.asyncio import AsyncSession -from database import get_db +from app.core.database import get_db security = HTTPBearer(auto_error=False) diff --git a/utils/__init__.py b/app/core/middleware/__init__.py similarity index 100% rename from utils/__init__.py rename to app/core/middleware/__init__.py diff --git a/middleware/auth_middleware.py b/app/core/middleware/auth_middleware.py similarity index 98% rename from middleware/auth_middleware.py rename to app/core/middleware/auth_middleware.py index 64409c2..eb46eff 100644 --- a/middleware/auth_middleware.py +++ b/app/core/middleware/auth_middleware.py @@ -5,7 +5,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from fastapi import status from starlette.requests import Request from fastapi.responses import Response, RedirectResponse -from config import settings +from app.core.config import settings logger = logging.getLogger(__name__) diff --git a/app/core/observers/__init__.py b/app/core/observers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/utils/__init__.py b/app/core/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/__init__.py b/app/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/admin/__init__.py b/app/modules/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/admin/controller.py b/app/modules/admin/controller.py new file mode 100644 index 0000000..7cb544a --- /dev/null +++ b/app/modules/admin/controller.py @@ -0,0 +1,705 @@ +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 pathlib import Path +from app.core.config import settings +from app.core.middleware.auth_middleware import verify_password, get_password_hash +from app.core.database import get_db +from app.modules.endpoints.repositories.endpoint_repository import EndpointRepository +from app.modules.endpoints.schemas.endpoint_schema import EndpointCreate, EndpointUpdate, EndpointResponse +from app.modules.endpoints.services.route_service import RouteManager +from app.modules.oauth2.repositories import OAuthClientRepository, OAuthTokenRepository, OAuthUserRepository +from app.modules.oauth2.schemas import OAuthClientCreate, OAuthClientUpdate +import secrets + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin", tags=["admin"]) +templates = Jinja2Templates(directory=str(Path(__file__).parent / "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 app.core.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/templates/admin/dashboard.html b/app/modules/admin/templates/admin/dashboard.html similarity index 100% rename from templates/admin/dashboard.html rename to app/modules/admin/templates/admin/dashboard.html diff --git a/templates/admin/endpoint_form.html b/app/modules/admin/templates/admin/endpoint_form.html similarity index 100% rename from templates/admin/endpoint_form.html rename to app/modules/admin/templates/admin/endpoint_form.html diff --git a/templates/admin/endpoints.html b/app/modules/admin/templates/admin/endpoints.html similarity index 100% rename from templates/admin/endpoints.html rename to app/modules/admin/templates/admin/endpoints.html diff --git a/templates/admin/login.html b/app/modules/admin/templates/admin/login.html similarity index 100% rename from templates/admin/login.html rename to app/modules/admin/templates/admin/login.html diff --git a/templates/admin/oauth/client_form.html b/app/modules/admin/templates/admin/oauth/client_form.html similarity index 100% rename from templates/admin/oauth/client_form.html rename to app/modules/admin/templates/admin/oauth/client_form.html diff --git a/templates/admin/oauth/clients.html b/app/modules/admin/templates/admin/oauth/clients.html similarity index 100% rename from templates/admin/oauth/clients.html rename to app/modules/admin/templates/admin/oauth/clients.html diff --git a/templates/admin/oauth/tokens.html b/app/modules/admin/templates/admin/oauth/tokens.html similarity index 100% rename from templates/admin/oauth/tokens.html rename to app/modules/admin/templates/admin/oauth/tokens.html diff --git a/templates/admin/oauth/users.html b/app/modules/admin/templates/admin/oauth/users.html similarity index 100% rename from templates/admin/oauth/users.html rename to app/modules/admin/templates/admin/oauth/users.html diff --git a/app/modules/endpoints/__init__.py b/app/modules/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/endpoints/models/__init__.py b/app/modules/endpoints/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/endpoints/models/endpoint_model.py b/app/modules/endpoints/models/endpoint_model.py new file mode 100644 index 0000000..b0ba897 --- /dev/null +++ b/app/modules/endpoints/models/endpoint_model.py @@ -0,0 +1,31 @@ +from sqlalchemy import Column, Integer, String, Boolean, Text, TIMESTAMP, UniqueConstraint +from sqlalchemy.sql import func +from sqlalchemy.dialects.sqlite import JSON +from app.core.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/app/modules/endpoints/repositories/__init__.py b/app/modules/endpoints/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/endpoints/repositories/endpoint_repository.py b/app/modules/endpoints/repositories/endpoint_repository.py new file mode 100644 index 0000000..2a2d93b --- /dev/null +++ b/app/modules/endpoints/repositories/endpoint_repository.py @@ -0,0 +1,161 @@ +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 app.modules.endpoints.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/app/modules/endpoints/schemas/__init__.py b/app/modules/endpoints/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/endpoints/schemas/endpoint_schema.py b/app/modules/endpoints/schemas/endpoint_schema.py new file mode 100644 index 0000000..ca0d26e --- /dev/null +++ b/app/modules/endpoints/schemas/endpoint_schema.py @@ -0,0 +1,124 @@ +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/app/modules/endpoints/services/__init__.py b/app/modules/endpoints/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/endpoints/services/route_service.py b/app/modules/endpoints/services/route_service.py new file mode 100644 index 0000000..9c17407 --- /dev/null +++ b/app/modules/endpoints/services/route_service.py @@ -0,0 +1,370 @@ +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 app.core.config import settings + +from app.modules.endpoints.models.endpoint_model import Endpoint +from app.modules.endpoints.repositories.endpoint_repository import EndpointRepository +from app.modules.endpoints.services.template_service import TemplateService +from app.modules.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/app/modules/endpoints/services/template_service.py b/app/modules/endpoints/services/template_service.py new file mode 100644 index 0000000..23c8d7b --- /dev/null +++ b/app/modules/endpoints/services/template_service.py @@ -0,0 +1,41 @@ +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/oauth2/__init__.py b/app/modules/oauth2/__init__.py similarity index 100% rename from oauth2/__init__.py rename to app/modules/oauth2/__init__.py diff --git a/oauth2/auth_code_store.py b/app/modules/oauth2/auth_code_store.py similarity index 99% rename from oauth2/auth_code_store.py rename to app/modules/oauth2/auth_code_store.py index 630412b..e8d45d1 100644 --- a/oauth2/auth_code_store.py +++ b/app/modules/oauth2/auth_code_store.py @@ -7,7 +7,7 @@ import asyncio import logging from datetime import datetime, timedelta from typing import Dict, Optional -from config import settings +from app.core.config import settings logger = logging.getLogger(__name__) diff --git a/oauth2/controller.py b/app/modules/oauth2/controller.py similarity index 99% rename from oauth2/controller.py rename to app/modules/oauth2/controller.py index c67fd0e..07be94c 100644 --- a/oauth2/controller.py +++ b/app/modules/oauth2/controller.py @@ -19,8 +19,8 @@ from fastapi.responses import RedirectResponse, JSONResponse from fastapi.security import HTTPBasic, HTTPBasicCredentials from sqlalchemy.ext.asyncio import AsyncSession -from database import get_db -from config import settings +from app.core.database import get_db +from app.core.config import settings from .services import ( OAuthService, TokenService, diff --git a/oauth2/dependencies.py b/app/modules/oauth2/dependencies.py similarity index 100% rename from oauth2/dependencies.py rename to app/modules/oauth2/dependencies.py diff --git a/models/oauth_models.py b/app/modules/oauth2/models.py similarity index 98% rename from models/oauth_models.py rename to app/modules/oauth2/models.py index 2e82eef..c8999da 100644 --- a/models/oauth_models.py +++ b/app/modules/oauth2/models.py @@ -1,7 +1,7 @@ from sqlalchemy import Column, Integer, String, Boolean, TIMESTAMP, ForeignKey, Index, UniqueConstraint from sqlalchemy.sql import func from sqlalchemy.dialects.sqlite import JSON -from database import Base +from app.core.database import Base class OAuthClient(Base): diff --git a/oauth2/repositories.py b/app/modules/oauth2/repositories.py similarity index 99% rename from oauth2/repositories.py rename to app/modules/oauth2/repositories.py index 653a48d..7c3727f 100644 --- a/oauth2/repositories.py +++ b/app/modules/oauth2/repositories.py @@ -6,8 +6,8 @@ from sqlalchemy.exc import SQLAlchemyError import logging # Import database first to resolve circular import -import database -from models.oauth_models import OAuthClient, OAuthToken, OAuthUser +from app.core import database +from app.modules.oauth2.models import OAuthClient, OAuthToken, OAuthUser logger = logging.getLogger(__name__) diff --git a/oauth2/schemas.py b/app/modules/oauth2/schemas.py similarity index 100% rename from oauth2/schemas.py rename to app/modules/oauth2/schemas.py diff --git a/oauth2/services.py b/app/modules/oauth2/services.py similarity index 99% rename from oauth2/services.py rename to app/modules/oauth2/services.py index 76fbbd6..3e98175 100644 --- a/oauth2/services.py +++ b/app/modules/oauth2/services.py @@ -8,8 +8,8 @@ from jose import jwt, JWTError from fastapi import HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from config import settings -from middleware.auth_middleware import verify_password +from app.core.config import settings +from app.core.middleware.auth_middleware import verify_password from .repositories import OAuthClientRepository, OAuthTokenRepository, OAuthUserRepository from .schemas import OAuthTokenCreate, OAuthClientResponse from .auth_code_store import authorization_code_store diff --git a/app/static/__init__.py b/app/static/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/templates/__init__.py b/app/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config.py.backup b/config.py.backup deleted file mode 100644 index e067daa..0000000 --- a/config.py.backup +++ /dev/null @@ -1,43 +0,0 @@ -from pydantic_settings import BaseSettings -from typing import Optional -from pydantic import field_validator, ConfigDict - - -class Settings(BaseSettings): - # Database - database_url: str = "sqlite+aiosqlite:///./mockapi.db" - - # Application - debug: bool = False - title: str = "Mock API Server" - version: str = "1.0.0" - - # Admin authentication - admin_username: str = "admin" - admin_password: str = "admin123" - secret_key: str = "your-secret-key-here-change-me" - - # Security - session_cookie_name: str = "mockapi_session" - session_max_age: int = 24 * 60 * 60 # 24 hours - - @field_validator('admin_password') - def validate_admin_password(cls, v, info): - if not info.data.get('debug', True) and v == "admin123": - raise ValueError( - 'admin_password must be set via environment variable in production (debug=False)' - ) - return v - - @field_validator('secret_key') - def validate_secret_key(cls, v, info): - if not info.data.get('debug', True) and v == "your-secret-key-here-change-me": - raise ValueError( - 'secret_key must be set via environment variable in production (debug=False)' - ) - return v - - model_config = ConfigDict(env_file=".env") - - -settings = Settings() diff --git a/cookies.txt b/cookies.txt new file mode 100644 index 0000000..c31d989 --- /dev/null +++ b/cookies.txt @@ -0,0 +1,4 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..8879c88 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,173 @@ +# API Testing Collections for MockAPI + +This directory contains ready-to-use API collections for testing the MockAPI application with OAuth2 provider. + +## Available Collections + +### 1. Bruno Collection (`mockapi-collection.bru`) +- **Format**: Bruno native format (`.bru`) +- **Features**: Scripting support, environment variables, folder organization +- **Import**: Drag and drop into Bruno or use "Import Collection" + +### 2. Postman Collection (`mockapi-postman-collection.json`) +- **Format**: Postman Collection v2.1 +- **Features**: Pre-request scripts, tests, environment variables +- **Import**: Import into Postman via "Import" button + +### 3. Setup Scripts +- `setup-test-client.py`: Creates test OAuth client in database +- `setup.sh`: Interactive setup helper + +## Collection Features + +Both collections include: + +### Global Variables +- `baseUrl`: Base URL of your MockAPI instance (default: `http://localhost:8000`) +- `adminUsername`, `adminPassword`: Admin credentials (default: `admin`/`admin123`) +- `clientId`, `clientSecret`: OAuth2 client credentials (use `test_client`/`test_secret` after setup) +- `accessToken`, `refreshToken`, `authCode`: OAuth2 tokens (auto-populated) +- `endpointId`: ID of created mock endpoints (auto-populated) + +### Request Folders + +1. **Health Check** - Basic health endpoint +2. **Admin - Login** - Admin authentication (sets session cookie) +3. **Mock Endpoints** - CRUD operations for mock endpoints + - Create, list, update, delete endpoints + - Call mock endpoints with template variables +4. **OAuth2** - Full OAuth2 flow testing + - Client credentials grant + - Authorization code grant (2-step) + - Refresh token grant + - Token introspection and revocation + - OpenID Connect discovery +5. **Admin OAuth Management** - OAuth2 admin UI endpoints +6. **Protected Endpoint Example** - Create and test OAuth-protected endpoints + +## Quick Setup + +### 1. Start MockAPI Server +```bash +# Development (auto-reload) +python run.py + +# Or production +waitress-serve --host=0.0.0.0 --port=8000 wsgi:wsgi_app +``` + +### 2. Create Test OAuth Client +```bash +# Run setup script +./examples/setup.sh + +# Or directly +python examples/setup-test-client.py +``` + +This creates an OAuth client with: +- **Client ID**: `test_client` +- **Client Secret**: `test_secret` +- **Grant Types**: `authorization_code`, `client_credentials`, `refresh_token` +- **Scopes**: `openid`, `profile`, `email`, `api:read`, `api:write` + +### 3. Import Collection +- **Bruno**: Import `mockapi-collection.bru` +- **Postman**: Import `mockapi-postman-collection.json` + +### 4. Update Variables (if needed) +- Update `baseUrl` if server runs on different host/port +- Use `test_client`/`test_secret` for OAuth2 testing + +## Testing Workflow + +### Basic Testing +1. Run **Health Check** to verify server is running +2. Run **Admin - Login** to authenticate (sets session cookie) +3. Use **Mock Endpoints** folder to create and test endpoints + +### OAuth2 Testing +1. Ensure test client is created (`test_client`/`test_secret`) +2. Run **Client Credentials Grant** to get access token +3. Token is automatically saved to `accessToken` variable +4. Use token in protected endpoint requests + +### Protected Endpoints +1. Create protected endpoint using **Create OAuth-Protected Endpoint** +2. Test unauthorized access (should fail with 401/403) +3. Test authorized access with saved token + +## Collection-Specific Features + +### Bruno Features +- **Scripting**: JavaScript scripts for request/response handling +- **Variables**: `{{variable}}` syntax in URLs, headers, body +- **`btoa()` function**: Built-in for Basic auth encoding +- **Console logs**: Script output in Bruno console + +### Postman Features +- **Pre-request scripts**: Setup before requests +- **Tests**: JavaScript tests after responses +- **Environment variables**: Separate from collection variables +- **Basic auth UI**: Built-in authentication helpers + +## Manual Testing with cURL + +For quick manual testing without collections: + +```bash +# Health check +curl http://localhost:8000/health + +# Create mock endpoint (after admin login) +curl -c cookies.txt -X POST http://localhost:8000/admin/login \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=admin&password=admin123" + +curl -b cookies.txt -X POST http://localhost:8000/admin/endpoints \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "route=/api/test&method=GET&response_body={\"message\":\"test\"}&response_code=200&content_type=application/json&is_active=true" + +# OAuth2 client credentials grant +curl -X POST http://localhost:8000/oauth/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials&client_id=test_client&client_secret=test_secret&scope=api:read" +``` + +## Troubleshooting + +### Common Issues + +1. **401 Unauthorized (Admin endpoints)** + - Run **Admin - Login** first + - Check session cookies are being sent + +2. **403 Forbidden (OAuth2)** + - Verify token has required scopes + - Check endpoint `oauth_scopes` configuration + +3. **404 Not Found** + - Endpoint may not be active (`is_active=true`) + - Route may not be registered (refresh routes) + +4. **Invalid OAuth Client** + - Run setup script to create test client + - Update `clientId`/`clientSecret` variables + +5. **Bruno/Postman Import Errors** + - Ensure JSON format is valid + - Try re-downloading collection files + +### Debug Tips + +- Enable debug logging in MockAPI: Set `DEBUG=True` in `.env` +- Check Bruno/Postman console for script output +- Verify variables are set correctly before requests +- Test endpoints directly in browser: `http://localhost:8000/docs` + +## Resources + +- [MockAPI Documentation](../README.md) +- [Bruno Documentation](https://docs.usebruno.com/) +- [Postman Documentation](https://learning.postman.com/docs/getting-started/introduction/) +- [OAuth2 RFC 6749](https://tools.ietf.org/html/rfc6749) diff --git a/examples/mockapi-collection.bru b/examples/mockapi-collection.bru new file mode 100644 index 0000000..fb9c70e --- /dev/null +++ b/examples/mockapi-collection.bru @@ -0,0 +1,756 @@ +{ + "version": "1", + "name": "MockAPI Collection", + "type": "collection", + "variables": [ + { + "name": "baseUrl", + "value": "http://localhost:8000", + "enabled": true + }, + { + "name": "adminUsername", + "value": "admin", + "enabled": true + }, + { + "name": "adminPassword", + "value": "admin123", + "enabled": true + }, + { + "name": "clientId", + "value": "test_client", + "enabled": true + }, + { + "name": "clientSecret", + "value": "test_secret", + "enabled": true + }, + { + "name": "accessToken", + "value": "", + "enabled": true + }, + { + "name": "authCode", + "value": "", + "enabled": true + }, + { + "name": "refreshToken", + "value": "", + "enabled": true + }, + { + "name": "endpointId", + "value": "", + "enabled": true + }, + { + "name": "oauthClientId", + "value": "", + "enabled": true + } + ], + "items": [ + { + "name": "Health Check", + "type": "http", + "request": { + "url": "{{baseUrl}}/health", + "method": "GET", + "headers": [], + "body": {}, + "auth": {} + } + }, + { + "name": "Admin - Login", + "type": "http", + "request": { + "url": "{{baseUrl}}/admin/login", + "method": "POST", + "headers": [ + { + "name": "Content-Type", + "value": "application/x-www-form-urlencoded", + "enabled": true + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "name": "username", + "value": "{{adminUsername}}", + "enabled": true + }, + { + "name": "password", + "value": "{{adminPassword}}", + "enabled": true + } + ] + }, + "auth": {} + }, + "script": { + "req": "// This will set a session cookie automatically\nconsole.log('Login response headers:', req.headers);" + } + }, + { + "name": "Mock Endpoints", + "type": "folder", + "items": [ + { + "name": "List Endpoints", + "type": "http", + "request": { + "url": "{{baseUrl}}/admin/endpoints", + "method": "GET", + "headers": [], + "body": {}, + "auth": {} + } + }, + { + "name": "Create Mock Endpoint", + "type": "http", + "request": { + "url": "{{baseUrl}}/admin/endpoints", + "method": "POST", + "headers": [ + { + "name": "Content-Type", + "value": "application/x-www-form-urlencoded", + "enabled": true + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "name": "route", + "value": "/api/greeting/{name}", + "enabled": true + }, + { + "name": "method", + "value": "GET", + "enabled": true + }, + { + "name": "response_body", + "value": "{\"message\": \"Hello, {{ name }}!\", \"timestamp\": \"{{ timestamp }}\"}", + "enabled": true + }, + { + "name": "response_code", + "value": "200", + "enabled": true + }, + { + "name": "content_type", + "value": "application/json", + "enabled": true + }, + { + "name": "is_active", + "value": "true", + "enabled": true + }, + { + "name": "variables", + "value": "{}", + "enabled": true + }, + { + "name": "headers", + "value": "{}", + "enabled": true + }, + { + "name": "delay_ms", + "value": "0", + "enabled": true + } + ] + }, + "auth": {} + }, + "script": { + "res": "// Extract endpoint ID from response\nconst location = res.headers.location;\nif (location && location.includes('/admin/endpoints/')) {\n const match = location.match(/\\/admin\\/endpoints\\/(\\d+)/);\n if (match) {\n bruno.setVar('endpointId', match[1]);\n console.log('Endpoint ID saved:', match[1]);\n }\n}" + } + }, + { + "name": "Call Mock Endpoint", + "type": "http", + "request": { + "url": "{{baseUrl}}/api/greeting/World", + "method": "GET", + "headers": [], + "body": {}, + "auth": {} + } + }, + { + "name": "Call Mock Endpoint with Query", + "type": "http", + "request": { + "url": "{{baseUrl}}/api/greeting/World?format=json", + "method": "GET", + "headers": [], + "body": {}, + "auth": {} + } + }, + { + "name": "Update Endpoint", + "type": "http", + "request": { + "url": "{{baseUrl}}/admin/endpoints/{{endpointId}}", + "method": "POST", + "headers": [ + { + "name": "Content-Type", + "value": "application/x-www-form-urlencoded", + "enabled": true + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "name": "route", + "value": "/api/greeting/{name}", + "enabled": true + }, + { + "name": "method", + "value": "GET", + "enabled": true + }, + { + "name": "response_body", + "value": "{\"message\": \"Hello, {{ name }}! Welcome to MockAPI.\", \"timestamp\": \"{{ timestamp }}\"}", + "enabled": true + }, + { + "name": "response_code", + "value": "200", + "enabled": true + }, + { + "name": "content_type", + "value": "application/json", + "enabled": true + }, + { + "name": "is_active", + "value": "true", + "enabled": true + }, + { + "name": "variables", + "value": "{}", + "enabled": true + }, + { + "name": "headers", + "value": "{}", + "enabled": true + }, + { + "name": "delay_ms", + "value": "100", + "enabled": true + } + ] + }, + "auth": {} + } + }, + { + "name": "Delete Endpoint", + "type": "http", + "request": { + "url": "{{baseUrl}}/admin/endpoints/{{endpointId}}/delete", + "method": "POST", + "headers": [], + "body": {}, + "auth": {} + } + } + ] + }, + { + "name": "OAuth2", + "type": "folder", + "items": [ + { + "name": "Create OAuth Client (via Admin)", + "type": "http", + "request": { + "url": "{{baseUrl}}/admin/oauth/clients", + "method": "POST", + "headers": [ + { + "name": "Content-Type", + "value": "application/x-www-form-urlencoded", + "enabled": true + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "name": "client_name", + "value": "Test Client", + "enabled": true + }, + { + "name": "redirect_uris", + "value": "http://localhost:8080/callback,https://example.com/cb", + "enabled": true + }, + { + "name": "grant_types", + "value": "authorization_code,client_credentials,refresh_token", + "enabled": true + }, + { + "name": "scopes", + "value": "openid profile email api:read api:write", + "enabled": true + }, + { + "name": "is_active", + "value": "true", + "enabled": true + } + ] + }, + "auth": {} + }, + "script": { + "res": "// Extract client ID from response\n// Note: In real usage, you'd get the client ID from the admin UI or API response\nconsole.log('Client created. Set clientId and clientSecret variables manually.');" + } + }, + { + "name": "Client Credentials Grant", + "type": "http", + "request": { + "url": "{{baseUrl}}/oauth/token", + "method": "POST", + "headers": [ + { + "name": "Content-Type", + "value": "application/x-www-form-urlencoded", + "enabled": true + }, + { + "name": "Authorization", + "value": "Basic {{btoa(clientId + ':' + clientSecret)}}", + "enabled": true + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "name": "grant_type", + "value": "client_credentials", + "enabled": true + }, + { + "name": "scope", + "value": "api:read", + "enabled": true + } + ] + }, + "auth": {} + }, + "script": { + "res": "// Save access token\nif (res.status === 200) {\n const data = JSON.parse(res.body);\n bruno.setVar('accessToken', data.access_token);\n if (data.refresh_token) {\n bruno.setVar('refreshToken', data.refresh_token);\n }\n console.log('Access token saved:', data.access_token.substring(0, 20) + '...');\n}" + } + }, + { + "name": "Authorization Code Grant - Step 1: Get Auth Code", + "type": "http", + "request": { + "url": "{{baseUrl}}/oauth/authorize", + "method": "GET", + "headers": [], + "params": [ + { + "name": "response_type", + "value": "code", + "enabled": true + }, + { + "name": "client_id", + "value": "{{clientId}}", + "enabled": true + }, + { + "name": "redirect_uri", + "value": "http://localhost:8080/callback", + "enabled": true + }, + { + "name": "scope", + "value": "api:read openid", + "enabled": true + }, + { + "name": "state", + "value": "xyz123", + "enabled": true + } + ], + "body": {}, + "auth": {} + }, + "script": { + "res": "// Extract authorization code from redirect location\n// Note: This requires manual extraction from the redirect URL\nconsole.log('Check redirect location header for authorization code');\nconst location = res.headers.location;\nif (location) {\n console.log('Redirect URL:', location);\n // In Bruno, you'd parse the URL to get the code\n}" + } + }, + { + "name": "Authorization Code Grant - Step 2: Exchange Code for Token", + "type": "http", + "request": { + "url": "{{baseUrl}}/oauth/token", + "method": "POST", + "headers": [ + { + "name": "Content-Type", + "value": "application/x-www-form-urlencoded", + "enabled": true + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "name": "grant_type", + "value": "authorization_code", + "enabled": true + }, + { + "name": "code", + "value": "{{authCode}}", + "enabled": true + }, + { + "name": "redirect_uri", + "value": "http://localhost:8080/callback", + "enabled": true + }, + { + "name": "client_id", + "value": "{{clientId}}", + "enabled": true + }, + { + "name": "client_secret", + "value": "{{clientSecret}}", + "enabled": true + } + ] + }, + "auth": {} + }, + "script": { + "res": "// Save tokens\nif (res.status === 200) {\n const data = JSON.parse(res.body);\n bruno.setVar('accessToken', data.access_token);\n if (data.refresh_token) {\n bruno.setVar('refreshToken', data.refresh_token);\n }\n console.log('Access token saved:', data.access_token.substring(0, 20) + '...');\n}" + } + }, + { + "name": "Refresh Token Grant", + "type": "http", + "request": { + "url": "{{baseUrl}}/oauth/token", + "method": "POST", + "headers": [ + { + "name": "Content-Type", + "value": "application/x-www-form-urlencoded", + "enabled": true + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "name": "grant_type", + "value": "refresh_token", + "enabled": true + }, + { + "name": "refresh_token", + "value": "{{refreshToken}}", + "enabled": true + }, + { + "name": "client_id", + "value": "{{clientId}}", + "enabled": true + }, + { + "name": "client_secret", + "value": "{{clientSecret}}", + "enabled": true + } + ] + }, + "auth": {} + }, + "script": { + "res": "// Save new tokens\nif (res.status === 200) {\n const data = JSON.parse(res.body);\n bruno.setVar('accessToken', data.access_token);\n if (data.refresh_token) {\n bruno.setVar('refreshToken', data.refresh_token);\n }\n console.log('New access token saved:', data.access_token.substring(0, 20) + '...');\n}" + } + }, + { + "name": "User Info", + "type": "http", + "request": { + "url": "{{baseUrl}}/oauth/userinfo", + "method": "GET", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{accessToken}}", + "enabled": true + } + ], + "body": {}, + "auth": {} + } + }, + { + "name": "Token Introspection", + "type": "http", + "request": { + "url": "{{baseUrl}}/oauth/introspect", + "method": "POST", + "headers": [ + { + "name": "Content-Type", + "value": "application/x-www-form-urlencoded", + "enabled": true + }, + { + "name": "Authorization", + "value": "Basic {{btoa(clientId + ':' + clientSecret)}}", + "enabled": true + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "name": "token", + "value": "{{accessToken}}", + "enabled": true + } + ] + }, + "auth": {} + } + }, + { + "name": "Token Revocation", + "type": "http", + "request": { + "url": "{{baseUrl}}/oauth/revoke", + "method": "POST", + "headers": [ + { + "name": "Content-Type", + "value": "application/x-www-form-urlencoded", + "enabled": true + }, + { + "name": "Authorization", + "value": "Basic {{btoa(clientId + ':' + clientSecret)}}", + "enabled": true + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "name": "token", + "value": "{{accessToken}}", + "enabled": true + } + ] + }, + "auth": {} + } + }, + { + "name": "OpenID Connect Discovery", + "type": "http", + "request": { + "url": "{{baseUrl}}/.well-known/openid-configuration", + "method": "GET", + "headers": [], + "body": {}, + "auth": {} + } + } + ] + }, + { + "name": "Admin OAuth Management", + "type": "folder", + "items": [ + { + "name": "List OAuth Clients", + "type": "http", + "request": { + "url": "{{baseUrl}}/admin/oauth/clients", + "method": "GET", + "headers": [], + "body": {}, + "auth": {} + } + }, + { + "name": "List OAuth Tokens", + "type": "http", + "request": { + "url": "{{baseUrl}}/admin/oauth/tokens", + "method": "GET", + "headers": [], + "body": {}, + "auth": {} + } + }, + { + "name": "List OAuth Users", + "type": "http", + "request": { + "url": "{{baseUrl}}/admin/oauth/users", + "method": "GET", + "headers": [], + "body": {}, + "auth": {} + } + } + ] + }, + { + "name": "Protected Endpoint Example", + "type": "folder", + "items": [ + { + "name": "Create OAuth-Protected Endpoint", + "type": "http", + "request": { + "url": "{{baseUrl}}/admin/endpoints", + "method": "POST", + "headers": [ + { + "name": "Content-Type", + "value": "application/x-www-form-urlencoded", + "enabled": true + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "name": "route", + "value": "/api/protected/data", + "enabled": true + }, + { + "name": "method", + "value": "GET", + "enabled": true + }, + { + "name": "response_body", + "value": "{\"data\": \"This is protected data\", \"timestamp\": \"{{ timestamp }}\"}", + "enabled": true + }, + { + "name": "response_code", + "value": "200", + "enabled": true + }, + { + "name": "content_type", + "value": "application/json", + "enabled": true + }, + { + "name": "is_active", + "value": "true", + "enabled": true + }, + { + "name": "requires_oauth", + "value": "true", + "enabled": true + }, + { + "name": "oauth_scopes", + "value": "[\"api:read\"]", + "enabled": true + }, + { + "name": "variables", + "value": "{}", + "enabled": true + }, + { + "name": "headers", + "value": "{}", + "enabled": true + }, + { + "name": "delay_ms", + "value": "0", + "enabled": true + } + ] + }, + "auth": {} + } + }, + { + "name": "Call Protected Endpoint (Unauthorized)", + "type": "http", + "request": { + "url": "{{baseUrl}}/api/protected/data", + "method": "GET", + "headers": [], + "body": {}, + "auth": {} + } + }, + { + "name": "Call Protected Endpoint (Authorized)", + "type": "http", + "request": { + "url": "{{baseUrl}}/api/protected/data", + "method": "GET", + "headers": [ + { + "name": "Authorization", + "value": "Bearer {{accessToken}}", + "enabled": true + } + ], + "body": {}, + "auth": {} + } + } + ] + } + ] +} diff --git a/examples/mockapi-postman-collection.json b/examples/mockapi-postman-collection.json new file mode 100644 index 0000000..8cfba5d --- /dev/null +++ b/examples/mockapi-postman-collection.json @@ -0,0 +1,942 @@ +{ + "info": { + "name": "MockAPI Collection", + "description": "Postman collection for testing MockAPI with OAuth2 provider", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8000", + "type": "string" + }, + { + "key": "adminUsername", + "value": "admin", + "type": "string" + }, + { + "key": "adminPassword", + "value": "admin123", + "type": "string" + }, + { + "key": "clientId", + "value": "test_client", + "type": "string" + }, + { + "key": "clientSecret", + "value": "test_secret", + "type": "string" + }, + { + "key": "accessToken", + "value": "", + "type": "string" + }, + { + "key": "authCode", + "value": "", + "type": "string" + }, + { + "key": "refreshToken", + "value": "", + "type": "string" + }, + { + "key": "endpointId", + "value": "", + "type": "string" + }, + { + "key": "oauthClientId", + "value": "", + "type": "string" + } + ], + "item": [ + { + "name": "Health Check", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/health", + "host": ["{{baseUrl}}"], + "path": ["health"] + }, + "description": "Basic health endpoint" + }, + "response": [] + }, + { + "name": "Admin - Login", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded", + "type": "text" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "username", + "value": "{{adminUsername}}", + "type": "text" + }, + { + "key": "password", + "value": "{{adminPassword}}", + "type": "text" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/admin/login", + "host": ["{{baseUrl}}"], + "path": ["admin", "login"] + }, + "description": "Admin authentication (sets session cookie)" + }, + "response": [] + }, + { + "name": "Mock Endpoints", + "item": [ + { + "name": "List Endpoints", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/endpoints", + "host": ["{{baseUrl}}"], + "path": ["admin", "endpoints"] + }, + "description": "List all mock endpoints" + }, + "response": [] + }, + { + "name": "Create Mock Endpoint", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded", + "type": "text" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "route", + "value": "/api/greeting/{name}", + "type": "text" + }, + { + "key": "method", + "value": "GET", + "type": "text" + }, + { + "key": "response_body", + "value": "{\"message\": \"Hello, {{ name }}!\", \"timestamp\": \"{{ timestamp }}\"}", + "type": "text" + }, + { + "key": "response_code", + "value": "200", + "type": "text" + }, + { + "key": "content_type", + "value": "application/json", + "type": "text" + }, + { + "key": "is_active", + "value": "true", + "type": "text" + }, + { + "key": "variables", + "value": "{}", + "type": "text" + }, + { + "key": "headers", + "value": "{}", + "type": "text" + }, + { + "key": "delay_ms", + "value": "0", + "type": "text" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/admin/endpoints", + "host": ["{{baseUrl}}"], + "path": ["admin", "endpoints"] + }, + "description": "Create a new mock endpoint" + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Extract endpoint ID from response location header", + "if (pm.response.headers.get('Location')) {", + " const location = pm.response.headers.get('Location');", + " const match = location.match(/\\/admin\\/endpoints\\/(\\d+)/);", + " if (match && match[1]) {", + " pm.collectionVariables.set('endpointId', match[1]);", + " console.log('Endpoint ID saved:', match[1]);", + " }", + "}" + ], + "type": "text/javascript" + } + } + ], + "response": [] + }, + { + "name": "Call Mock Endpoint", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/greeting/World", + "host": ["{{baseUrl}}"], + "path": ["api", "greeting", "World"] + }, + "description": "Call the created mock endpoint" + }, + "response": [] + }, + { + "name": "Call Mock Endpoint with Query", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/greeting/World?format=json", + "host": ["{{baseUrl}}"], + "path": ["api", "greeting", "World"], + "query": [ + { + "key": "format", + "value": "json" + } + ] + }, + "description": "Call mock endpoint with query parameter" + }, + "response": [] + }, + { + "name": "Update Endpoint", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded", + "type": "text" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "route", + "value": "/api/greeting/{name}", + "type": "text" + }, + { + "key": "method", + "value": "GET", + "type": "text" + }, + { + "key": "response_body", + "value": "{\"message\": \"Hello, {{ name }}! Welcome to MockAPI.\", \"timestamp\": \"{{ timestamp }}\"}", + "type": "text" + }, + { + "key": "response_code", + "value": "200", + "type": "text" + }, + { + "key": "content_type", + "value": "application/json", + "type": "text" + }, + { + "key": "is_active", + "value": "true", + "type": "text" + }, + { + "key": "variables", + "value": "{}", + "type": "text" + }, + { + "key": "headers", + "value": "{}", + "type": "text" + }, + { + "key": "delay_ms", + "value": "100", + "type": "text" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/admin/endpoints/{{endpointId}}", + "host": ["{{baseUrl}}"], + "path": ["admin", "endpoints", "{{endpointId}}"] + }, + "description": "Update an existing endpoint" + }, + "response": [] + }, + { + "name": "Delete Endpoint", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/endpoints/{{endpointId}}/delete", + "host": ["{{baseUrl}}"], + "path": ["admin", "endpoints", "{{endpointId}}", "delete"] + }, + "description": "Delete an endpoint" + }, + "response": [] + } + ] + }, + { + "name": "OAuth2", + "item": [ + { + "name": "Create OAuth Client (via Admin)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded", + "type": "text" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "client_name", + "value": "Test Client", + "type": "text" + }, + { + "key": "redirect_uris", + "value": "http://localhost:8080/callback,https://example.com/cb", + "type": "text" + }, + { + "key": "grant_types", + "value": "authorization_code,client_credentials,refresh_token", + "type": "text" + }, + { + "key": "scopes", + "value": "openid profile email api:read api:write", + "type": "text" + }, + { + "key": "is_active", + "value": "true", + "type": "text" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/admin/oauth/clients", + "host": ["{{baseUrl}}"], + "path": ["admin", "oauth", "clients"] + }, + "description": "Create an OAuth client via admin interface" + }, + "response": [] + }, + { + "name": "Client Credentials Grant", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded", + "type": "text" + } + ], + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{clientId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{clientSecret}}", + "type": "string" + } + ] + }, + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "client_credentials", + "type": "text" + }, + { + "key": "scope", + "value": "api:read", + "type": "text" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/oauth/token", + "host": ["{{baseUrl}}"], + "path": ["oauth", "token"] + }, + "description": "Obtain access token using client credentials grant" + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Save access token from response", + "if (pm.response.code === 200) {", + " const response = pm.response.json();", + " pm.collectionVariables.set('accessToken', response.access_token);", + " if (response.refresh_token) {", + " pm.collectionVariables.set('refreshToken', response.refresh_token);", + " }", + " console.log('Access token saved:', response.access_token.substring(0, 20) + '...');", + "}" + ], + "type": "text/javascript" + } + } + ], + "response": [] + }, + { + "name": "Authorization Code Grant - Step 1: Get Auth Code", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/oauth/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:8080/callback&scope=api:read openid&state=xyz123", + "host": ["{{baseUrl}}"], + "path": ["oauth", "authorize"], + "query": [ + { + "key": "response_type", + "value": "code" + }, + { + "key": "client_id", + "value": "{{clientId}}" + }, + { + "key": "redirect_uri", + "value": "http://localhost:8080/callback" + }, + { + "key": "scope", + "value": "api:read openid" + }, + { + "key": "state", + "value": "xyz123" + } + ] + }, + "description": "First step: get authorization code (user redirects)" + }, + "response": [] + }, + { + "name": "Authorization Code Grant - Step 2: Exchange Code for Token", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded", + "type": "text" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "authorization_code", + "type": "text" + }, + { + "key": "code", + "value": "{{authCode}}", + "type": "text" + }, + { + "key": "redirect_uri", + "value": "http://localhost:8080/callback", + "type": "text" + }, + { + "key": "client_id", + "value": "{{clientId}}", + "type": "text" + }, + { + "key": "client_secret", + "value": "{{clientSecret}}", + "type": "text" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/oauth/token", + "host": ["{{baseUrl}}"], + "path": ["oauth", "token"] + }, + "description": "Second step: exchange authorization code for tokens" + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Save tokens from response", + "if (pm.response.code === 200) {", + " const response = pm.response.json();", + " pm.collectionVariables.set('accessToken', response.access_token);", + " if (response.refresh_token) {", + " pm.collectionVariables.set('refreshToken', response.refresh_token);", + " }", + " console.log('Access token saved:', response.access_token.substring(0, 20) + '...');", + "}" + ], + "type": "text/javascript" + } + } + ], + "response": [] + }, + { + "name": "Refresh Token Grant", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded", + "type": "text" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "refresh_token", + "type": "text" + }, + { + "key": "refresh_token", + "value": "{{refreshToken}}", + "type": "text" + }, + { + "key": "client_id", + "value": "{{clientId}}", + "type": "text" + }, + { + "key": "client_secret", + "value": "{{clientSecret}}", + "type": "text" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/oauth/token", + "host": ["{{baseUrl}}"], + "path": ["oauth", "token"] + }, + "description": "Refresh access token using refresh token" + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Save new tokens from response", + "if (pm.response.code === 200) {", + " const response = pm.response.json();", + " pm.collectionVariables.set('accessToken', response.access_token);", + " if (response.refresh_token) {", + " pm.collectionVariables.set('refreshToken', response.refresh_token);", + " }", + " console.log('New access token saved:', response.access_token.substring(0, 20) + '...');", + "}" + ], + "type": "text/javascript" + } + } + ], + "response": [] + }, + { + "name": "User Info", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/oauth/userinfo", + "host": ["{{baseUrl}}"], + "path": ["oauth", "userinfo"] + }, + "description": "Get user info using access token" + }, + "response": [] + }, + { + "name": "Token Introspection", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded", + "type": "text" + } + ], + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{clientId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{clientSecret}}", + "type": "string" + } + ] + }, + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "text" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/oauth/introspect", + "host": ["{{baseUrl}}"], + "path": ["oauth", "introspect"] + }, + "description": "Introspect token (RFC 7662)" + }, + "response": [] + }, + { + "name": "Token Revocation", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded", + "type": "text" + } + ], + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{clientId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{clientSecret}}", + "type": "string" + } + ] + }, + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "text" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/oauth/revoke", + "host": ["{{baseUrl}}"], + "path": ["oauth", "revoke"] + }, + "description": "Revoke token (RFC 7009)" + }, + "response": [] + }, + { + "name": "OpenID Connect Discovery", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/.well-known/openid-configuration", + "host": ["{{baseUrl}}"], + "path": [".well-known", "openid-configuration"] + }, + "description": "OpenID Connect discovery endpoint" + }, + "response": [] + } + ] + }, + { + "name": "Admin OAuth Management", + "item": [ + { + "name": "List OAuth Clients", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/oauth/clients", + "host": ["{{baseUrl}}"], + "path": ["admin", "oauth", "clients"] + }, + "description": "List OAuth clients in admin interface" + }, + "response": [] + }, + { + "name": "List OAuth Tokens", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/oauth/tokens", + "host": ["{{baseUrl}}"], + "path": ["admin", "oauth", "tokens"] + }, + "description": "List OAuth tokens in admin interface" + }, + "response": [] + }, + { + "name": "List OAuth Users", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/admin/oauth/users", + "host": ["{{baseUrl}}"], + "path": ["admin", "oauth", "users"] + }, + "description": "List OAuth users in admin interface" + }, + "response": [] + } + ] + }, + { + "name": "Protected Endpoint Example", + "item": [ + { + "name": "Create OAuth-Protected Endpoint", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded", + "type": "text" + } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "route", + "value": "/api/protected/data", + "type": "text" + }, + { + "key": "method", + "value": "GET", + "type": "text" + }, + { + "key": "response_body", + "value": "{\"data\": \"This is protected data\", \"timestamp\": \"{{ timestamp }}\"}", + "type": "text" + }, + { + "key": "response_code", + "value": "200", + "type": "text" + }, + { + "key": "content_type", + "value": "application/json", + "type": "text" + }, + { + "key": "is_active", + "value": "true", + "type": "text" + }, + { + "key": "requires_oauth", + "value": "true", + "type": "text" + }, + { + "key": "oauth_scopes", + "value": "[\"api:read\"]", + "type": "text" + }, + { + "key": "variables", + "value": "{}", + "type": "text" + }, + { + "key": "headers", + "value": "{}", + "type": "text" + }, + { + "key": "delay_ms", + "value": "0", + "type": "text" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/admin/endpoints", + "host": ["{{baseUrl}}"], + "path": ["admin", "endpoints"] + }, + "description": "Create an endpoint that requires OAuth2 authentication" + }, + "response": [] + }, + { + "name": "Call Protected Endpoint (Unauthorized)", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/protected/data", + "host": ["{{baseUrl}}"], + "path": ["api", "protected", "data"] + }, + "description": "Call protected endpoint without authentication (should fail)" + }, + "response": [] + }, + { + "name": "Call Protected Endpoint (Authorized)", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/api/protected/data", + "host": ["{{baseUrl}}"], + "path": ["api", "protected", "data"] + }, + "description": "Call protected endpoint with valid access token" + }, + "response": [] + } + ] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "// Pre-request script can be used for setup", + "console.log('MockAPI Collection - Base URL:', pm.collectionVariables.get('baseUrl'));" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Global test script", + "console.log('Request completed:', pm.request.url);" + ] + } + } + ], + "auth": null +} diff --git a/examples/setup-test-client.py b/examples/setup-test-client.py new file mode 100755 index 0000000..e614626 --- /dev/null +++ b/examples/setup-test-client.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +Script to create a test OAuth client for Bruno collection testing. +Run this script after starting the MockAPI server. +""" +import asyncio +import sys +import os +import json +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker +from sqlalchemy import select, text + +# Add parent directory to path +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 + HAS_AUTH = True +except ImportError: + # Fallback to simple hash function if middleware not available + import hashlib + import secrets + def get_password_hash(password: str) -> str: + # Simple hash for demo purposes (not production-ready) + salt = secrets.token_hex(8) + return f"{salt}${hashlib.sha256((salt + password).encode()).hexdigest()}" + HAS_AUTH = False + +async def create_test_client(): + """Create a test OAuth client in the database.""" + # Use the same database URL as the app + database_url = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./mockapi.db") + + engine = create_async_engine(database_url, echo=False) + + # Create session + async_session = sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False + ) + + async with async_session() as session: + # Check if test client already exists + result = await session.execute( + text("SELECT * FROM oauth_clients WHERE client_id = :client_id"), + {"client_id": "test_client"} + ) + existing = result.fetchone() + + if existing: + print("Test client already exists:") + print(f" Client ID: {existing.client_id}") + print(f" Client Secret: (hashed, original secret was 'test_secret')") + print(f" Name: {existing.name}") + print(f" Grant Types: {json.loads(existing.grant_types) if existing.grant_types else []}") + print(f" Scopes: {json.loads(existing.scopes) if existing.scopes else []}") + return + + # Create new test client + client_secret_plain = "test_secret" + client_secret_hash = get_password_hash(client_secret_plain) + + # Insert directly using SQL to avoid model import issues + await session.execute( + text(""" + INSERT INTO oauth_clients + (client_id, client_secret, name, redirect_uris, grant_types, scopes, is_active) + VALUES + (:client_id, :client_secret, :name, :redirect_uris, :grant_types, :scopes, :is_active) + """), + { + "client_id": "test_client", + "client_secret": client_secret_hash, + "name": "Test Client for API Collections", + "redirect_uris": json.dumps(["http://localhost:8080/callback"]), + "grant_types": json.dumps(["authorization_code", "client_credentials", "refresh_token"]), + "scopes": json.dumps(["openid", "profile", "email", "api:read", "api:write"]), + "is_active": 1 # SQLite uses 1 for true + } + ) + + await session.commit() + + print("✅ Test OAuth client created successfully!") + print() + print("Client Details:") + print(f" Client ID: test_client") + print(f" Client Secret: {client_secret_plain}") + print(f" Name: Test Client for API Collections") + print(f" Redirect URIs: ['http://localhost:8080/callback']") + print(f" Grant Types: ['authorization_code', 'client_credentials', 'refresh_token']") + print(f" Scopes: ['openid', 'profile', 'email', 'api:read', 'api:write']") + print() + print("Update API client variables:") + print(" - Set 'clientId' to 'test_client'") + print(" - Set 'clientSecret' to 'test_secret'") + print() + print("Or update the collection file variables directly (.bru for Bruno, .json for Postman).") + + # Get base URL from environment or use default + base_url = os.getenv("BASE_URL", "http://localhost:8000") + + print("\nCURL Examples:") + print("1. Client Credentials Grant:") + print(f' curl -X POST {base_url}/oauth/token \\') + print(' -H "Content-Type: application/x-www-form-urlencoded" \\') + print(' -d "grant_type=client_credentials&client_id=test_client&client_secret=test_secret&scope=api:read"') + + print("\n2. Create a mock endpoint (after admin login):") + print(' # First login (sets session cookie)') + print(f' curl -c cookies.txt -X POST {base_url}/admin/login \\') + print(' -H "Content-Type: application/x-www-form-urlencoded" \\') + print(' -d "username=admin&password=admin123"') + print(' # Then create endpoint') + print(f' curl -b cookies.txt -X POST {base_url}/admin/endpoints \\') + print(' -H "Content-Type: application/x-www-form-urlencoded" \\') + print(' -d "route=/api/test&method=GET&response_body={\\"message\\":\\"test\\"}&response_code=200&content_type=application/json&is_active=true"') + +if __name__ == "__main__": + asyncio.run(create_test_client()) diff --git a/examples/setup.sh b/examples/setup.sh new file mode 100755 index 0000000..a89865d --- /dev/null +++ b/examples/setup.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Setup script for MockAPI API collections + +echo "🔧 Setting up MockAPI API collections..." + +# Check if Python is available +if ! command -v python3 &> /dev/null; then + echo "❌ Python 3 is not installed or not in PATH" + exit 1 +fi + +# Check if MockAPI server is running (optional) +echo "📡 Checking if MockAPI server is running..." +if curl -s http://localhost:8000/health > /dev/null 2>&1; then + echo "✅ MockAPI server is running" +else + echo "⚠️ MockAPI server may not be running on http://localhost:8000" + echo " Start it with: python run.py" + read -p "Continue anyway? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi +fi + +# Run the test client setup script +echo "🔄 Creating test OAuth client..." +cd "$(dirname "$0")/.." # Go to project root +python3 examples/setup-test-client.py + +echo "" +echo "✅ Setup complete!" +echo "" +echo "📋 Next steps:" +echo "1. Choose your API client:" +echo " - Bruno: Import 'examples/mockapi-collection.bru'" +echo " - Postman: Import 'examples/mockapi-postman-collection.json'" +echo "2. Update variables if needed:" +echo " - baseUrl: URL of your MockAPI instance" +echo " - clientId/clientSecret: Use the values printed above" +echo "3. Start testing!" +echo "" +echo "📚 Collections include:" +echo " - Health check" +echo " - Admin authentication" +echo " - Mock endpoint CRUD operations" +echo " - OAuth2 flows (client credentials, auth code, refresh)" +echo " - Token introspection and revocation" +echo " - Protected endpoint testing" +echo "" +echo "💡 Tip: Run 'Admin - Login' first to authenticate for admin endpoints" diff --git a/reset_admin_password.py b/reset_admin_password.py deleted file mode 100755 index 396b953..0000000 --- a/reset_admin_password.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -""" -Admin Password Reset Utility - -This script helps reset the admin password in the .env file. -Run with: python reset_admin_password.py [new_password] -If no password provided, a random secure password will be generated. -""" - -import os -import sys -import random -import string -import subprocess -from pathlib import Path - -def generate_secure_password(length=12): - """Generate a secure random password.""" - chars = string.ascii_letters + string.digits + "!@#$%^&*" - return ''.join(random.choice(chars) for _ in range(length)) - -def update_env_file(new_password): - """Update the ADMIN_PASSWORD in .env file.""" - env_file = Path(".env") - - if not env_file.exists(): - print("❌ .env file not found!") - print("Create one from .env.example: cp .env.example .env") - sys.exit(1) - - # Read current content - with open(env_file, 'r') as f: - lines = f.readlines() - - # Update ADMIN_PASSWORD line - updated = False - new_lines = [] - for line in lines: - if line.startswith("ADMIN_PASSWORD="): - new_lines.append(f"ADMIN_PASSWORD={new_password}\n") - updated = True - else: - new_lines.append(line) - - # Write back - with open(env_file, 'w') as f: - f.writelines(new_lines) - - if updated: - print(f"✅ Password updated in .env file") - return True - else: - print("❌ ADMIN_PASSWORD line not found in .env file") - return False - -def main(): - # Get new password from command line or generate - if len(sys.argv) > 1: - new_password = sys.argv[1] - print(f"🔐 Using provided password") - else: - new_password = generate_secure_password() - print(f"🔐 Generated secure password: {new_password}") - - # Update .env file - if update_env_file(new_password): - print("\n📋 Next steps:") - print(f"1. New password: {new_password}") - print("2. Restart the server for changes to take effect") - print("3. Log out and log back in if currently authenticated") - - # Offer to restart if server is running - print("\n💡 To restart:") - print(" If using 'python run.py': Ctrl+C and restart") - print(" If using 'uvicorn app:app --reload': It will auto-restart") - - # Show current settings - print("\n📄 Current .env location:", Path(".env").resolve()) - -if __name__ == "__main__": - main() diff --git a/test_production.py b/test_production.py deleted file mode 100644 index ef27e23..0000000 --- a/test_production.py +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env python3 -""" -Test production deployment with Waitress WSGI server. -Starts Waitress on a free port, verifies health endpoint, then shuts down. -""" -import subprocess -import time -import signal -import sys -import os -from typing import Optional -import httpx - -# Set environment variables for production-like settings -os.environ['DEBUG'] = 'False' -os.environ['ADMIN_PASSWORD'] = 'test-production-password' -os.environ['SECRET_KEY'] = 'test-secret-key-for-production-test' - -def wait_for_server(url: str, timeout: int = 10) -> bool: - """Wait until server responds with 200 OK.""" - start = time.time() - while time.time() - start < timeout: - try: - response = httpx.get(url, timeout=1) - if response.status_code == 200: - return True - except (httpx.ConnectError, httpx.ReadTimeout): - pass - time.sleep(0.5) - return False - -def main(): - port = 18081 # Use a high port unlikely to conflict - host = '127.0.0.1' - url = f'http://{host}:{port}' - - # Start Waitress server in a subprocess - print(f"Starting Waitress server on {url}...") - # Set PYTHONPATH to ensure wsgi module can be imported - env = os.environ.copy() - env['PYTHONPATH'] = '.' - proc = subprocess.Popen( - [ - 'waitress-serve', - '--host', host, - '--port', str(port), - '--threads', '2', - 'wsgi:wsgi_app' - ], - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True - ) - - try: - # Give server a moment to start - time.sleep(2) - - # Wait for server to be ready - print("Waiting for server to be ready...") - if not wait_for_server(f'{url}/health', timeout=30): - print("ERROR: Server did not become ready within timeout") - proc.terminate() - stdout, stderr = proc.communicate(timeout=5) - print("STDOUT:", stdout) - print("STDERR:", stderr) - sys.exit(1) - - # Test health endpoint - print("Testing health endpoint...") - response = httpx.get(f'{url}/health', timeout=5) - if response.status_code == 200: - print(f"SUCCESS: Health endpoint returned {response.status_code}: {response.json()}") - else: - print(f"ERROR: Health endpoint returned {response.status_code}: {response.text}") - sys.exit(1) - - # Test admin login page (should be accessible) - print("Testing admin login page...") - response = httpx.get(f'{url}/admin/login', timeout=5) - if response.status_code == 200 and 'Admin Login' in response.text: - print("SUCCESS: Admin login page accessible") - else: - print(f"ERROR: Admin login page failed: {response.status_code}") - sys.exit(1) - - print("\n✅ All production tests passed!") - - finally: - # Kill the server - print("Shutting down Waitress server...") - proc.terminate() - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait() - print("Server stopped.") - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/test_simple_wsgi.py b/test_simple_wsgi.py deleted file mode 100644 index 2e2bf3c..0000000 --- a/test_simple_wsgi.py +++ /dev/null @@ -1,9 +0,0 @@ -def simple_app(environ, start_response): - start_response('200 OK', [('Content-Type', 'text/plain')]) - return [b'Hello, World!'] - -wsgi_app = simple_app - -if __name__ == '__main__': - from waitress import serve - serve(wsgi_app, host='127.0.0.1', port=18082) \ No newline at end of file