chore: auto-commit 2026-03-16 09:00
This commit is contained in:
parent
9531bc9be8
commit
894020494a
57 changed files with 4288 additions and 624 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -45,6 +45,7 @@ mockapi.db
|
||||||
server.log
|
server.log
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
|
.opencode/
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
|
|
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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
209
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Configurable Mock API with Admin Interface
|
# Configurable Mock API with Admin Interface and OAuth2 Provider
|
||||||
|
|
||||||
A lightweight, configurable mock API application in Python that allows dynamic endpoint management via an admin interface. The API serves customizable responses stored in a SQLite database with template variable support.
|
A lightweight, configurable mock API application in Python that allows dynamic endpoint management via an admin interface. The API serves customizable responses stored in a SQLite database with template variable support. Includes a full OAuth2 provider for securing endpoints with token-based authentication.
|
||||||
|
|
||||||
## Features
|
## 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.
|
||||||
|
- **Endpoint‑Level OAuth Protection**: Individual endpoints can require OAuth2 tokens with configurable scopes.
|
||||||
|
- **Admin OAuth2 Management**: Web UI for managing OAuth clients, tokens, and users.
|
||||||
|
- **OpenID Connect Discovery**: Standards‑compliant discovery endpoint.
|
||||||
- **Production Ready**: Uses Waitress WSGI server, SQLAlchemy async, and FastAPI with proper error handling and security measures.
|
- **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**: Session‑based admin authentication with bcrypt password hashing
|
||||||
|
- **OAuth2**: JWT‑based tokens with configurable scopes, client validation, and token revocation
|
||||||
- **Frontend**: Bootstrap 5 (CDN) for admin UI
|
- **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 server‑side components.
|
||||||
|
- **Client Credentials**: For machine‑to‑machine communication.
|
||||||
|
- **Refresh Token**: To obtain new access tokens without user interaction.
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/oauth/authorize` | GET, POST | Authorization endpoint (interactive user consent) |
|
||||||
|
| `/oauth/token` | POST | Token issuance and refresh |
|
||||||
|
| `/oauth/userinfo` | GET | OpenID Connect user claims |
|
||||||
|
| `/oauth/introspect` | POST | Token introspection (RFC 7662) |
|
||||||
|
| `/oauth/revoke` | POST | Token revocation (RFC 7009) |
|
||||||
|
| `/.well‑known/openid‑configuration` | GET | OpenID Connect discovery document |
|
||||||
|
|
||||||
|
### Scope Management
|
||||||
|
|
||||||
|
Default scopes include:
|
||||||
|
- `openid` – OpenID Connect support
|
||||||
|
- `profile` – Basic profile information
|
||||||
|
- `email` – Email address claim
|
||||||
|
- `api:read` – Read access to protected endpoints
|
||||||
|
- `api:write` – Write access to protected endpoints
|
||||||
|
|
||||||
|
### Admin OAuth2 Management
|
||||||
|
|
||||||
|
Access OAuth2 management via the admin interface:
|
||||||
|
|
||||||
|
- **Clients**: `http://localhost:8000/admin/oauth/clients` – Register and manage OAuth clients
|
||||||
|
- **Tokens**: `http://localhost:8000/admin/oauth/tokens` – View and revoke issued tokens
|
||||||
|
- **Users**: `http://localhost:8000/admin/oauth/users` – Manage OAuth user records
|
||||||
|
|
||||||
## Production Deployment Considerations
|
## 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` | `your‑secret‑key‑here‑change‑me` | Session signing secret |
|
| `SECRET_KEY` | `your‑secret‑key‑here‑change‑me` | 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 multi‑user support)
|
- Single admin user (no multi‑user 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.
|
||||||
|
|
|
||||||
257
README.md.backup
257
README.md.backup
|
|
@ -1,257 +0,0 @@
|
||||||
# Configurable Mock API with Admin Interface
|
|
||||||
|
|
||||||
A lightweight, configurable mock API application in Python that allows dynamic endpoint management via an admin interface. The API serves customizable responses stored in a SQLite database with template variable support.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Dynamic Endpoint Configuration**: Create, read, update, and delete API endpoints through a web-based admin interface.
|
|
||||||
- **Template Variable Support**: Response bodies can include Jinja2 template variables (e.g., `{{ user_id }}`, `{{ timestamp }}`) populated from path parameters, query strings, headers, request body, system variables, and endpoint defaults.
|
|
||||||
- **Dynamic Route Registration**: Endpoints are registered/unregistered at runtime without restarting the server.
|
|
||||||
- **Admin Interface**: Secure web UI with session-based authentication for managing endpoints.
|
|
||||||
- **Production Ready**: Uses Waitress WSGI server, SQLAlchemy async, and FastAPI with proper error handling and security measures.
|
|
||||||
|
|
||||||
## Technology Stack
|
|
||||||
|
|
||||||
- **Framework**: FastAPI (with automatic OpenAPI documentation)
|
|
||||||
- **Server**: Waitress (production WSGI server)
|
|
||||||
- **Database**: SQLite with SQLAlchemy 2.0 async ORM
|
|
||||||
- **Templating**: Jinja2 with sandboxed environment
|
|
||||||
- **Authentication**: Session-based with bcrypt password hashing
|
|
||||||
- **Frontend**: Bootstrap 5 (CDN) for admin UI
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
mockapi/
|
|
||||||
├── app.py # FastAPI application factory & lifespan
|
|
||||||
├── config.py # Configuration (Pydantic Settings)
|
|
||||||
├── database.py # SQLAlchemy async database setup
|
|
||||||
├── dependencies.py # FastAPI dependencies
|
|
||||||
├── example_usage.py # Integration test & demonstration script
|
|
||||||
├── middleware/
|
|
||||||
│ └── auth_middleware.py # Admin authentication middleware
|
|
||||||
├── models/
|
|
||||||
│ └── endpoint_model.py # Endpoint SQLAlchemy model
|
|
||||||
├── observers/
|
|
||||||
│ └── __init__.py # Observer pattern placeholder
|
|
||||||
├── repositories/
|
|
||||||
│ └── endpoint_repository.py # Repository pattern for endpoints
|
|
||||||
├── run.py # Application entry point (production)
|
|
||||||
├── services/
|
|
||||||
│ ├── route_service.py # Dynamic route registration/management
|
|
||||||
│ └── template_service.py # Jinja2 template rendering
|
|
||||||
├── controllers/
|
|
||||||
│ └── admin_controller.py # Admin UI routes
|
|
||||||
├── schemas/
|
|
||||||
│ └── endpoint_schema.py # Pydantic schemas for validation
|
|
||||||
├── templates/ # Jinja2 HTML templates
|
|
||||||
│ ├── base.html # Base layout
|
|
||||||
│ └── admin/
|
|
||||||
│ ├── login.html # Login page
|
|
||||||
│ ├── dashboard.html # Admin dashboard
|
|
||||||
│ ├── endpoints.html # Endpoint list
|
|
||||||
│ └── endpoint_form.html # Create/edit endpoint
|
|
||||||
├── static/
|
|
||||||
│ └── css/ # Static CSS (optional)
|
|
||||||
├── tests/ # Test suite
|
|
||||||
│ ├── test_admin.py # Admin authentication tests
|
|
||||||
│ ├── test_endpoint_repository.py
|
|
||||||
│ └── test_route_manager_fix.py
|
|
||||||
├── utils/ # Utility modules
|
|
||||||
│ └── __init__.py
|
|
||||||
├── requirements.txt # Python dependencies
|
|
||||||
├── .env.example # Example environment variables
|
|
||||||
├── .env # Local environment variables (create from .env.example)
|
|
||||||
├── run_example.sh # Script to run the integration test
|
|
||||||
└── README.md # This file
|
|
||||||
```
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
1. **Clone or extract the project**:
|
|
||||||
```bash
|
|
||||||
cd mockapi
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create a virtual environment** (optional but recommended):
|
|
||||||
```bash
|
|
||||||
python3 -m venv venv
|
|
||||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Install dependencies**:
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Configure environment variables**:
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env with your settings (admin password, secret key, etc.)
|
|
||||||
```
|
|
||||||
|
|
||||||
Example `.env`:
|
|
||||||
```ini
|
|
||||||
DATABASE_URL=sqlite+aiosqlite:///./mockapi.db
|
|
||||||
ADMIN_USERNAME=admin
|
|
||||||
ADMIN_PASSWORD=admin123 # Change this in production!
|
|
||||||
SECRET_KEY=your-secret-key-here # Change this!
|
|
||||||
DEBUG=True # Set to False in production
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Initialize the database** (tables are created automatically on first run).
|
|
||||||
|
|
||||||
## Running the Application
|
|
||||||
|
|
||||||
### Development (with auto‑reload)
|
|
||||||
```bash
|
|
||||||
uvicorn app:app --reload --host 0.0.0.0 --port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production (with Waitress)
|
|
||||||
```bash
|
|
||||||
waitress-serve --host=0.0.0.0 --port=8000 --threads=4 app:app
|
|
||||||
```
|
|
||||||
|
|
||||||
The server will start on `http://localhost:8000`.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### 1. Access the Admin Interface
|
|
||||||
- Open `http://localhost:8000/admin/login`
|
|
||||||
- Log in with the credentials set in `.env` (default: `admin` / `admin123`)
|
|
||||||
|
|
||||||
### 2. Create a Mock Endpoint
|
|
||||||
1. Navigate to **Endpoints** → **Create New**.
|
|
||||||
2. Fill in the form:
|
|
||||||
- **Route**: `/api/greeting/{name}` (supports path parameters)
|
|
||||||
- **Method**: GET
|
|
||||||
- **Response Body**: `{ "message": "Hello, {{ name }}!" }`
|
|
||||||
- **Response Code**: 200
|
|
||||||
- **Content-Type**: `application/json`
|
|
||||||
- **Variables**: `{ "server": "mock-api" }` (optional defaults)
|
|
||||||
3. Click **Create**.
|
|
||||||
|
|
||||||
### 3. Call the Mock Endpoint
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8000/api/greeting/World
|
|
||||||
```
|
|
||||||
Response:
|
|
||||||
```json
|
|
||||||
{ "message": "Hello, World!" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Template Variables
|
|
||||||
The following variable sources are available in response templates:
|
|
||||||
|
|
||||||
| Source | Example variable | Usage in template |
|
|
||||||
|--------|------------------|-------------------|
|
|
||||||
| Path parameters | `{{ name }}` | `/users/{id}` → `{{ id }}` |
|
|
||||||
| Query parameters | `{{ query.page }}` | `?page=1` → `{{ page }}` |
|
|
||||||
| Request headers | `{{ header.authorization }}` | `Authorization: Bearer token` |
|
|
||||||
| Request body | `{{ body.user.email }}` | JSON request body |
|
|
||||||
| System variables | `{{ timestamp }}`, `{{ request_id }}` | Automatically injected |
|
|
||||||
| Endpoint defaults | `{{ server }}` | Defined in endpoint variables |
|
|
||||||
|
|
||||||
### 5. Admin Functions
|
|
||||||
- **List endpoints** with pagination and filtering
|
|
||||||
- **Edit** existing endpoints (changes take effect immediately)
|
|
||||||
- **Activate/deactivate** endpoints without deletion
|
|
||||||
- **Delete** endpoints (removes route)
|
|
||||||
- **Dashboard** with statistics (total endpoints, active routes, etc.)
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
- **Admin authentication**: Uses bcrypt password hashing. Store a strong password hash in production.
|
|
||||||
- **Session management**: Signed cookies with configurable secret key.
|
|
||||||
- **Template sandboxing**: Jinja2 environment restricted with `SandboxedEnvironment` and `StrictUndefined`.
|
|
||||||
- **Request size limits**: Maximum body size of 1MB to prevent DoS.
|
|
||||||
- **Route validation**: Prevents path traversal (`..`) and other unsafe patterns.
|
|
||||||
- **SQL injection protection**: All queries use SQLAlchemy ORM.
|
|
||||||
|
|
||||||
## Configuration Options
|
|
||||||
|
|
||||||
See `config.py` for all available settings. Key environment variables:
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `DATABASE_URL` | `sqlite+aiosqlite:///./mockapi.db` | SQLAlchemy database URL |
|
|
||||||
| `ADMIN_USERNAME` | `admin` | Admin login username |
|
|
||||||
| `ADMIN_PASSWORD` | `admin123` | Admin login password (plaintext) |
|
|
||||||
| `SECRET_KEY` | `your‑secret‑key‑here‑change‑me` | Session signing secret |
|
|
||||||
| `DEBUG` | `False` | Enable debug mode (more logging, relaxed validation) |
|
|
||||||
|
|
||||||
**Warning**: In production (`DEBUG=False`), the default `ADMIN_PASSWORD` and `SECRET_KEY` will cause validation errors. You must set unique values via environment variables.
|
|
||||||
|
|
||||||
## API Documentation
|
|
||||||
|
|
||||||
FastAPI automatically provides OpenAPI documentation at:
|
|
||||||
- Swagger UI: `http://localhost:8000/docs`
|
|
||||||
- ReDoc: `http://localhost:8000/redoc`
|
|
||||||
|
|
||||||
The dynamic mock endpoints are not listed in the OpenAPI schema (they are registered at runtime).
|
|
||||||
|
|
||||||
## Development & Testing
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
|
|
||||||
Run tests with pytest:
|
|
||||||
```bash
|
|
||||||
pytest tests/
|
|
||||||
```
|
|
||||||
|
|
||||||
The test suite includes:
|
|
||||||
- Unit tests for repository and service layers
|
|
||||||
- Integration tests for admin authentication
|
|
||||||
- Template rendering tests
|
|
||||||
|
|
||||||
### Example Integration Test
|
|
||||||
|
|
||||||
A ready‑to‑run integration test demonstrates the core functionality:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Make the script executable (Linux/macOS)
|
|
||||||
chmod +x run_example.sh
|
|
||||||
|
|
||||||
# Run the example
|
|
||||||
./run_example.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Or directly with Python:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python example_usage.py
|
|
||||||
```
|
|
||||||
|
|
||||||
The example script will:
|
|
||||||
1. Start the FastAPI app (via TestClient)
|
|
||||||
2. Log in as admin
|
|
||||||
3. Create a mock endpoint with template variables
|
|
||||||
4. Call the endpoint and verify the response
|
|
||||||
5. Report success or failure
|
|
||||||
|
|
||||||
This is a great way to verify that the API is working correctly after installation.
|
|
||||||
|
|
||||||
## Limitations & Future Enhancements
|
|
||||||
|
|
||||||
- **Current limitations**:
|
|
||||||
- SQLite only (but can be extended to PostgreSQL via `DATABASE_URL`)
|
|
||||||
- Single admin user (no multi‑user support)
|
|
||||||
- No request logging/history
|
|
||||||
|
|
||||||
- **Possible extensions**:
|
|
||||||
- Import/export endpoints as JSON/YAML
|
|
||||||
- Request logging and analytics
|
|
||||||
- WebSocket notifications for admin actions
|
|
||||||
- Multiple admin users with roles
|
|
||||||
- Rate limiting per endpoint
|
|
||||||
- CORS configuration
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This project is provided as-is for demonstration purposes. Use at your own risk.
|
|
||||||
|
|
||||||
## Acknowledgments
|
|
||||||
|
|
||||||
- Built with [FastAPI](https://fastapi.tiangolo.com/), [SQLAlchemy](https://www.sqlalchemy.org/), and [Jinja2](https://jinja.palletsprojects.com/).
|
|
||||||
- Admin UI uses [Bootstrap 5](https://getbootstrap.com/) via CDN.
|
|
||||||
573
README.md.backup2
Normal file
573
README.md.backup2
Normal 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.
|
||||||
|
- **Endpoint‑Level OAuth Protection**: Individual endpoints can require OAuth2 tokens with configurable scopes.
|
||||||
|
- **Admin OAuth2 Management**: Web UI for managing OAuth clients, tokens, and users.
|
||||||
|
- **OpenID Connect Discovery**: Standards‑compliant discovery endpoint.
|
||||||
|
- **Production Ready**: Uses Waitress WSGI server, SQLAlchemy async, and FastAPI with proper error handling and security measures.
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
- **Framework**: FastAPI (with automatic OpenAPI documentation)
|
||||||
|
- **Server**: Waitress (production WSGI server)
|
||||||
|
- **Database**: SQLite with SQLAlchemy 2.0 async ORM
|
||||||
|
- **Templating**: Jinja2 with sandboxed environment
|
||||||
|
- **Authentication**: Session‑based admin authentication with bcrypt password hashing
|
||||||
|
- **OAuth2**: JWT‑based tokens with configurable scopes, client validation, and token revocation
|
||||||
|
- **Frontend**: Bootstrap 5 (CDN) for admin UI
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
mockapi/
|
||||||
|
├── app.py # FastAPI application factory & lifespan
|
||||||
|
├── config.py # Configuration (Pydantic Settings)
|
||||||
|
├── database.py # SQLAlchemy async database setup
|
||||||
|
├── dependencies.py # FastAPI dependencies
|
||||||
|
├── example_usage.py # Integration test & demonstration script
|
||||||
|
├── middleware/
|
||||||
|
│ └── auth_middleware.py # Admin authentication middleware
|
||||||
|
├── models/
|
||||||
|
│ ├── endpoint_model.py # Endpoint SQLAlchemy model
|
||||||
|
│ └── oauth_models.py # OAuth2 client, token, and user models
|
||||||
|
├── observers/
|
||||||
|
│ └── __init__.py # Observer pattern placeholder
|
||||||
|
├── repositories/
|
||||||
|
│ ├── endpoint_repository.py # Repository pattern for endpoints
|
||||||
|
│ └── oauth2/ # OAuth2 repositories
|
||||||
|
├── run.py # Development runner script (with auto-reload)
|
||||||
|
├── services/
|
||||||
|
│ ├── route_service.py # Dynamic route registration/management
|
||||||
|
│ └── template_service.py # Jinja2 template rendering
|
||||||
|
├── controllers/
|
||||||
|
│ ├── admin_controller.py # Admin UI routes
|
||||||
|
│ └── oauth2/ # OAuth2 controllers and services
|
||||||
|
├── schemas/
|
||||||
|
│ ├── endpoint_schema.py # Pydantic schemas for validation
|
||||||
|
│ └── oauth2/ # OAuth2 schemas
|
||||||
|
├── templates/ # Jinja2 HTML templates
|
||||||
|
│ ├── base.html # Base layout
|
||||||
|
│ └── admin/
|
||||||
|
│ ├── login.html # Login page
|
||||||
|
│ ├── dashboard.html # Admin dashboard
|
||||||
|
│ ├── endpoints.html # Endpoint list
|
||||||
|
│ ├── endpoint_form.html # Create/edit endpoint
|
||||||
|
│ └── oauth/ # OAuth2 management pages
|
||||||
|
├── static/
|
||||||
|
│ └── css/ # Static CSS (optional)
|
||||||
|
├── tests/ # Test suite
|
||||||
|
│ ├── test_admin.py # Admin authentication tests
|
||||||
|
│ ├── test_endpoint_repository.py
|
||||||
|
│ ├── test_route_manager_fix.py
|
||||||
|
│ ├── test_oauth2_controller.py
|
||||||
|
│ └── integration/ # Integration tests
|
||||||
|
├── utils/ # Utility modules
|
||||||
|
│ └── __init__.py
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
├── .env.example # Example environment variables
|
||||||
|
├── .env # Local environment variables (create from .env.example)
|
||||||
|
├── run_example.sh # Script to run the integration test
|
||||||
|
├── LICENSE # MIT License
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. **Navigate to project directory**:
|
||||||
|
```bash
|
||||||
|
cd ~/GitLab/customer-engineering/mockapi
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create a virtual environment** (recommended):
|
||||||
|
```bash
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Install dependencies**:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Configure environment variables**:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your settings
|
||||||
|
```
|
||||||
|
|
||||||
|
Example `.env`:
|
||||||
|
```ini
|
||||||
|
DATABASE_URL=sqlite+aiosqlite:///./mockapi.db
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=admin123 # Change this in production!
|
||||||
|
SECRET_KEY=your-secret-key-here # Change this!
|
||||||
|
DEBUG=True # Set to False in production
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Initialize the database** (tables are created automatically on first run).
|
||||||
|
|
||||||
|
## Running the Application
|
||||||
|
|
||||||
|
### Development (with auto‑reload)
|
||||||
|
|
||||||
|
Make sure your virtual environment is activated:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate # Linux/macOS
|
||||||
|
# venv\Scripts\activate # Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run with auto-reload for development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using run.py (convenience script)
|
||||||
|
python run.py
|
||||||
|
|
||||||
|
# Or directly with uvicorn
|
||||||
|
uvicorn app:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production (with Waitress)
|
||||||
|
|
||||||
|
For production deployment, use Waitress WSGI server with the provided WSGI adapter (a2wsgi):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
waitress-serve --host=0.0.0.0 --port=8000 --threads=4 wsgi:wsgi_app
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will start on `http://localhost:8000` (or your configured host/port).
|
||||||
|
|
||||||
|
**Note:** Waitress is a WSGI server, but FastAPI is an ASGI framework. The `wsgi.py` file uses `a2wsgi` to wrap the ASGI application into a WSGI-compatible interface. Routes are automatically refreshed from the database on server startup.
|
||||||
|
|
||||||
|
## Quick Start with cURL Examples
|
||||||
|
|
||||||
|
### 1. Create a Mock Endpoint via Admin API
|
||||||
|
|
||||||
|
First, log in to the admin interface (default credentials: `admin` / `admin123`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Simulate login and create session (use browser for UI, but you can also use curl)
|
||||||
|
curl -c cookies.txt -X POST http://localhost:8000/admin/login \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-d "username=admin&password=admin123"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then create a mock endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -b cookies.txt -X POST http://localhost:8000/admin/endpoints \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-d "route=/api/greeting/{name}&method=GET&response_body={\"message\": \"Hello, {{ name }}!\"}&response_code=200&content_type=application/json&is_active=true"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Call the Mock Endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/greeting/World
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{ "message": "Hello, World!" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Use Template Variables
|
||||||
|
|
||||||
|
Create an endpoint that uses multiple variable sources:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -b cookies.txt -X POST http://localhost:8000/admin/endpoints \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-d "route=/api/user/{id}&method=GET&response_body={\"id\": {{ id }}, \"name\": \"{{ query.name }}\", \"timestamp\": \"{{ timestamp }}\"}&response_code=200&content_type=application/json&is_active=true"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then call it with query parameters:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:8000/api/user/123?name=John"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{ "id": 123, "name": "John", "timestamp": "2026-03-16T06:14:12.345678" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. OAuth2 Client Credentials Flow
|
||||||
|
|
||||||
|
First, create an OAuth client via the admin UI or using the admin API. Then obtain a token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get an access token using client credentials
|
||||||
|
curl -X POST http://localhost:8000/oauth/token \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-d "grant_type=client_credentials&client_id=your_client_id&client_secret=your_client_secret&scope=api:read"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 1800,
|
||||||
|
"scope": "api:read"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Protect an Endpoint with OAuth2
|
||||||
|
|
||||||
|
Create an endpoint that requires OAuth2:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -b cookies.txt -X POST http://localhost:8000/admin/endpoints \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-d "route=/api/protected&method=GET&response_body={\"status\": \"authorized\"}&response_code=200&content_type=application/json&is_active=true&requires_oauth=true&oauth_scopes=[\"api:read\"]"
|
||||||
|
```
|
||||||
|
|
||||||
|
Call it with a valid token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..." \
|
||||||
|
http://localhost:8000/api/protected
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{ "status": "authorized" }
|
||||||
|
```
|
||||||
|
> **Note**: The `requires_oauth` and `oauth_scopes` fields are not yet exposed in the admin UI. To set OAuth protection, update the endpoint directly in the database or use the repository API.
|
||||||
|
|
||||||
|
### 6. OAuth2 Authorization Code Flow
|
||||||
|
|
||||||
|
For interactive applications:
|
||||||
|
|
||||||
|
1. **Authorization request** (user redirects to):
|
||||||
|
```
|
||||||
|
http://localhost:8000/oauth/authorize?response_type=code&client_id=your_client_id&redirect_uri=http://localhost:8080/callback&scope=api:read&state=xyz123
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Exchange code for token**:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/oauth/token \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-d "grant_type=authorization_code&code=AUTH_CODE_HERE&redirect_uri=http://localhost:8080/callback&client_id=your_client_id&client_secret=your_client_secret"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. OAuth2 Token Introspection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -u client_id:client_secret -X POST http://localhost:8000/oauth/introspect \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-d "token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. OpenID Connect Discovery
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/.well-known/openid-configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## OAuth2 Authentication
|
||||||
|
|
||||||
|
The application includes a full OAuth2 provider implementing RFC 6749 and OpenID Connect Discovery.
|
||||||
|
|
||||||
|
### Supported Grant Types
|
||||||
|
|
||||||
|
- **Authorization Code**: For web applications with server‑side components.
|
||||||
|
- **Client Credentials**: For machine‑to‑machine communication.
|
||||||
|
- **Refresh Token**: To obtain new access tokens without user interaction.
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/oauth/authorize` | GET, POST | Authorization endpoint (interactive user consent) |
|
||||||
|
| `/oauth/token` | POST | Token issuance and refresh |
|
||||||
|
| `/oauth/userinfo` | GET | OpenID Connect user claims |
|
||||||
|
| `/oauth/introspect` | POST | Token introspection (RFC 7662) |
|
||||||
|
| `/oauth/revoke` | POST | Token revocation (RFC 7009) |
|
||||||
|
| `/.well‑known/openid‑configuration` | GET | OpenID Connect discovery document |
|
||||||
|
|
||||||
|
### Scope Management
|
||||||
|
|
||||||
|
Default scopes include:
|
||||||
|
- `openid` – OpenID Connect support
|
||||||
|
- `profile` – Basic profile information
|
||||||
|
- `email` – Email address claim
|
||||||
|
- `api:read` – Read access to protected endpoints
|
||||||
|
- `api:write` – Write access to protected endpoints
|
||||||
|
|
||||||
|
### Admin OAuth2 Management
|
||||||
|
|
||||||
|
Access OAuth2 management via the admin interface:
|
||||||
|
|
||||||
|
- **Clients**: `http://localhost:8000/admin/oauth/clients` – Register and manage OAuth clients
|
||||||
|
- **Tokens**: `http://localhost:8000/admin/oauth/tokens` – View and revoke issued tokens
|
||||||
|
- **Users**: `http://localhost:8000/admin/oauth/users` – Manage OAuth user records
|
||||||
|
|
||||||
|
## Production Deployment Considerations
|
||||||
|
|
||||||
|
### 1. **Environment Configuration**
|
||||||
|
- Set `DEBUG=False` in production
|
||||||
|
- Use strong, unique values for `ADMIN_PASSWORD` and `SECRET_KEY`
|
||||||
|
- Consider using a more robust database (PostgreSQL) by changing `DATABASE_URL`
|
||||||
|
- Store sensitive values in environment variables or a secrets manager
|
||||||
|
|
||||||
|
### 2. **Process Management**
|
||||||
|
Use a process manager like systemd (Linux) or Supervisor to keep the application running:
|
||||||
|
|
||||||
|
**Example systemd service (`/etc/systemd/system/mockapi.service`)**:
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Mock API Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=www-data
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=/path/to/mockapi
|
||||||
|
Environment="PATH=/path/to/mockapi/venv/bin"
|
||||||
|
ExecStart=/path/to/mockapi/venv/bin/waitress-serve --host=0.0.0.0 --port=8000 wsgi:wsgi_app
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **Reverse Proxy (Recommended)**
|
||||||
|
Use Nginx or Apache as a reverse proxy for SSL termination, load balancing, and static file serving:
|
||||||
|
|
||||||
|
**Example Nginx configuration**:
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name api.yourdomain.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. **Database Backups**
|
||||||
|
For SQLite, regularly backup the `mockapi.db` file. For production, consider migrating to PostgreSQL.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### 1. Access the Admin Interface
|
||||||
|
- Open `http://localhost:8000/admin/login`
|
||||||
|
- Log in with the credentials set in `.env` (default: `admin` / `admin123`)
|
||||||
|
|
||||||
|
### 2. Create a Mock Endpoint
|
||||||
|
1. Navigate to **Endpoints** → **Create New**.
|
||||||
|
2. Fill in the form:
|
||||||
|
- **Route**: `/api/greeting/{name}` (supports path parameters)
|
||||||
|
- **Method**: GET
|
||||||
|
- **Response Body**: `{ "message": "Hello, {{ name }}!" }`
|
||||||
|
- **Response Code**: 200
|
||||||
|
- **Content-Type**: `application/json`
|
||||||
|
- **Variables**: `{ "server": "mock-api" }` (optional defaults)
|
||||||
|
3. Click **Create**.
|
||||||
|
|
||||||
|
### 3. Call the Mock Endpoint
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/greeting/World
|
||||||
|
```
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{ "message": "Hello, World!" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Template Variables
|
||||||
|
The following variable sources are available in response templates:
|
||||||
|
|
||||||
|
| Source | Example variable | Usage in template |
|
||||||
|
|--------|------------------|-------------------|
|
||||||
|
| Path parameters | `{{ name }}` | `/users/{id}` → `{{ id }}` |
|
||||||
|
| Query parameters | `{{ query.page }}` | `?page=1` → `{{ page }}` |
|
||||||
|
| Request headers | `{{ header.authorization }}` | `Authorization: Bearer token` |
|
||||||
|
| Request body | `{{ body.user.email }}` | JSON request body |
|
||||||
|
| System variables | `{{ timestamp }}`, `{{ request_id }}` | Automatically injected |
|
||||||
|
| Endpoint defaults | `{{ server }}` | Defined in endpoint variables |
|
||||||
|
|
||||||
|
### 5. Admin Functions
|
||||||
|
- **List endpoints** with pagination and filtering
|
||||||
|
- **Edit** existing endpoints (changes take effect immediately)
|
||||||
|
- **Activate/deactivate** endpoints without deletion
|
||||||
|
- **Delete** endpoints (removes route)
|
||||||
|
- **Dashboard** with statistics (total endpoints, active routes, etc.)
|
||||||
|
- **OAuth2 management** – clients, tokens, users
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- **Admin authentication**: Uses bcrypt password hashing. Store a strong password hash in production.
|
||||||
|
- **Session management**: Signed cookies with configurable secret key.
|
||||||
|
- **Template sandboxing**: Jinja2 environment restricted with `SandboxedEnvironment` and `StrictUndefined`.
|
||||||
|
- **Request size limits**: Maximum body size of 1MB to prevent DoS.
|
||||||
|
- **Route validation**: Prevents path traversal (`..`) and other unsafe patterns.
|
||||||
|
- **SQL injection protection**: All queries use SQLAlchemy ORM.
|
||||||
|
- **OAuth2 security**: Client secret hashing, token revocation, scope validation, secure token storage.
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
See `config.py` for all available settings. Key environment variables:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `DATABASE_URL` | `sqlite+aiosqlite:///./mockapi.db` | SQLAlchemy database URL |
|
||||||
|
| `ADMIN_USERNAME` | `admin` | Admin login username |
|
||||||
|
| `ADMIN_PASSWORD` | `admin123` | Admin login password (plaintext) |
|
||||||
|
| `SECRET_KEY` | `your‑secret‑key‑here‑change‑me` | Session signing secret |
|
||||||
|
| `DEBUG` | `False` | Enable debug mode (more logging, relaxed validation) |
|
||||||
|
| `OAUTH2_ISSUER` | `http://localhost:8000` | OAuth2 issuer URL for discovery |
|
||||||
|
| `OAUTH2_ACCESS_TOKEN_EXPIRE_MINUTES` | `30` | Access token lifetime |
|
||||||
|
| `OAUTH2_REFRESH_TOKEN_EXPIRE_DAYS` | `7` | Refresh token lifetime |
|
||||||
|
| `OAUTH2_SUPPORTED_SCOPES` | `["openid","profile","email","api:read","api:write"]` | Available OAuth2 scopes |
|
||||||
|
|
||||||
|
**Warning**: In production (`DEBUG=False`), the default `ADMIN_PASSWORD` and `SECRET_KEY` will cause validation errors. You must set unique values via environment variables.
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
FastAPI automatically provides OpenAPI documentation at:
|
||||||
|
- Swagger UI: `http://localhost:8000/docs`
|
||||||
|
- ReDoc: `http://localhost:8000/redoc`
|
||||||
|
|
||||||
|
The root URL (/) automatically redirects to the Swagger documentation at /docs.
|
||||||
|
|
||||||
|
The dynamic mock endpoints are not listed in the OpenAPI schema (they are registered at runtime).
|
||||||
|
|
||||||
|
## Development & Testing
|
||||||
|
|
||||||
|
## API Testing with Bruno
|
||||||
|
|
||||||
|
A ready-to-use [Bruno](https://www.usebruno.com/) API collection is available in the `examples/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set up test OAuth client and view instructions
|
||||||
|
./examples/setup.sh
|
||||||
|
|
||||||
|
# Or import directly:
|
||||||
|
# examples/mockapi-collection.bru
|
||||||
|
```
|
||||||
|
|
||||||
|
The collection includes:
|
||||||
|
- Global variables for base URL and credentials
|
||||||
|
- Pre-configured requests for all endpoints
|
||||||
|
- OAuth2 flow examples (client credentials, authorization code)
|
||||||
|
- Admin authentication
|
||||||
|
- Mock endpoint creation and testing
|
||||||
|
- Protected endpoint examples
|
||||||
|
|
||||||
|
See [examples/README.md](examples/README.md) for detailed usage instructions.
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
Run tests with pytest:
|
||||||
|
```bash
|
||||||
|
pytest tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
The test suite includes:
|
||||||
|
- Unit tests for repository and service layers
|
||||||
|
- Integration tests for admin authentication
|
||||||
|
- Template rendering tests
|
||||||
|
- OAuth2 unit and integration tests (21+ tests)
|
||||||
|
|
||||||
|
### Example Integration Test
|
||||||
|
|
||||||
|
A ready‑to‑run integration test demonstrates the core functionality:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make the script executable (Linux/macOS)
|
||||||
|
chmod +x run_example.sh
|
||||||
|
|
||||||
|
# Run the example
|
||||||
|
./run_example.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Or directly with Python:
|
||||||
|
```bash
|
||||||
|
python example_usage.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The example script will:
|
||||||
|
1. Start the FastAPI app (via TestClient)
|
||||||
|
2. Log in as admin
|
||||||
|
3. Create a mock endpoint with template variables
|
||||||
|
4. Call the endpoint and verify the response
|
||||||
|
5. Report success or failure
|
||||||
|
|
||||||
|
This is a great way to verify that the API is working correctly after installation.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **"no such table: endpoints" error**
|
||||||
|
- The database hasn't been initialized
|
||||||
|
- Restart the application - tables are created on first startup
|
||||||
|
- Or run `python -c "from database import init_db; import asyncio; asyncio.run(init_db())"`
|
||||||
|
|
||||||
|
2. **Login fails even with correct credentials**
|
||||||
|
- Check that `DEBUG=True` is set in `.env` (or provide unique credentials)
|
||||||
|
- The default credentials only work when `DEBUG=True`
|
||||||
|
- In production, you must set unique `ADMIN_PASSWORD` and `SECRET_KEY`
|
||||||
|
|
||||||
|
3. **Routes not being registered**
|
||||||
|
- Check that the endpoint is marked as active (`is_active=True`)
|
||||||
|
- Refresh the page - routes are registered immediately after creation
|
||||||
|
- Check application logs for errors
|
||||||
|
|
||||||
|
4. **Template variables not rendering**
|
||||||
|
- Ensure you're using double curly braces: `{{ variable }}`
|
||||||
|
- Check variable names match the context (use path_, query_, header_ prefixes as needed)
|
||||||
|
- View the rendered template in the admin edit form preview
|
||||||
|
|
||||||
|
5. **OAuth2 token validation fails**
|
||||||
|
- Verify the token hasn't expired
|
||||||
|
- Check that the client is active
|
||||||
|
- Confirm the token has required scopes for the endpoint
|
||||||
|
- Ensure the token hasn't been revoked
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
Enable debug logging by setting `DEBUG=True` in `.env`. Check the console output for detailed error messages.
|
||||||
|
|
||||||
|
## Limitations & Future Enhancements
|
||||||
|
|
||||||
|
- **Current limitations**:
|
||||||
|
- SQLite only (but can be extended to PostgreSQL via `DATABASE_URL`)
|
||||||
|
- Single admin user (no multi‑user support)
|
||||||
|
- No request logging/history
|
||||||
|
- OAuth2 user authentication uses placeholder user IDs (integration with external identity providers pending)
|
||||||
|
|
||||||
|
- **Possible extensions**:
|
||||||
|
- Import/export endpoints as JSON/YAML
|
||||||
|
- Request logging and analytics
|
||||||
|
- WebSocket notifications for admin actions
|
||||||
|
- Multiple admin users with roles
|
||||||
|
- Rate limiting per endpoint
|
||||||
|
- CORS configuration
|
||||||
|
- PKCE support for public OAuth2 clients
|
||||||
|
- Integration with external identity providers (SAML, LDAP)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
- Built with [FastAPI](https://fastapi.tiangolo.com/), [SQLAlchemy](https://www.sqlalchemy.org/), and [Jinja2](https://jinja.palletsprojects.com/).
|
||||||
|
- Admin UI uses [Bootstrap 5](https://getbootstrap.com/) via CDN.
|
||||||
|
- OAuth2 implementation follows RFC 6749, RFC 7662, and OpenID Connect standards.
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
@ -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__)
|
||||||
|
|
||||||
0
app/core/observers/__init__.py
Normal file
0
app/core/observers/__init__.py
Normal file
0
app/core/utils/__init__.py
Normal file
0
app/core/utils/__init__.py
Normal file
0
app/modules/__init__.py
Normal file
0
app/modules/__init__.py
Normal file
0
app/modules/admin/__init__.py
Normal file
0
app/modules/admin/__init__.py
Normal file
705
app/modules/admin/controller.py
Normal file
705
app/modules/admin/controller.py
Normal 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
|
||||||
|
|
||||||
|
# Pre‑computed hash of admin password (bcrypt)
|
||||||
|
admin_password_hash = get_password_hash(settings.admin_password)
|
||||||
|
|
||||||
|
# ---------- Authentication Routes ----------
|
||||||
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
|
async def login_page(request: Request, error: Optional[str] = None):
|
||||||
|
"""Display login form."""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"admin/login.html",
|
||||||
|
{"request": request, "error": error, "session": request.session}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/login", response_class=RedirectResponse)
|
||||||
|
async def login(
|
||||||
|
request: Request,
|
||||||
|
username: str = Form(...),
|
||||||
|
password: str = Form(...),
|
||||||
|
):
|
||||||
|
"""Process login credentials and set session."""
|
||||||
|
if username != settings.admin_username:
|
||||||
|
logger.warning(f"Failed login attempt: invalid username '{username}'")
|
||||||
|
return RedirectResponse(
|
||||||
|
url="/admin/login?error=Invalid+credentials",
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify password against pre‑computed bcrypt hash
|
||||||
|
if not verify_password(password, admin_password_hash):
|
||||||
|
logger.warning(f"Failed login attempt: invalid password for '{username}'")
|
||||||
|
return RedirectResponse(
|
||||||
|
url="/admin/login?error=Invalid+credentials",
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Authentication successful, set session
|
||||||
|
request.session["username"] = username
|
||||||
|
logger.info(f"User '{username}' logged in")
|
||||||
|
return RedirectResponse(url="/admin", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
|
@router.get("/logout")
|
||||||
|
async def logout(request: Request):
|
||||||
|
"""Clear session and redirect to login."""
|
||||||
|
request.session.clear()
|
||||||
|
return RedirectResponse(url="/admin/login", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
|
# ---------- Dashboard ----------
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def dashboard(
|
||||||
|
request: Request,
|
||||||
|
repository: EndpointRepository = Depends(get_repository),
|
||||||
|
route_manager: RouteManager = Depends(get_route_manager),
|
||||||
|
):
|
||||||
|
"""Admin dashboard with statistics."""
|
||||||
|
async with repository.session as session:
|
||||||
|
# Total endpoints
|
||||||
|
total_endpoints = await repository.get_all(limit=1000)
|
||||||
|
total_count = len(total_endpoints)
|
||||||
|
# Active endpoints
|
||||||
|
active_endpoints = await repository.get_active()
|
||||||
|
active_count = len(active_endpoints)
|
||||||
|
# Methods count (unique)
|
||||||
|
methods = set(e.method for e in total_endpoints)
|
||||||
|
methods_count = len(methods)
|
||||||
|
# Registered routes count
|
||||||
|
total_routes = len(route_manager.registered_routes)
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"total_endpoints": total_count,
|
||||||
|
"active_endpoints": active_count,
|
||||||
|
"methods_count": methods_count,
|
||||||
|
"total_routes": total_routes,
|
||||||
|
}
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"admin/dashboard.html",
|
||||||
|
{"request": request, "stats": stats, "session": request.session}
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------- Endpoints CRUD ----------
|
||||||
|
@router.get("/endpoints", response_class=HTMLResponse)
|
||||||
|
async def list_endpoints(
|
||||||
|
request: Request,
|
||||||
|
page: int = 1,
|
||||||
|
repository: EndpointRepository = Depends(get_repository),
|
||||||
|
):
|
||||||
|
"""List all endpoints with pagination."""
|
||||||
|
skip = (page - 1) * PAGE_SIZE
|
||||||
|
endpoints = await repository.get_all(skip=skip, limit=PAGE_SIZE)
|
||||||
|
total = len(await repository.get_all(limit=1000)) # naive count
|
||||||
|
total_pages = (total + PAGE_SIZE - 1) // PAGE_SIZE if total > 0 else 1
|
||||||
|
|
||||||
|
# Ensure page is within bounds
|
||||||
|
if page < 1 or (total_pages > 0 and page > total_pages):
|
||||||
|
return RedirectResponse(url="/admin/endpoints?page=1")
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"admin/endpoints.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"session": request.session,
|
||||||
|
"endpoints": endpoints,
|
||||||
|
"page": page,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"error": request.query_params.get("error"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/endpoints/new", response_class=HTMLResponse)
|
||||||
|
async def new_endpoint_form(request: Request):
|
||||||
|
"""Display form to create a new endpoint."""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"admin/endpoint_form.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"session": request.session,
|
||||||
|
"action": "Create",
|
||||||
|
"form_action": "/admin/endpoints",
|
||||||
|
"endpoint": None,
|
||||||
|
"errors": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/endpoints", response_class=RedirectResponse)
|
||||||
|
async def create_endpoint(
|
||||||
|
request: Request,
|
||||||
|
route: str = Form(...),
|
||||||
|
method: str = Form(...),
|
||||||
|
response_body: str = Form(...),
|
||||||
|
response_code: int = Form(200),
|
||||||
|
content_type: str = Form("application/json"),
|
||||||
|
is_active: bool = Form(True),
|
||||||
|
variables: str = Form("{}"),
|
||||||
|
headers: str = Form("{}"),
|
||||||
|
delay_ms: int = Form(0),
|
||||||
|
repository: EndpointRepository = Depends(get_repository),
|
||||||
|
route_manager: RouteManager = Depends(get_route_manager),
|
||||||
|
):
|
||||||
|
"""Create a new endpoint."""
|
||||||
|
# Parse JSON fields
|
||||||
|
try:
|
||||||
|
variables_dict = json.loads(variables) if variables else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return RedirectResponse(
|
||||||
|
url="/admin/endpoints/new?error=Invalid+JSON+in+variables",
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
headers_dict = json.loads(headers) if headers else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return RedirectResponse(
|
||||||
|
url="/admin/endpoints/new?error=Invalid+JSON+in+headers",
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate using Pydantic schema
|
||||||
|
try:
|
||||||
|
endpoint_data = EndpointCreate(
|
||||||
|
route=route,
|
||||||
|
method=method,
|
||||||
|
response_body=response_body,
|
||||||
|
response_code=response_code,
|
||||||
|
content_type=content_type,
|
||||||
|
is_active=is_active,
|
||||||
|
variables=variables_dict,
|
||||||
|
headers=headers_dict,
|
||||||
|
delay_ms=delay_ms,
|
||||||
|
).dict()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Validation error: {e}")
|
||||||
|
# Could pass errors to form, but for simplicity redirect with error
|
||||||
|
return RedirectResponse(
|
||||||
|
url="/admin/endpoints/new?error=" + str(e).replace(" ", "+"),
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create endpoint
|
||||||
|
endpoint = await repository.create(endpoint_data)
|
||||||
|
if not endpoint:
|
||||||
|
return RedirectResponse(
|
||||||
|
url="/admin/endpoints/new?error=Failed+to+create+endpoint",
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Created endpoint {endpoint.id}: {method} {route}")
|
||||||
|
# Refresh routes to include new endpoint
|
||||||
|
await route_manager.refresh_routes()
|
||||||
|
|
||||||
|
return RedirectResponse(url="/admin/endpoints", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
|
@router.get("/endpoints/{endpoint_id}", response_class=HTMLResponse)
|
||||||
|
async def edit_endpoint_form(
|
||||||
|
request: Request,
|
||||||
|
endpoint_id: int,
|
||||||
|
repository: EndpointRepository = Depends(get_repository),
|
||||||
|
):
|
||||||
|
"""Display form to edit an existing endpoint."""
|
||||||
|
endpoint = await repository.get_by_id(endpoint_id)
|
||||||
|
if not endpoint:
|
||||||
|
raise HTTPException(status_code=404, detail="Endpoint not found")
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"admin/endpoint_form.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"session": request.session,
|
||||||
|
"action": "Edit",
|
||||||
|
"form_action": f"/admin/endpoints/{endpoint_id}",
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"errors": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/endpoints/{endpoint_id}", response_class=RedirectResponse)
|
||||||
|
async def update_endpoint(
|
||||||
|
request: Request,
|
||||||
|
endpoint_id: int,
|
||||||
|
route: Optional[str] = Form(None),
|
||||||
|
method: Optional[str] = Form(None),
|
||||||
|
response_body: Optional[str] = Form(None),
|
||||||
|
response_code: Optional[int] = Form(None),
|
||||||
|
content_type: Optional[str] = Form(None),
|
||||||
|
is_active: Optional[bool] = Form(None),
|
||||||
|
variables: Optional[str] = Form(None),
|
||||||
|
headers: Optional[str] = Form(None),
|
||||||
|
delay_ms: Optional[int] = Form(None),
|
||||||
|
repository: EndpointRepository = Depends(get_repository),
|
||||||
|
route_manager: RouteManager = Depends(get_route_manager),
|
||||||
|
):
|
||||||
|
"""Update an existing endpoint."""
|
||||||
|
# Parse JSON fields if provided
|
||||||
|
variables_dict = None
|
||||||
|
if variables is not None:
|
||||||
|
try:
|
||||||
|
variables_dict = json.loads(variables) if variables else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/admin/endpoints/{endpoint_id}?error=Invalid+JSON+in+variables",
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
headers_dict = None
|
||||||
|
if headers is not None:
|
||||||
|
try:
|
||||||
|
headers_dict = json.loads(headers) if headers else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/admin/endpoints/{endpoint_id}?error=Invalid+JSON+in+headers",
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build update dict (only include fields that are not None)
|
||||||
|
update_data = {}
|
||||||
|
if route is not None:
|
||||||
|
update_data["route"] = route
|
||||||
|
if method is not None:
|
||||||
|
update_data["method"] = method
|
||||||
|
if response_body is not None:
|
||||||
|
update_data["response_body"] = response_body
|
||||||
|
if response_code is not None:
|
||||||
|
update_data["response_code"] = response_code
|
||||||
|
if content_type is not None:
|
||||||
|
update_data["content_type"] = content_type
|
||||||
|
if is_active is not None:
|
||||||
|
update_data["is_active"] = is_active
|
||||||
|
if variables_dict is not None:
|
||||||
|
update_data["variables"] = variables_dict
|
||||||
|
if headers_dict is not None:
|
||||||
|
update_data["headers"] = headers_dict
|
||||||
|
if delay_ms is not None:
|
||||||
|
update_data["delay_ms"] = delay_ms
|
||||||
|
|
||||||
|
# Validate using Pydantic schema (optional fields)
|
||||||
|
try:
|
||||||
|
validated = EndpointUpdate(**update_data).dict(exclude_unset=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Validation error: {e}")
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/admin/endpoints/{endpoint_id}?error=" + str(e).replace(" ", "+"),
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update endpoint
|
||||||
|
endpoint = await repository.update(endpoint_id, validated)
|
||||||
|
if not endpoint:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/admin/endpoints/{endpoint_id}?error=Failed+to+update+endpoint",
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Updated endpoint {endpoint_id}")
|
||||||
|
# Refresh routes to reflect changes
|
||||||
|
await route_manager.refresh_routes()
|
||||||
|
|
||||||
|
return RedirectResponse(url="/admin/endpoints", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
|
@router.post("/endpoints/{endpoint_id}", response_class=RedirectResponse, include_in_schema=False)
|
||||||
|
async def delete_endpoint(
|
||||||
|
request: Request,
|
||||||
|
endpoint_id: int,
|
||||||
|
repository: EndpointRepository = Depends(get_repository),
|
||||||
|
route_manager: RouteManager = Depends(get_route_manager),
|
||||||
|
):
|
||||||
|
"""Delete an endpoint (handled via POST with _method=DELETE)."""
|
||||||
|
# Check if method override is present (HTML forms can't send DELETE)
|
||||||
|
form = await request.form()
|
||||||
|
if form.get("_method") != "DELETE":
|
||||||
|
# Fallback to update
|
||||||
|
return await update_endpoint(request, endpoint_id, repository=repository, route_manager=route_manager)
|
||||||
|
|
||||||
|
success = await repository.delete(endpoint_id)
|
||||||
|
if not success:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/admin/endpoints?error=Failed+to+delete+endpoint",
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Deleted endpoint {endpoint_id}")
|
||||||
|
# Refresh routes to remove deleted endpoint
|
||||||
|
await route_manager.refresh_routes()
|
||||||
|
|
||||||
|
return RedirectResponse(url="/admin/endpoints", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
|
# ---------- OAuth2 Management Routes ----------
|
||||||
|
@router.get("/oauth/clients", response_class=HTMLResponse, tags=["admin-oauth"])
|
||||||
|
async def list_oauth_clients(
|
||||||
|
request: Request,
|
||||||
|
page: int = 1,
|
||||||
|
repository: OAuthClientRepository = Depends(get_oauth_client_repository),
|
||||||
|
):
|
||||||
|
"""List all OAuth clients with pagination."""
|
||||||
|
skip = (page - 1) * PAGE_SIZE
|
||||||
|
clients = await repository.get_all(skip=skip, limit=PAGE_SIZE)
|
||||||
|
total = len(await repository.get_all(limit=1000)) # naive count
|
||||||
|
total_pages = (total + PAGE_SIZE - 1) // PAGE_SIZE if total > 0 else 1
|
||||||
|
|
||||||
|
# Ensure page is within bounds
|
||||||
|
if page < 1 or (total_pages > 0 and page > total_pages):
|
||||||
|
return RedirectResponse(url="/admin/oauth/clients?page=1")
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"admin/oauth/clients.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"session": request.session,
|
||||||
|
"clients": clients,
|
||||||
|
"page": page,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"error": request.query_params.get("error"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/oauth/clients/new", response_class=HTMLResponse, tags=["admin-oauth"])
|
||||||
|
async def new_oauth_client_form(request: Request):
|
||||||
|
"""Display form to create a new OAuth client."""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"admin/oauth/client_form.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"session": request.session,
|
||||||
|
"action": "Create",
|
||||||
|
"form_action": "/admin/oauth/clients",
|
||||||
|
"client": None,
|
||||||
|
"errors": {},
|
||||||
|
"error": request.query_params.get("error"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/oauth/clients", response_class=RedirectResponse, tags=["admin-oauth"])
|
||||||
|
async def create_oauth_client(
|
||||||
|
request: Request,
|
||||||
|
client_name: str = Form(...),
|
||||||
|
redirect_uris: str = Form(...),
|
||||||
|
grant_types: str = Form(...),
|
||||||
|
scopes: str = Form(...),
|
||||||
|
is_active: bool = Form(True),
|
||||||
|
repository: OAuthClientRepository = Depends(get_oauth_client_repository),
|
||||||
|
):
|
||||||
|
"""Create a new OAuth client."""
|
||||||
|
try:
|
||||||
|
# Prepare client data with generated credentials
|
||||||
|
data = prepare_client_data(
|
||||||
|
client_name=client_name,
|
||||||
|
redirect_uris=redirect_uris,
|
||||||
|
grant_types=grant_types,
|
||||||
|
scopes=scopes,
|
||||||
|
is_active=is_active,
|
||||||
|
)
|
||||||
|
plain_secret = data.pop("_plain_secret")
|
||||||
|
|
||||||
|
# Validate using Pydantic schema
|
||||||
|
client_data = OAuthClientCreate(**data).dict()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Validation error: {e}")
|
||||||
|
return RedirectResponse(
|
||||||
|
url="/admin/oauth/clients/new?error=" + str(e).replace(" ", "+"),
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create client
|
||||||
|
client = await repository.create(client_data)
|
||||||
|
if not client:
|
||||||
|
return RedirectResponse(
|
||||||
|
url="/admin/oauth/clients/new?error=Failed+to+create+client",
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Created OAuth client {client.client_id}")
|
||||||
|
# TODO: Display client secret only once (store in flash message)
|
||||||
|
# For now, redirect to list with success message
|
||||||
|
return RedirectResponse(url="/admin/oauth/clients", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
|
@router.get("/oauth/clients/{client_id}/edit", response_class=HTMLResponse, tags=["admin-oauth"])
|
||||||
|
async def edit_oauth_client_form(
|
||||||
|
request: Request,
|
||||||
|
client_id: int,
|
||||||
|
repository: OAuthClientRepository = Depends(get_oauth_client_repository),
|
||||||
|
):
|
||||||
|
"""Display form to edit an existing OAuth client."""
|
||||||
|
client = await repository.get_by_id(client_id)
|
||||||
|
if not client:
|
||||||
|
raise HTTPException(status_code=404, detail="Client not found")
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"admin/oauth/client_form.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"session": request.session,
|
||||||
|
"action": "Edit",
|
||||||
|
"form_action": f"/admin/oauth/clients/{client_id}",
|
||||||
|
"client": client,
|
||||||
|
"errors": {},
|
||||||
|
"error": request.query_params.get("error"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/oauth/clients/{client_id}", response_class=RedirectResponse, tags=["admin-oauth"])
|
||||||
|
async def update_oauth_client(
|
||||||
|
request: Request,
|
||||||
|
client_id: int,
|
||||||
|
client_name: Optional[str] = Form(None),
|
||||||
|
redirect_uris: Optional[str] = Form(None),
|
||||||
|
grant_types: Optional[str] = Form(None),
|
||||||
|
scopes: Optional[str] = Form(None),
|
||||||
|
is_active: Optional[bool] = Form(None),
|
||||||
|
repository: OAuthClientRepository = Depends(get_oauth_client_repository),
|
||||||
|
):
|
||||||
|
"""Update an existing OAuth client."""
|
||||||
|
# Build update dict
|
||||||
|
update_data = {}
|
||||||
|
if client_name is not None:
|
||||||
|
update_data["name"] = client_name
|
||||||
|
if redirect_uris is not None:
|
||||||
|
update_data["redirect_uris"] = [uri.strip() for uri in redirect_uris.split(",") if uri.strip()]
|
||||||
|
if grant_types is not None:
|
||||||
|
update_data["grant_types"] = [gt.strip() for gt in grant_types.split(",") if gt.strip()]
|
||||||
|
if scopes is not None:
|
||||||
|
update_data["scopes"] = [scope.strip() for scope in scopes.split(",") if scope.strip()]
|
||||||
|
if is_active is not None:
|
||||||
|
update_data["is_active"] = is_active
|
||||||
|
|
||||||
|
if not update_data:
|
||||||
|
return RedirectResponse(url=f"/admin/oauth/clients/{client_id}/edit", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
|
# Validate using Pydantic schema (optional fields)
|
||||||
|
try:
|
||||||
|
validated = OAuthClientUpdate(**update_data).dict(exclude_unset=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Validation error: {e}")
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/admin/oauth/clients/{client_id}/edit?error=" + str(e).replace(" ", "+"),
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update client
|
||||||
|
client = await repository.update(client_id, validated)
|
||||||
|
if not client:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/admin/oauth/clients/{client_id}/edit?error=Failed+to+update+client",
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Updated OAuth client {client_id}")
|
||||||
|
return RedirectResponse(url="/admin/oauth/clients", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
|
@router.post("/oauth/clients/{client_id}/delete", response_class=RedirectResponse, tags=["admin-oauth"])
|
||||||
|
async def delete_oauth_client(
|
||||||
|
request: Request,
|
||||||
|
client_id: int,
|
||||||
|
repository: OAuthClientRepository = Depends(get_oauth_client_repository),
|
||||||
|
):
|
||||||
|
"""Delete a client (soft delete via is_active=False)."""
|
||||||
|
client = await repository.update(client_id, {"is_active": False})
|
||||||
|
if not client:
|
||||||
|
return RedirectResponse(
|
||||||
|
url="/admin/oauth/clients?error=Failed+to+delete+client",
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Soft-deleted OAuth client {client_id}")
|
||||||
|
return RedirectResponse(url="/admin/oauth/clients", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
|
@router.get("/oauth/tokens", response_class=HTMLResponse, tags=["admin-oauth"])
|
||||||
|
async def list_oauth_tokens(
|
||||||
|
request: Request,
|
||||||
|
page: int = 1,
|
||||||
|
client_id: Optional[str] = None,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
active: Optional[bool] = None,
|
||||||
|
repository: OAuthTokenRepository = Depends(get_oauth_token_repository),
|
||||||
|
):
|
||||||
|
"""List OAuth tokens with filtering (client, user, active/expired)."""
|
||||||
|
# Fetch all tokens (limited to reasonable count) for filtering
|
||||||
|
all_tokens = await repository.get_all(limit=1000)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
filtered = []
|
||||||
|
for token in all_tokens:
|
||||||
|
if client_id is not None and token.client_id != client_id:
|
||||||
|
continue
|
||||||
|
if user_id is not None and token.user_id != user_id:
|
||||||
|
continue
|
||||||
|
if active is not None:
|
||||||
|
is_expired = token.expires_at < datetime.utcnow()
|
||||||
|
if active and is_expired:
|
||||||
|
continue
|
||||||
|
if not active and not is_expired:
|
||||||
|
continue
|
||||||
|
filtered.append(token)
|
||||||
|
|
||||||
|
# Pagination after filtering
|
||||||
|
total = len(filtered)
|
||||||
|
total_pages = (total + PAGE_SIZE - 1) // PAGE_SIZE if total > 0 else 1
|
||||||
|
|
||||||
|
# Ensure page is within bounds
|
||||||
|
if page < 1 or (total_pages > 0 and page > total_pages):
|
||||||
|
return RedirectResponse(url="/admin/oauth/tokens?page=1")
|
||||||
|
|
||||||
|
skip = (page - 1) * PAGE_SIZE
|
||||||
|
tokens = filtered[skip:skip + PAGE_SIZE]
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"admin/oauth/tokens.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"session": request.session,
|
||||||
|
"tokens": tokens,
|
||||||
|
"page": page,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"client_id": client_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"active": active,
|
||||||
|
"now": datetime.utcnow(),
|
||||||
|
"error": request.query_params.get("error"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/oauth/tokens/{token_id}/revoke", response_class=RedirectResponse, tags=["admin-oauth"])
|
||||||
|
async def revoke_oauth_token(
|
||||||
|
request: Request,
|
||||||
|
token_id: int,
|
||||||
|
repository: OAuthTokenRepository = Depends(get_oauth_token_repository),
|
||||||
|
):
|
||||||
|
"""Revoke token (delete from database)."""
|
||||||
|
success = await repository.delete(token_id)
|
||||||
|
if not success:
|
||||||
|
return RedirectResponse(
|
||||||
|
url="/admin/oauth/tokens?error=Failed+to+revoke+token",
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Revoked OAuth token {token_id}")
|
||||||
|
return RedirectResponse(url="/admin/oauth/tokens", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
|
@router.get("/oauth/users", response_class=HTMLResponse, tags=["admin-oauth"])
|
||||||
|
async def list_oauth_users(
|
||||||
|
request: Request,
|
||||||
|
page: int = 1,
|
||||||
|
repository: OAuthUserRepository = Depends(get_oauth_user_repository),
|
||||||
|
):
|
||||||
|
"""List OAuth users."""
|
||||||
|
skip = (page - 1) * PAGE_SIZE
|
||||||
|
users = await repository.get_all(skip=skip, limit=PAGE_SIZE)
|
||||||
|
total = len(await repository.get_all(limit=1000)) # naive count
|
||||||
|
total_pages = (total + PAGE_SIZE - 1) // PAGE_SIZE if total > 0 else 1
|
||||||
|
|
||||||
|
# Ensure page is within bounds
|
||||||
|
if page < 1 or (total_pages > 0 and page > total_pages):
|
||||||
|
return RedirectResponse(url="/admin/oauth/users?page=1")
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"admin/oauth/users.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"session": request.session,
|
||||||
|
"users": users,
|
||||||
|
"page": page,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"error": request.query_params.get("error"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/oauth/users/{user_id}/toggle", response_class=RedirectResponse, tags=["admin-oauth"])
|
||||||
|
async def toggle_oauth_user(
|
||||||
|
request: Request,
|
||||||
|
user_id: int,
|
||||||
|
repository: OAuthUserRepository = Depends(get_oauth_user_repository),
|
||||||
|
):
|
||||||
|
"""Toggle user active status."""
|
||||||
|
user = await repository.get_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(
|
||||||
|
url="/admin/oauth/users?error=User+not+found",
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
new_status = not user.is_active
|
||||||
|
updated = await repository.update(user_id, {"is_active": new_status})
|
||||||
|
if not updated:
|
||||||
|
return RedirectResponse(
|
||||||
|
url="/admin/oauth/users?error=Failed+to+toggle+user",
|
||||||
|
status_code=status.HTTP_302_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Toggled OAuth user {user_id} active status to {new_status}")
|
||||||
|
return RedirectResponse(url="/admin/oauth/users", status_code=status.HTTP_302_FOUND)
|
||||||
0
app/modules/endpoints/__init__.py
Normal file
0
app/modules/endpoints/__init__.py
Normal file
0
app/modules/endpoints/models/__init__.py
Normal file
0
app/modules/endpoints/models/__init__.py
Normal file
31
app/modules/endpoints/models/endpoint_model.py
Normal file
31
app/modules/endpoints/models/endpoint_model.py
Normal 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}>"
|
||||||
0
app/modules/endpoints/repositories/__init__.py
Normal file
0
app/modules/endpoints/repositories/__init__.py
Normal file
161
app/modules/endpoints/repositories/endpoint_repository.py
Normal file
161
app/modules/endpoints/repositories/endpoint_repository.py
Normal 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
|
||||||
0
app/modules/endpoints/schemas/__init__.py
Normal file
0
app/modules/endpoints/schemas/__init__.py
Normal file
124
app/modules/endpoints/schemas/endpoint_schema.py
Normal file
124
app/modules/endpoints/schemas/endpoint_schema.py
Normal 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`)
|
||||||
0
app/modules/endpoints/services/__init__.py
Normal file
0
app/modules/endpoints/services/__init__.py
Normal file
370
app/modules/endpoints/services/route_service.py
Normal file
370
app/modules/endpoints/services/route_service.py
Normal 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,
|
||||||
|
}
|
||||||
41
app/modules/endpoints/services/template_service.py
Normal file
41
app/modules/endpoints/services/template_service.py
Normal 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
|
||||||
|
|
@ -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__)
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -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):
|
||||||
|
|
@ -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__)
|
||||||
|
|
@ -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
0
app/static/__init__.py
Normal file
0
app/templates/__init__.py
Normal file
0
app/templates/__init__.py
Normal 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
4
cookies.txt
Normal 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
173
examples/README.md
Normal 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)
|
||||||
756
examples/mockapi-collection.bru
Normal file
756
examples/mockapi-collection.bru
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
942
examples/mockapi-postman-collection.json
Normal file
942
examples/mockapi-postman-collection.json
Normal 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
121
examples/setup-test-client.py
Executable 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
51
examples/setup.sh
Executable 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"
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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)
|
|
||||||
Loading…
Reference in a new issue