chore: auto-commit 2026-03-16 09:00

This commit is contained in:
cclohmar 2026-03-16 09:00:26 +00:00
parent 9531bc9be8
commit 894020494a
57 changed files with 4288 additions and 624 deletions

1
.gitignore vendored
View file

@ -45,6 +45,7 @@ mockapi.db
server.log server.log
# IDE # IDE
.opencode/
.vscode/ .vscode/
.idea/ .idea/
*.swp *.swp

21
LICENSE Normal file
View file

@ -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.

View file

@ -144,8 +144,8 @@ See `requirements.txt` in architect spec.
- ✅ Phase 6.5: Configuration & Integration completed - ✅ Phase 6.5: Configuration & Integration completed
- ✅ Phase 6.6: Testing completed - ✅ Phase 6.6: Testing completed
## Next Steps ## Next Steps (Updated 2026-03-16)
1. Update documentation with OAuth2 usage examples. 1. ✅ Documentation updated with OAuth2 usage examples and Bruno API collection.
2. Deploy to production environment (if needed). 2. Deploy to production environment (if needed).
3. Consider adding PKCE support for public clients. 3. Consider adding PKCE support for public clients.
4. Add more advanced OAuth2 features (e.g., token introspection, JWKS endpoint). 4. Add more advanced OAuth2 features (e.g., token introspection, JWKS endpoint).

View file

@ -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.

209
README.md
View file

