Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions .env.example

This file was deleted.

20 changes: 20 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
################################################
# MoodlewareAPI environment template
# Copy to .env and adjust values
################################################

# Base Moodle instance URL
# Example: https://moodle.school.edu
MOODLE_URL=

# Port exposed by the API (compose uses this)
PORT=8000

# CORS: comma-separated list of allowed origins
# If empty or "*", all origins are allowed (no credentials)
ALLOW_ORIGINS=*

# Log level for application
# Valid: critical,error,warning,info,debug
# Default when unset: info
LOG_LEVEL=info
161 changes: 78 additions & 83 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,126 +14,121 @@
</p>

## 🚀 Features
- **Configuration-driven**: All endpoints defined in `config.json`
- **Dynamic Routes**: Automatically generates FastAPI routes from configuration
- **Authentication Flow**: Built-in token management for Moodle API
- **Auto Documentation**: Interactive API docs with Swagger/ReDoc
- **Docker Ready**: Easy deployment with Docker and docker-compose
- **Type Safety**: Full parameter validation and type checking
- Configuration-driven via `config.json`
- Dynamic FastAPI routes generated from config
- Built-in token retrieval endpoint
- Interactive API docs (Swagger UI)
- Docker-ready

## 📦 Installation
## 📦 Run

### Using Docker (Recommended)
### Docker
Use the included `compose.yaml` or run the image directly.

#### Option 1: Pull and Run a Pre-built Image
```bash
docker pull mydrift-user/moodlewareapi:latest
```

