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
|
||||
|
||||
# IDE
|
||||
.opencode/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.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.6: Testing completed
|
||||
|
||||
## Next Steps
|
||||
1. Update documentation with OAuth2 usage examples.
|
||||
## Next Steps (Updated 2026-03-16)
|
||||
1. ✅ Documentation updated with OAuth2 usage examples and Bruno API collection.
|
||||
2. Deploy to production environment (if needed).
|
||||
3. Consider adding PKCE support for public clients.
|
||||
4. Add more advanced OAuth2 features (e.g., token introspection, JWKS endpoint).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -8,6 +8,10 @@ A lightweight, configurable mock API application in Python that allows dynamic e
|
|||
- **Template Variable Support**: Response bodies can include Jinja2 template variables (e.g., `{{ user_id }}`, `{{ timestamp }}`) populated from path parameters, query strings, headers, request body, system variables, and endpoint defaults.
|
||||
- **Dynamic Route Registration**: Endpoints are registered/unregistered at runtime without restarting the server.
|
||||
- **Admin Interface**: Secure web UI with session-based authentication for managing endpoints.
|
||||
- **OAuth2 Provider**: Full OAuth2 implementation supporting authorization code, client credentials, and refresh token grant types.
|
||||
- **Endpoint‑Level OAuth Protection**: Individual endpoints can require OAuth2 tokens with configurable scopes.
|
||||
- **Admin OAuth2 Management**: Web UI for managing OAuth clients, tokens, and users.
|
||||
- **OpenID Connect Discovery**: Standards‑compliant discovery endpoint.
|
||||
- **Production Ready**: Uses Waitress WSGI server, SQLAlchemy async, and FastAPI with proper error handling and security measures.
|
||||
|
||||
## Technology Stack
|
||||
|
|
@ -16,7 +20,8 @@ A lightweight, configurable mock API application in Python that allows dynamic e
|
|||
- **Server**: Waitress (production WSGI server)
|
||||
- **Database**: SQLite with SQLAlchemy 2.0 async ORM
|
||||
- **Templating**: Jinja2 with sandboxed environment
|
||||
- **Authentication**: Session-based with bcrypt password hashing
|
||||
- **Authentication**: Session‑based admin authentication with bcrypt password hashing
|
||||
- **OAuth2**: JWT‑based tokens with configurable scopes, client validation, and token revocation
|
||||
- **Frontend**: Bootstrap 5 (CDN) for admin UI
|
||||
|
||||
## Project Structure
|
||||
|
|
@ -31,38 +36,46 @@ mockapi/
|
|||
├── middleware/
|
||||
│ └── auth_middleware.py # Admin authentication middleware
|
||||
├── models/
|
||||
│ └── endpoint_model.py # Endpoint SQLAlchemy model
|
||||
│ ├── endpoint_model.py # Endpoint SQLAlchemy model
|
||||
│ └── oauth_models.py # OAuth2 client, token, and user models
|
||||
├── observers/
|
||||
│ └── __init__.py # Observer pattern placeholder
|
||||
├── repositories/
|
||||
│ └── endpoint_repository.py # Repository pattern for endpoints
|
||||
│ ├── endpoint_repository.py # Repository pattern for endpoints
|
||||
│ └── oauth2/ # OAuth2 repositories
|
||||
├── run.py # Development runner script (with auto-reload)
|
||||
├── services/
|
||||
│ ├── route_service.py # Dynamic route registration/management
|
||||
│ └── template_service.py # Jinja2 template rendering
|
||||
├── controllers/
|
||||
│ └── admin_controller.py # Admin UI routes
|
||||
│ ├── admin_controller.py # Admin UI routes
|
||||
│ └── oauth2/ # OAuth2 controllers and services
|
||||
├── schemas/
|
||||
│ └── endpoint_schema.py # Pydantic schemas for validation
|
||||
│ ├── endpoint_schema.py # Pydantic schemas for validation
|
||||
│ └── oauth2/ # OAuth2 schemas
|
||||
├── templates/ # Jinja2 HTML templates
|
||||
│ ├── base.html # Base layout
|
||||
│ └── admin/
|
||||
│ ├── login.html # Login page
|
||||
│ ├── dashboard.html # Admin dashboard
|
||||
│ ├── endpoints.html # Endpoint list
|
||||
│ └── endpoint_form.html # Create/edit endpoint
|
||||
│ ├── endpoint_form.html # Create/edit endpoint
|
||||
│ └── oauth/ # OAuth2 management pages
|
||||
├── static/
|
||||
│ └── css/ # Static CSS (optional)
|
||||
├── tests/ # Test suite
|
||||
│ ├── test_admin.py # Admin authentication tests
|
||||
│ ├── test_endpoint_repository.py
|
||||
│ └── test_route_manager_fix.py
|
||||
│ ├── test_route_manager_fix.py
|
||||
│ ├── test_oauth2_controller.py
|
||||
│ └── integration/ # Integration tests
|
||||
├── utils/ # Utility modules
|
||||
│ └── __init__.py
|
||||
├── requirements.txt # Python dependencies
|
||||
├── .env.example # Example environment variables
|
||||
├── .env # Local environment variables (create from .env.example)
|
||||
├── run_example.sh # Script to run the integration test
|
||||
├── LICENSE # MIT License
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
|
|
@ -134,6 +147,128 @@ The server will start on `http://localhost:8000` (or your configured host/port).
|
|||
|
||||
**Note:** Waitress is a WSGI server, but FastAPI is an ASGI framework. The `wsgi.py` file uses `a2wsgi` to wrap the ASGI application into a WSGI-compatible interface. Routes are automatically refreshed from the database on server startup.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Start the Server
|
||||
|
||||
```bash
|
||||
# Development (auto-reload)
|
||||
python run.py
|
||||
|
||||
# Or directly with uvicorn
|
||||
uvicorn app:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# Production (with Waitress)
|
||||
waitress-serve --host=0.0.0.0 --port=8000 --threads=4 wsgi:wsgi_app
|
||||
```
|
||||
|
||||
The server will start on `http://localhost:8000` (or your configured host/port).
|
||||
|
||||
### 2. Test Basic Functionality
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# Access Swagger UI (auto-generated docs)
|
||||
open http://localhost:8000/docs
|
||||
```
|
||||
|
||||
### 3. Use API Testing Collections
|
||||
|
||||
Ready-to-use API collections are available in the `examples/` directory:
|
||||
|
||||
| Collection | Format | Description |
|
||||
|------------|--------|-------------|
|
||||
| **Bruno** | `mockapi-collection.bru` | Bruno collection with scripting support |
|
||||
| **Postman** | `mockapi-postman-collection.json` | Postman Collection v2.1 |
|
||||
|
||||
Both collections include:
|
||||
- Global variables (base URL, credentials, tokens)
|
||||
- Full OAuth2 flow testing (client credentials, authorization code, refresh)
|
||||
- Mock endpoint CRUD operations
|
||||
- Admin authentication
|
||||
- Protected endpoint examples
|
||||
|
||||
**Setup:**
|
||||
```bash
|
||||
# Create test OAuth client
|
||||
./examples/setup.sh
|
||||
|
||||
# Or manually
|
||||
python examples/setup-test-client.py
|
||||
```
|
||||
|
||||
**Import:**
|
||||
- **Bruno**: Drag and drop `.bru` file or use "Import Collection"
|
||||
- **Postman**: Use "Import" button and select JSON file
|
||||
|
||||
See [examples/README.md](examples/README.md) for detailed usage.
|
||||
|
||||
### 4. Basic cURL Examples (Quick Reference)
|
||||
|
||||
```bash
|
||||
# Admin login (sets session cookie)
|
||||
curl -c cookies.txt -X POST http://localhost:8000/admin/login \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "username=admin&password=admin123"
|
||||
|
||||
# Create a simple mock endpoint
|
||||
curl -b cookies.txt -X POST http://localhost:8000/admin/endpoints \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "route=/api/test&method=GET&response_body={\"message\":\"test\"}&response_code=200&content_type=application/json&is_active=true"
|
||||
|
||||
# Call the endpoint
|
||||
curl http://localhost:8000/api/test
|
||||
|
||||
# OAuth2 client credentials grant (using test client)
|
||||
curl -X POST http://localhost:8000/oauth/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=client_credentials&client_id=test_client&client_secret=test_secret&scope=api:read"
|
||||
```
|
||||
|
||||
For comprehensive testing with all OAuth2 flows and examples, use the provided API collections.
|
||||
|
||||
|
||||
|
||||
## OAuth2 Authentication
|
||||
|
||||
The application includes a full OAuth2 provider implementing RFC 6749 and OpenID Connect Discovery.
|
||||
|
||||
### Supported Grant Types
|
||||
|
||||
- **Authorization Code**: For web applications with server‑side components.
|
||||
- **Client Credentials**: For machine‑to‑machine communication.
|
||||
- **Refresh Token**: To obtain new access tokens without user interaction.
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/oauth/authorize` | GET, POST | Authorization endpoint (interactive user consent) |
|
||||
| `/oauth/token` | POST | Token issuance and refresh |
|
||||
| `/oauth/userinfo` | GET | OpenID Connect user claims |
|
||||
| `/oauth/introspect` | POST | Token introspection (RFC 7662) |
|
||||
| `/oauth/revoke` | POST | Token revocation (RFC 7009) |
|
||||
| `/.well‑known/openid‑configuration` | GET | OpenID Connect discovery document |
|
||||
|
||||
### Scope Management
|
||||
|
||||
Default scopes include:
|
||||
- `openid` – OpenID Connect support
|
||||
- `profile` – Basic profile information
|
||||
- `email` – Email address claim
|
||||
- `api:read` – Read access to protected endpoints
|
||||
- `api:write` – Write access to protected endpoints
|
||||
|
||||
### Admin OAuth2 Management
|
||||
|
||||
Access OAuth2 management via the admin interface:
|
||||
|
||||
- **Clients**: `http://localhost:8000/admin/oauth/clients` – Register and manage OAuth clients
|
||||
- **Tokens**: `http://localhost:8000/admin/oauth/tokens` – View and revoke issued tokens
|
||||
- **Users**: `http://localhost:8000/admin/oauth/users` – Manage OAuth user records
|
||||
|
||||
## Production Deployment Considerations
|
||||
|
||||
### 1. **Environment Configuration**
|
||||
|
|
@ -230,6 +365,7 @@ The following variable sources are available in response templates:
|
|||
- **Activate/deactivate** endpoints without deletion
|
||||
- **Delete** endpoints (removes route)
|
||||
- **Dashboard** with statistics (total endpoints, active routes, etc.)
|
||||
- **OAuth2 management** – clients, tokens, users
|
||||
|
||||
## Security Considerations
|
||||
|
||||
|
|
@ -239,6 +375,7 @@ The following variable sources are available in response templates:
|
|||
- **Request size limits**: Maximum body size of 1MB to prevent DoS.
|
||||
- **Route validation**: Prevents path traversal (`..`) and other unsafe patterns.
|
||||
- **SQL injection protection**: All queries use SQLAlchemy ORM.
|
||||
- **OAuth2 security**: Client secret hashing, token revocation, scope validation, secure token storage.
|
||||
|
||||
## Configuration Options
|
||||
|
||||
|
|
@ -251,6 +388,10 @@ See `config.py` for all available settings. Key environment variables:
|
|||
| `ADMIN_PASSWORD` | `admin123` | Admin login password (plaintext) |
|
||||
| `SECRET_KEY` | `your‑secret‑key‑here‑change‑me` | Session signing secret |
|
||||
| `DEBUG` | `False` | Enable debug mode (more logging, relaxed validation) |
|
||||
| `OAUTH2_ISSUER` | `http://localhost:8000` | OAuth2 issuer URL for discovery |
|
||||
| `OAUTH2_ACCESS_TOKEN_EXPIRE_MINUTES` | `30` | Access token lifetime |
|
||||
| `OAUTH2_REFRESH_TOKEN_EXPIRE_DAYS` | `7` | Refresh token lifetime |
|
||||
| `OAUTH2_SUPPORTED_SCOPES` | `["openid","profile","email","api:read","api:write"]` | Available OAuth2 scopes |
|
||||
|
||||
**Warning**: In production (`DEBUG=False`), the default `ADMIN_PASSWORD` and `SECRET_KEY` will cause validation errors. You must set unique values via environment variables.
|
||||
|
||||
|
|
@ -266,6 +407,42 @@ The dynamic mock endpoints are not listed in the OpenAPI schema (they are regist
|
|||
|
||||
## Development & Testing
|
||||
|
||||
## API Testing Collections
|
||||
|
||||
Ready-to-use API collections are available in the `examples/` directory:
|
||||
|
||||
### Bruno Collection (`mockapi-collection.bru`)
|
||||
- **Format**: Bruno native format (`.bru`)
|
||||
- **Features**: Scripting support, environment variables, folder organization
|
||||
- **Import**: Drag and drop into Bruno or use "Import Collection"
|
||||
|
||||
### Postman Collection (`mockapi-postman-collection.json`)
|
||||
- **Format**: Postman Collection v2.1
|
||||
- **Features**: Pre-request scripts, tests, environment variables
|
||||
- **Import**: Import into Postman via "Import" button
|
||||
|
||||
**Quick setup:**
|
||||
```bash
|
||||
# Create test OAuth client and view instructions
|
||||
./examples/setup.sh
|
||||
|
||||
# Or import directly:
|
||||
# examples/mockapi-collection.bru (Bruno)
|
||||
# examples/mockapi-postman-collection.json (Postman)
|
||||
```
|
||||
|
||||
Both collections include:
|
||||
- Global variables for base URL and credentials
|
||||
- Pre-configured requests for all endpoints
|
||||
- OAuth2 flow examples (client credentials, authorization code, refresh)
|
||||
- Admin authentication
|
||||
- Mock endpoint creation and testing
|
||||
- Protected endpoint examples
|
||||
|
||||
See [examples/README.md](examples/README.md) for detailed usage instructions.
|
||||
|
||||
|
||||
|
||||
### Running Tests
|
||||
|
||||
Run tests with pytest:
|
||||
|
|
@ -277,6 +454,7 @@ The test suite includes:
|
|||
- Unit tests for repository and service layers
|
||||
- Integration tests for admin authentication
|
||||
- Template rendering tests
|
||||
- OAuth2 unit and integration tests (21+ tests)
|
||||
|
||||
### Example Integration Test
|
||||
|
||||
|
|
@ -328,6 +506,12 @@ This is a great way to verify that the API is working correctly after installati
|
|||
- Check variable names match the context (use path_, query_, header_ prefixes as needed)
|
||||
- View the rendered template in the admin edit form preview
|
||||
|
||||
5. **OAuth2 token validation fails**
|
||||
- Verify the token hasn't expired
|
||||
- Check that the client is active
|
||||
- Confirm the token has required scopes for the endpoint
|
||||
- Ensure the token hasn't been revoked
|
||||
|
||||
### Logging
|
||||
Enable debug logging by setting `DEBUG=True` in `.env`. Check the console output for detailed error messages.
|
||||
|
||||
|
|
@ -337,6 +521,8 @@ Enable debug logging by setting `DEBUG=True` in `.env`. Check the console output
|
|||
- SQLite only (but can be extended to PostgreSQL via `DATABASE_URL`)
|
||||
- Single admin user (no multi‑user support)
|
||||
- No request logging/history
|
||||
- OAuth2 protection fields (requires_oauth, oauth_scopes) not exposed in admin UI
|
||||
- OAuth2 user authentication uses placeholder user IDs (integration with external identity providers pending)
|
||||
|
||||
- **Possible extensions**:
|
||||
- Import/export endpoints as JSON/YAML
|
||||
|
|
@ -345,12 +531,15 @@ Enable debug logging by setting `DEBUG=True` in `.env`. Check the console output
|
|||
- Multiple admin users with roles
|
||||
- Rate limiting per endpoint
|
||||
- CORS configuration
|
||||
- PKCE support for public OAuth2 clients
|
||||
- Integration with external identity providers (SAML, LDAP)
|
||||
|
||||
## License
|
||||
|
||||
This project is provided as-is for demonstration purposes. Use at your own risk.
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Built with [FastAPI](https://fastapi.tiangolo.com/), [SQLAlchemy](https://www.sqlalchemy.org/), and [Jinja2](https://jinja.palletsprojects.com/).
|
||||
- Admin UI uses [Bootstrap 5](https://getbootstrap.com/) via CDN.
|
||||
- OAuth2 implementation follows RFC 6749, RFC 7662, and OpenID Connect standards.
|
||||
|
|
|
|||
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.orm import sessionmaker, declarative_base
|
||||
from sqlalchemy import text, event
|
||||
from config import settings
|
||||
from app.core.config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -30,7 +30,8 @@ AsyncSessionLocal = sessionmaker(
|
|||
Base = declarative_base()
|
||||
|
||||
# Import models to ensure they are registered with Base.metadata
|
||||
from models import Endpoint, OAuthClient, OAuthToken, OAuthUser
|
||||
from app.modules.endpoints.models.endpoint_model import Endpoint
|
||||
from app.modules.oauth2.models import OAuthClient, OAuthToken, OAuthUser
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from database import get_db
|
||||
from app.core.database import get_db
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
|
@ -5,7 +5,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||
from fastapi import status
|
||||
from starlette.requests import Request
|
||||
from fastapi.responses import Response, RedirectResponse
|
||||
from config import settings
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional
|
||||
from config import settings
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -19,8 +19,8 @@ from fastapi.responses import RedirectResponse, JSONResponse
|
|||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.config import settings
|
||||
from .services import (
|
||||
OAuthService,
|
||||
TokenService,
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
from sqlalchemy import Column, Integer, String, Boolean, TIMESTAMP, ForeignKey, Index, UniqueConstraint
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from database import Base
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class OAuthClient(Base):
|
||||
|
|
@ -6,8 +6,8 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||
import logging
|
||||
|
||||
# Import database first to resolve circular import
|
||||
import database
|
||||
from models.oauth_models import OAuthClient, OAuthToken, OAuthUser
|
||||
from app.core import database
|
||||
from app.modules.oauth2.models import OAuthClient, OAuthToken, OAuthUser
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -8,8 +8,8 @@ from jose import jwt, JWTError
|
|||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
from middleware.auth_middleware import verify_password
|
||||
from app.core.config import settings
|
||||
from app.core.middleware.auth_middleware import verify_password
|
||||
from .repositories import OAuthClientRepository, OAuthTokenRepository, OAuthUserRepository
|
||||
from .schemas import OAuthTokenCreate, OAuthClientResponse
|
||||
from .auth_code_store import authorization_code_store
|
||||
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