@ -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 ## 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. - **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. - **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. - **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.
- **EndpointLevel 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**: Standardscompliant discovery endpoint.
- **Production Ready**: Uses Waitress WSGI server, SQLAlchemy async, and FastAPI with proper error handling and security measures. - **Production Ready**: Uses Waitress WSGI server, SQLAlchemy async, and FastAPI with proper error handling and security measures.
## Technology Stack ## Technology Stack
@ -16,7 +20,8 @@ A lightweight, configurable mock API application in Python that allows dynamic e
- **Server**: Waitress (production WSGI server) - **Server**: Waitress (production WSGI server)
- **Database**: SQLite with SQLAlchemy 2.0 async ORM - **Database**: SQLite with SQLAlchemy 2.0 async ORM
- **Templating**: Jinja2 with sandboxed environment - **Templating**: Jinja2 with sandboxed environment
- **Authentication**: Session-based with bcrypt password hashing - **Authentication**: Sessionbased admin authentication with bcrypt password hashing
- **OAuth2**: JWTbased tokens with configurable scopes, client validation, and token revocation
- **Frontend**: Bootstrap 5 (CDN) for admin UI - **Frontend**: Bootstrap 5 (CDN) for admin UI
## Project Structure ## Project Structure
@ -31,38 +36,46 @@ mockapi/
├── middleware/ ├── middleware/
│ └── auth_middleware.py # Admin authentication middleware │ └── auth_middleware.py # Admin authentication middleware
├── models/ ├── models/
│ └── endpoint_model.py # Endpoint SQLAlchemy model │ ├── endpoint_model.py # Endpoint SQLAlchemy model
│ └── oauth_models.py # OAuth2 client, token, and user models
├── observers/ ├── observers/
│ └── __init__.py # Observer pattern placeholder │ └── __init__.py # Observer pattern placeholder
├── repositories/ ├── 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) ├── run.py # Development runner script (with auto-reload)
├── services/ ├── services/
│ ├── route_service.py # Dynamic route registration/management │ ├── route_service.py # Dynamic route registration/management
│ └── template_service.py # Jinja2 template rendering │ └── template_service.py # Jinja2 template rendering
├── controllers/ ├── controllers/
│ └── admin_controller.py # Admin UI routes │ ├── admin_controller.py # Admin UI routes
│ └── oauth2/ # OAuth2 controllers and services
├── schemas/ ├── schemas/
│ └── endpoint_schema.py # Pydantic schemas for validation │ ├── endpoint_schema.py # Pydantic schemas for validation
│ └── oauth2/ # OAuth2 schemas
├── templates/ # Jinja2 HTML templates ├── templates/ # Jinja2 HTML templates
│ ├── base.html # Base layout │ ├── base.html # Base layout
│ └── admin/ │ └── admin/
│ ├── login.html # Login page │ ├── login.html # Login page
│ ├── dashboard.html # Admin dashboard │ ├── dashboard.html # Admin dashboard
│ ├── endpoints.html # Endpoint list │ ├── endpoints.html # Endpoint list
│ └── endpoint_form.html # Create/edit endpoint │ ├── endpoint_form.html # Create/edit endpoint
│ └── oauth/ # OAuth2 management pages
├── static/ ├── static/
│ └── css/ # Static CSS (optional) │ └── css/ # Static CSS (optional)
├── tests/ # Test suite ├── tests/ # Test suite
│ ├── test_admin.py # Admin authentication tests │ ├── test_admin.py # Admin authentication tests
│ ├── test_endpoint_repository.py │ ├── test_endpoint_repository.py
│ └── test_route_manager_fix.py │ ├── test_route_manager_fix.py
│ ├── test_oauth2_controller.py
│ └── integration/ # Integration tests
├── utils/ # Utility modules ├── utils/ # Utility modules
│ └── __init__.py │ └── __init__.py
├── requirements.txt # Python dependencies ├── requirements.txt # Python dependencies
├── .env.example # Example environment variables ├── .env.example # Example environment variables
├── .env # Local environment variables (create from .env.example) ├── .env # Local environment variables (create from .env.example)
├── run_example.sh # Script to run the integration test ├── run_example.sh # Script to run the integration test
├── LICENSE # MIT License
└── README.md # This file └── 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. **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 serverside components.
- **Client Credentials**: For machinetomachine 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) |
| `/.wellknown/openidconfiguration` | 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 ## Production Deployment Considerations
### 1. **Environment Configuration** ### 1. **Environment Configuration**
@ -230,6 +365,7 @@ The following variable sources are available in response templates:
- **Activate/deactivate** endpoints without deletion - **Activate/deactivate** endpoints without deletion
- **Delete** endpoints (removes route) - **Delete** endpoints (removes route)
- **Dashboard** with statistics (total endpoints, active routes, etc.) - **Dashboard** with statistics (total endpoints, active routes, etc.)
- **OAuth2 management** clients, tokens, users
## Security Considerations ## 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. - **Request size limits**: Maximum body size of 1MB to prevent DoS.
- **Route validation**: Prevents path traversal (`..`) and other unsafe patterns. - **Route validation**: Prevents path traversal (`..`) and other unsafe patterns.
- **SQL injection protection**: All queries use SQLAlchemy ORM. - **SQL injection protection**: All queries use SQLAlchemy ORM.
- **OAuth2 security**: Client secret hashing, token revocation, scope validation, secure token storage.
## Configuration Options ## Configuration Options
@ -251,6 +388,10 @@ See `config.py` for all available settings. Key environment variables:
| `ADMIN_PASSWORD` | `admin123` | Admin login password (plaintext) | | `ADMIN_PASSWORD` | `admin123` | Admin login password (plaintext) |
| `SECRET_KEY` | `yoursecretkeyherechangeme` | Session signing secret | | `SECRET_KEY` | `yoursecretkeyherechangeme` | Session signing secret |
| `DEBUG` | `False` | Enable debug mode (more logging, relaxed validation) | | `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. **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 ## 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 ### Running Tests
Run tests with pytest: Run tests with pytest:
@ -277,6 +454,7 @@ The test suite includes:
- Unit tests for repository and service layers - Unit tests for repository and service layers
- Integration tests for admin authentication - Integration tests for admin authentication
- Template rendering tests - Template rendering tests
- OAuth2 unit and integration tests (21+ tests)
### Example Integration Test ### 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) - Check variable names match the context (use path_, query_, header_ prefixes as needed)
- View the rendered template in the admin edit form preview - 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 ### Logging
Enable debug logging by setting `DEBUG=True` in `.env`. Check the console output for detailed error messages. 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`) - SQLite only (but can be extended to PostgreSQL via `DATABASE_URL`)
- Single admin user (no multiuser support) - Single admin user (no multiuser support)
- No request logging/history - 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**: - **Possible extensions**:
- Import/export endpoints as JSON/YAML - 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 - Multiple admin users with roles
- Rate limiting per endpoint - Rate limiting per endpoint
- CORS configuration - CORS configuration
- PKCE support for public OAuth2 clients
- Integration with external identity providers (SAML, LDAP)
## License ## 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 ## Acknowledgments
- Built with [FastAPI](https://fastapi.tiangolo.com/), [SQLAlchemy](https://www.sqlalchemy.org/), and [Jinja2](https://jinja.palletsprojects.com/). - 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. - Admin UI uses [Bootstrap 5](https://getbootstrap.com/) via CDN.
- OAuth2 implementation follows RFC 6749, RFC 7662, and OpenID Connect standards.

View file

@ -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 autoreload)
```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` | `yoursecretkeyherechangeme` | 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 readytorun 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 multiuser 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.

573
README.md.backup2 Normal file
View file