Then, run the container:
```bash
docker run -d -p 8000:8000 --name moodlewareapi mydrift-user/moodlewareapi:latest
```
The API will be available at [http://localhost:8000](http://localhost:8000).

#### Option 2: Run with Docker Compose
**1. Create a `compose.yaml` file:**
Docker Compose (recommended):
```yaml
services:
moodlewareapi:
image: mydrift-user/moodlewareapi:latest
build: .
ports:
- "8000:8000"
restart: unless-stopped
environment:
- MOODLE_URL=https://your-moodle-site.com
# Option A: Fixed Moodle URL for all requests
- MOODLE_URL=https://moodle.example.edu
# Option B: Per-request Moodle URL (set to * or leave empty)
# - MOODLE_URL=*
- ALLOW_ORIGINS=*
- LOG_LEVEL=info
```

**2. Start it:**
Start:
```bash
docker compose up -d --build
```

The API will be available at:
- **Direct**: [http://localhost:8000](http://localhost:8000)
- **Interactive Docs**: [http://localhost:8000/docs](http://localhost:8000/docs)
- **ReDoc**: [http://localhost:8000/redoc](http://localhost:8000/redoc)

### Manual Installation

### Manual
```bash
# Clone the repository
git clone https://github.com/MyDrift-user/MoodlewareAPI.git
cd MoodlewareAPI

# Create a virtual environment
python -m venv venv
# On Linux/macOS:
source venv/bin/activate
# On Windows:
# venv\Scripts\activate

# Install dependencies
pip install -r requirements.txt

# Run the application
python asgi.py
```
The API will be available at [http://localhost:8000](http://localhost:8000).

Service URLs:
- Swagger UI: http://localhost:8000/
- Health: http://localhost:8000/healthz

## 🛠️ Usage

### 1. Get Authentication Token
### 1) Get a Moodle token
- If `MOODLE_URL` is unset or `*`, include `moodle_url` in the query.
- If `MOODLE_URL` is a real URL, `moodle_url` is not required.

Example (per-request Moodle URL):
```bash
curl "http://localhost:8000/auth?moodle_url=https://moodle.school.edu&username=USER&password=PASS&service=moodle_mobile_app"
```

### 2) Call Moodle functions via REST proxy
Provide the token either via Authorization header or `?wstoken=`.

Example using Authorization header (with preconfigured MOODLE_URL):
```bash
curl -X POST "http://localhost:8000/get-token" \
-H "Content-Type: application/json" \
-d '{"moodle_url": "https://your-moodle.com", "username": "your-username", "password": "your-password"}'
curl -H "Authorization: Bearer YOUR_TOKEN" "http://localhost:8000/core_webservice_get_site_info"
```

### 2. Use API Endpoints
Include `moodle_url` and `token` in subsequent requests to access Moodle functions through simplified endpoints.
Example using query parameter (with preconfigured MOODLE_URL):
```bash
curl "http://localhost:8000/core_webservice_get_site_info?wstoken=YOUR_TOKEN"
```

## ⚙️ Configuration
Notes:
- When `ALLOW_ORIGINS=*`, credentials are disabled per CORS spec.
- Each response includes passthrough headers `X-Moodle-Direct-URL` and `X-Moodle-Direct-Method` for debugging.

Add endpoints to `config.json`:
## ⚙️ Environment
- `MOODLE_URL`
- Set to a full base URL (e.g., `https://moodle.example.com`) to use it for all requests, or
- Set to `*` or leave empty to require `moodle_url` per request.
- `PORT` (default 8000)
- `ALLOW_ORIGINS` (comma-separated; `*` allows all without credentials)
- `LOG_LEVEL` (`critical|error|warning|info|debug`, default `info`)

## 🔧 Config (`config.json`)
Minimal shape of an entry:
```json
{
"path": "/your/endpoint",
"method": "POST",
"function": "moodle_function_name",
"name": "your_endpoint_name",
"description": "What this endpoint does",
"tags": ["Category"],
"params": [
"path": "/core_webservice_get_site_info",
"method": "GET",
"function": "core_webservice_get_site_info",
"description": "Get Moodle site information & user information",
"tags": ["Core"],
"query_params": [
{
"name": "param_name",
"type": "str|int|bool|list|dict",
"required": true,
"default": "default_value",
"description": "Parameter description"
"name": "userid",
"type": "int",
"required": false,
"description": "User ID"
}
]
],
"responses": {
"200": {
"description": "OK"
}
}
}
```

### Configuration Fields
- **path**: URL path for the endpoint
- **method**: HTTP method (GET, POST, etc.)
- **function**: Moodle web service function name (or "auth"/"universal" for special handlers)
- **name**: Internal name for the endpoint
- **description**: Human-readable description
- **tags**: Array of tags for grouping in docs
- **params**: Array of parameter definitions with type validation
- `path`: Path added under the Moodle base URL
- `method`: HTTP method
- `function`: Moodle wsfunction name (auto-added for `/webservice/rest/server.php`)
- `description`, `tags`: For docs grouping
- `query_params`: Parameter list with `name`, `type` (str|int|bool|float|double|list), `required`, `default`, `description`
- `responses`: Optional OpenAPI response metadata

## 📋 Requirements
- Python 3.13+
- Python (see `requirements.txt`)
- Docker (optional)
- Dependencies: Listed in [requirements.txt](./requirements.txt)

## 📜 License
This project is licensed under the MIT License.
See the [LICENSE](LICENSE) file for more details.
## 📄 License
MIT. See `LICENSE`.

---
<p align="center">Made with ❤️ by <a href="https://github.com/MyDrift-user">MyDrift</a></p>
<p align="center">Made with ❤️ by <a href="https://github.com/mydrift-user">MyDrift</a></p>
55 changes: 43 additions & 12 deletions src/app.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import os
import json
from pathlib import Path
from fastapi import FastAPI, Request, HTTPException, Body, Depends, Security
import logging
import uuid
from typing import Callable
from fastapi import FastAPI, Request, Security, Response
from dotenv import load_dotenv
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from .utils import get_env_variable, load_config, create_handler
from fastapi.security import HTTPBearer
from .mw_utils import get_env_variable, load_config, create_handler

load_dotenv()

# Configure logging level (default to INFO)
_log_level_name = (get_env_variable("LOG_LEVEL") or "info").upper()
_log_level = getattr(logging, _log_level_name, logging.INFO)
logging.basicConfig(level=_log_level)
logger = logging.getLogger("moodleware")

app = FastAPI(
title="MoodlewareAPI",
description="A FastAPI application to wrap Moodle API functions into individual endpoints.",
Expand All @@ -17,31 +24,55 @@
redoc_url=None
)

# CORS configuration from env
_allow_origins_env = (get_env_variable("ALLOW_ORIGINS") or "").strip()
if _allow_origins_env == "" or _allow_origins_env == "*":
_allow_origins = ["*"]
_allow_credentials = False # '*' cannot be used with credentials per CORS spec
else:
_allow_origins = [o.strip() for o in _allow_origins_env.split(",") if o.strip()]
_allow_credentials = True

app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_origins=_allow_origins,
allow_credentials=_allow_credentials,
allow_methods=["*"],
allow_headers=["*"],
)

# Request ID middleware
@app.middleware("http")
async def add_request_id(request: Request, call_next: Callable):
req_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
response: Response = await call_next(request)
response.headers["X-Request-Id"] = req_id
return response

# Optional HTTP Bearer security for Swagger Authorize
http_bearer = HTTPBearer(auto_error=False)

config = load_config("config.json")

for endpoint_path, functions in config.items():
print(f"Processing endpoint: {endpoint_path}")
logger.debug(f"Processing endpoint: {endpoint_path}")
for function in functions:
print(f"Processing function: {function['function']} at path {function['path']}")
# Attach bearer scheme to all but the open /auth endpoint so Swagger propagates the token
logger.debug(f"Processing function: {function['function']} at path {function['path']}")
deps = [Security(http_bearer)] if function["path"] != "/auth" else None
base_handler = create_handler(function, endpoint_path)
endpoint_callable = base_handler

app.add_api_route(
path=function["path"],
endpoint=create_handler(function, endpoint_path),
endpoint=endpoint_callable,
methods=[function["method"].upper()],
tags=function["tags"],
summary=function["description"],
responses=function.get("responses"),
dependencies=deps,
)
)

# Health check
@app.get("/healthz", tags=["meta"])
async def healthz():
return {"status": "ok"}
20 changes: 20 additions & 0 deletions src/mw_utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Utilities package split from the former monolithic utils.py.

Modules:
- env: environment helpers
- config: config loader
- params: query/body param encoding helpers
- auth: token resolution helpers
- http_client: shared HTTP client settings
- handlers: dynamic FastAPI handler factory
"""

from .env import get_env_variable
from .config import load_config
from .handlers import create_handler

__all__ = [
"get_env_variable",
"load_config",
"create_handler",
]
14 changes: 14 additions & 0 deletions src/mw_utils/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from fastapi import Request


async def resolve_token_from_request(request: Request) -> str:
"""Resolve token from Authorization header (Bearer) or ?wstoken= query.

Returns empty string if not found.
"""
auth = request.headers.get("Authorization", "").strip()
if auth:
parts = auth.split()
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1].strip()
return (request.query_params.get("wstoken") or "").strip()
12 changes: 12 additions & 0 deletions src/mw_utils/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import json


def load_config(file_path: str) -> dict:
"""Load JSON config file or raise a clear error."""
try:
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError as e:
raise RuntimeError(f"Config not found: {file_path}") from e
except json.JSONDecodeError as e:
raise RuntimeError(f"Invalid JSON in config: {file_path}") from e
11 changes: 11 additions & 0 deletions src/mw_utils/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import os
import logging

LOGGER = logging.getLogger("moodleware.env")

def get_env_variable(var_name: str) -> str:
"""Return an environment variable or empty string if unset."""
value = os.environ.get(var_name, "")
if not value:
LOGGER.debug("Env '%s' not set or empty", var_name)
return value
Loading
Loading