@ -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.
- **EndpointLevel 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**: Standardscompliant 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**: Sessionbased admin authentication with bcrypt password hashing
- **OAuth2**: JWTbased 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 autoreload)
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 serverside components.
- **Client Credentials**: For machinetomachine 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) |
| `/.wellknown/openidconfiguration` | 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` | `yoursecretkeyherechangeme` | 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 readytorun 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 multiuser 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.

View file

@ -1,7 +1,7 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker, declarative_base from sqlalchemy.orm import sessionmaker, declarative_base
from sqlalchemy import text, event from sqlalchemy import text, event
from config import settings from app.core.config import settings
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -30,7 +30,8 @@ AsyncSessionLocal = sessionmaker(
Base = declarative_base() Base = declarative_base()
# Import models to ensure they are registered with Base.metadata # Import models to ensure they are registered with Base.metadata
from 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: async def get_db() -> AsyncSession:

View file

@ -1,7 +1,7 @@
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from app.core.database import get_db
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)

View file

@ -5,7 +5,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import status from fastapi import status
from starlette.requests import Request from starlette.requests import Request
from fastapi.responses import Response, RedirectResponse from fastapi.responses import Response, RedirectResponse
from config import settings from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

View file

0
app/modules/__init__.py Normal file
View file

View file

View file

@ -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
# Precomputed 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 precomputed 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)

View file

View file

View file

@ -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"<Endpoint {self.method} {self.route}>"

View file

@ -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

View file

@ -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`)

View file

@ -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 <token>",
headers={"WWW-Authenticate": "Bearer error=\"invalid_token\""},
)
token = parts[1]
# Create a database session and validate token
async with self.async_session_factory() as session:
token_service = TokenService(session)
try:
payload = await token_service.verify_token(token)
except HTTPException:
raise # Re-raise token validation errors
except Exception as e:
logger.error(f"Unexpected error during token validation: {e}", exc_info=settings.debug)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error",
)
# Check scopes if endpoint specifies required scopes
if endpoint.oauth_scopes:
scope_service = ScopeService(session)
token_scopes = payload.get("scopes", [])
if not scope_service.check_scope_access(token_scopes, endpoint.oauth_scopes):
logger.warning(
f"Insufficient scopes for endpoint {endpoint.method} {endpoint.route}. "
f"Token scopes: {token_scopes}, required: {endpoint.oauth_scopes}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient scope",
headers={"WWW-Authenticate": f"Bearer error=\"insufficient_scope\", scope=\"{' '.join(endpoint.oauth_scopes)}\""},
)
logger.debug(f"OAuth2 token validated for endpoint {endpoint.method} {endpoint.route}, client_id: {payload.get('client_id')}, scopes: {payload.get('scopes')}")
return payload
def _get_system_variables(self, request: Request) -> Dict[str, Any]:
"""
Generate system variables (e.g., timestamp, request ID).
Returns:
Dictionary of system variables.
"""
return {
"timestamp": time.time(),
"datetime": time.strftime("%Y-%m-%d %H:%M:%S"),
"request_id": str(uuid4()),
"method": request.method,
"url": str(request.url),
"client_host": request.client.host if request.client else None,
}

View file

@ -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

View file

@ -7,7 +7,7 @@ import asyncio
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Dict, Optional from typing import Dict, Optional
from config import settings from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -19,8 +19,8 @@ from fastapi.responses import RedirectResponse, JSONResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security import HTTPBasic, HTTPBasicCredentials
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from app.core.database import get_db
from config import settings from app.core.config import settings
from .services import ( from .services import (
OAuthService, OAuthService,
TokenService, TokenService,

View file

@ -1,7 +1,7 @@
from sqlalchemy import Column, Integer, String, Boolean, TIMESTAMP, ForeignKey, Index, UniqueConstraint from sqlalchemy import Column, Integer, String, Boolean, TIMESTAMP, ForeignKey, Index, UniqueConstraint
from sqlalchemy.sql import func from sqlalchemy.sql import func
from sqlalchemy.dialects.sqlite import JSON from sqlalchemy.dialects.sqlite import JSON
from database import Base from app.core.database import Base
class OAuthClient(Base): class OAuthClient(Base):

View file

@ -6,8 +6,8 @@ from sqlalchemy.exc import SQLAlchemyError
import logging import logging
# Import database first to resolve circular import # Import database first to resolve circular import
import database from app.core import database
from models.oauth_models import OAuthClient, OAuthToken, OAuthUser from app.modules.oauth2.models import OAuthClient, OAuthToken, OAuthUser
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -8,8 +8,8 @@ from jose import jwt, JWTError
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import settings from app.core.config import settings
from middleware.auth_middleware import verify_password from app.core.middleware.auth_middleware import verify_password
from .repositories import OAuthClientRepository, OAuthTokenRepository, OAuthUserRepository from .repositories import OAuthClientRepository, OAuthTokenRepository, OAuthUserRepository
from .schemas import OAuthTokenCreate, OAuthClientResponse from .schemas import OAuthTokenCreate, OAuthClientResponse
from .auth_code_store import authorization_code_store from .auth_code_store import authorization_code_store

0
app/static/__init__.py Normal file
View file

View file

View file

@ -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()

4
cookies.txt Normal file
View file

@ -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.

173
examples/README.md Normal file
View file

@ -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)

View file

@ -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": {}
}
}
]
}
]
}

View file

@ -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
}

121
examples/setup-test-client.py Executable file
View file

@ -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())

51
examples/setup.sh Executable file
View file

@ -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"

View file

@ -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()

View file

@ -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()

View file

@ -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)