From f21094ee69455b023d68b787091d400d28d0b83e Mon Sep 17 00:00:00 2001
From: MyDrift
Date: Tue, 16 Sep 2025 10:24:32 +0200
Subject: [PATCH 1/3] Refactor utils into mw_utils package and improve app
setup
- Split the monolithic utils.py into a modular mw_utils package with dedicated modules for environment, config, parameter encoding, authentication, HTTP client, and handler creation.
- Updated app.py to use the new utilities, added logging configuration, improved CORS setup, and introduced request ID and health check endpoints.
- Replaced .env.example with a more detailed .env.template.
---
.env.example | 9 --
.env.template | 20 +++
src/app.py | 55 ++++++--
src/mw_utils/__init__.py | 20 +++
src/mw_utils/auth.py | 14 ++
src/mw_utils/config.py | 12 ++
src/mw_utils/env.py | 11 ++
src/mw_utils/handlers.py | 142 +++++++++++++++++++
src/mw_utils/http_client.py | 5 +
src/mw_utils/params.py | 70 ++++++++++
src/utils.py | 266 ------------------------------------
11 files changed, 337 insertions(+), 287 deletions(-)
delete mode 100644 .env.example
create mode 100644 .env.template
create mode 100644 src/mw_utils/__init__.py
create mode 100644 src/mw_utils/auth.py
create mode 100644 src/mw_utils/config.py
create mode 100644 src/mw_utils/env.py
create mode 100644 src/mw_utils/handlers.py
create mode 100644 src/mw_utils/http_client.py
create mode 100644 src/mw_utils/params.py
delete mode 100644 src/utils.py
diff --git a/.env.example b/.env.example
deleted file mode 100644
index 6dfa2f2..0000000
--- a/.env.example
+++ /dev/null
@@ -1,9 +0,0 @@
-# Example environment variables for MoodlewareAPI
-# Copy this file to ".env" and adjust values.
-
-# Port the app listens on (used by compose)
-PORT=8000
-
-# Base URL of your Moodle instance (required)
-MOODLE_URL=https://moodle.school.edu
-
diff --git a/.env.template b/.env.template
new file mode 100644
index 0000000..ac0eb55
--- /dev/null
+++ b/.env.template
@@ -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
\ No newline at end of file
diff --git a/src/app.py b/src/app.py
index 6e37ae1..cec8b68 100644
--- a/src/app.py
+++ b/src/app.py
@@ -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.",
@@ -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,
- )
\ No newline at end of file
+ )
+
+# Health check
+@app.get("/healthz", tags=["meta"])
+async def healthz():
+ return {"status": "ok"}
\ No newline at end of file
diff --git a/src/mw_utils/__init__.py b/src/mw_utils/__init__.py
new file mode 100644
index 0000000..c90c499
--- /dev/null
+++ b/src/mw_utils/__init__.py
@@ -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",
+]
diff --git a/src/mw_utils/auth.py b/src/mw_utils/auth.py
new file mode 100644
index 0000000..2a03072
--- /dev/null
+++ b/src/mw_utils/auth.py
@@ -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()
diff --git a/src/mw_utils/config.py b/src/mw_utils/config.py
new file mode 100644
index 0000000..4b18082
--- /dev/null
+++ b/src/mw_utils/config.py
@@ -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
diff --git a/src/mw_utils/env.py b/src/mw_utils/env.py
new file mode 100644
index 0000000..41623a8
--- /dev/null
+++ b/src/mw_utils/env.py
@@ -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
diff --git a/src/mw_utils/handlers.py b/src/mw_utils/handlers.py
new file mode 100644
index 0000000..f063316
--- /dev/null
+++ b/src/mw_utils/handlers.py
@@ -0,0 +1,142 @@
+import logging
+from typing import Any, Dict, List
+from fastapi import Query, HTTPException, Response, Request
+import httpx
+from .env import get_env_variable
+from .params import encode_param
+from .auth import resolve_token_from_request
+from .http_client import DEFAULT_HEADERS
+
+LOGGER = logging.getLogger("moodleware.handlers")
+
+
+def _normalize_base_url(base_url: str) -> str:
+ if not base_url.lower().startswith(("http://", "https://")):
+ return f"https://{base_url}"
+ return base_url
+
+
+def _is_auth_endpoint(ep_path: str) -> bool:
+ return ep_path.endswith("/login/token.php")
+
+
+def _build_handler_signature(query_params: List[Dict[str, Any]], require_moodle_url: bool):
+ import inspect
+
+ sig_params: List[inspect.Parameter] = [
+ inspect.Parameter("request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request),
+ inspect.Parameter("response", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Response),
+ ]
+
+ if require_moodle_url:
+ sig_params.append(
+ inspect.Parameter(
+ "moodle_url",
+ inspect.Parameter.KEYWORD_ONLY,
+ annotation=str,
+ default=Query(..., description="URL of the Moodle instance, e.g., 'https://moodle.example.com'."),
+ )
+ )
+
+ def _py_type(tname: str):
+ t = (tname or "str").lower()
+ if t == "int":
+ return int
+ if t in {"float", "double"}:
+ return float
+ if t == "bool":
+ return bool
+ return str
+
+ for param in query_params:
+ pname = param["name"]
+ ptype = _py_type(param.get("type", "str"))
+ if param["required"]:
+ sig_params.append(
+ inspect.Parameter(
+ pname,
+ inspect.Parameter.KEYWORD_ONLY,
+ annotation=ptype,
+ default=Query(..., description=param["description"]),
+ )
+ )
+ else:
+ default_value = param.get("default", None)
+ sig_params.append(
+ inspect.Parameter(
+ pname,
+ inspect.Parameter.KEYWORD_ONLY,
+ annotation=Any, # Optional type in docs
+ default=Query(default_value, description=param["description"]),
+ )
+ )
+
+ return inspect.Signature(sig_params)
+
+
+def create_handler(function_config: Dict[str, Any], endpoint_path: str):
+ query_params: List[Dict[str, Any]] = function_config.get("query_params", [])
+ method = function_config.get("method", "GET").upper()
+
+ async def handler(request: Request, response: Response, **kwargs):
+ base_url = get_env_variable("MOODLE_URL") or kwargs.get("moodle_url")
+ if not base_url:
+ raise HTTPException(status_code=400, detail="Moodle URL not provided. Set MOODLE_URL env var or pass moodle_url as query param.")
+
+ base_url = _normalize_base_url(base_url)
+ ep_path = endpoint_path if endpoint_path.startswith("/") else f"/{endpoint_path}"
+ url = f"{base_url.rstrip('/')}{ep_path}"
+
+ params: Dict[str, Any] = {}
+ for param in query_params:
+ pname = param["name"]
+ ptype = param.get("type", "str")
+ if pname in kwargs and kwargs[pname] is not None:
+ encode_param(params, pname, kwargs[pname], ptype)
+
+ if not _is_auth_endpoint(ep_path):
+ token = await resolve_token_from_request(request)
+ if token:
+ params["wstoken"] = token
+
+ if ep_path.endswith("/webservice/rest/server.php"):
+ params.setdefault("wsfunction", function_config.get("function"))
+ params.setdefault("moodlewsrestformat", "json")
+
+ from urllib.parse import urlencode as _urlencode
+ direct_url = f"{url}?{_urlencode(params, doseq=True)}" if params else url
+ response.headers["X-Moodle-Direct-URL"] = direct_url
+ response.headers["X-Moodle-Direct-Method"] = method
+
+ try:
+ async with httpx.AsyncClient(follow_redirects=True, headers=DEFAULT_HEADERS) as client:
+ if method == "GET":
+ resp = await client.get(url, params=params)
+ elif method == "POST":
+ resp = await client.post(url, data=params)
+ else:
+ resp = await client.request(
+ method,
+ url,
+ params=params if method in {"DELETE", "HEAD"} else None,
+ data=None if method in {"DELETE", "HEAD"} else params,
+ )
+
+ resp.raise_for_status()
+ try:
+ return resp.json()
+ except ValueError:
+ return resp.text
+ except httpx.HTTPStatusError as e:
+ try:
+ detail = e.response.json()
+ except Exception:
+ detail = e.response.text
+ raise HTTPException(status_code=e.response.status_code, detail=detail)
+ except httpx.RequestError as e:
+ raise HTTPException(status_code=502, detail=f"Error contacting Moodle at {url}: {str(e)}")
+
+ require_moodle_url = not get_env_variable("MOODLE_URL")
+ handler.__signature__ = _build_handler_signature(query_params, require_moodle_url) # type: ignore[attr-defined]
+
+ return handler
diff --git a/src/mw_utils/http_client.py b/src/mw_utils/http_client.py
new file mode 100644
index 0000000..15d08d9
--- /dev/null
+++ b/src/mw_utils/http_client.py
@@ -0,0 +1,5 @@
+from typing import Dict
+
+DEFAULT_HEADERS: Dict[str, str] = {
+ "Accept": "application/json, text/plain;q=0.9, */*;q=0.8"
+}
diff --git a/src/mw_utils/params.py b/src/mw_utils/params.py
new file mode 100644
index 0000000..afc5a34
--- /dev/null
+++ b/src/mw_utils/params.py
@@ -0,0 +1,70 @@
+import json
+from typing import Any, Dict, List
+
+
+def parse_list_value(raw_val: Any) -> List[Any]:
+ if raw_val is None:
+ return []
+ if isinstance(raw_val, list):
+ return raw_val
+ if isinstance(raw_val, (bool, int, float)):
+ return [raw_val]
+ if isinstance(raw_val, str):
+ s = raw_val.strip()
+ if s == "":
+ return []
+ try:
+ parsed = json.loads(s)
+ if isinstance(parsed, list):
+ return parsed
+ except Exception:
+ if "," in s:
+ return [part.strip() for part in s.split(",") if part.strip() != ""]
+ return [s]
+ return [raw_val]
+
+
+def encode_param(params: Dict[str, Any], name: str, value: Any, declared_type: str) -> None:
+ dtype = (declared_type or "str").lower()
+
+ if dtype == "bool":
+ if isinstance(value, str):
+ v = value.strip().lower()
+ value = 1 if v in {"1", "true", "on", "yes"} else 0
+ else:
+ value = 1 if bool(value) else 0
+ params[name] = value
+ return
+
+ if dtype in {"float", "double"}:
+ try:
+ params[name] = float(value)
+ except Exception:
+ params[name] = value
+ return
+
+ if dtype == "int":
+ try:
+ params[name] = int(value)
+ except Exception:
+ params[name] = value
+ return
+
+ if dtype == "list":
+ items = parse_list_value(value)
+ for idx, item in enumerate(items):
+ if isinstance(item, dict):
+ for k, v in item.items():
+ if isinstance(v, bool):
+ v = 1 if v else 0
+ params[f"{name}[{idx}][{k}]"] = v
+ else:
+ params[f"{name}[{idx}]"] = item
+ return
+
+ if isinstance(value, dict):
+ for k, v in value.items():
+ params[f"{name}[{k}]"] = v
+ return
+
+ params[name] = value
diff --git a/src/utils.py b/src/utils.py
deleted file mode 100644
index c6b6e23..0000000
--- a/src/utils.py
+++ /dev/null
@@ -1,266 +0,0 @@
-import os
-import json
-from fastapi import Query, HTTPException, Response, Request
-from typing import Optional
-import httpx
-from urllib.parse import urlencode
-
-
-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:
- print(f"Environment variable '{var_name}' not set or empty, please provide moodle_url parameters in the requests.")
- return value
-
-
-def load_config(file_path: str) -> dict:
- try:
- with open(file_path, 'r') as f:
- return json.load(f)
- except FileNotFoundError:
- print(f"Error: {file_path} not found.")
- exit(1)
- except json.JSONDecodeError:
- print(f"Error: Invalid JSON for {file_path}.")
- exit(1)
-
-
-# Helper utilities to prepare parameters for Moodle REST (supports arrays and nested structures)
-def _parse_list_value(raw_val):
- """Accepts a value for a 'list' parameter and returns a Python list.
- Accepts JSON string (preferred), comma-separated string, or already a list.
- """
- if raw_val is None:
- return []
- # Already a list
- if isinstance(raw_val, list):
- return raw_val
- # If value comes as bool/int/float, wrap into list
- if isinstance(raw_val, (bool, int, float)):
- return [raw_val]
- # Try parse JSON
- if isinstance(raw_val, str):
- s = raw_val.strip()
- if s == "":
- return []
- try:
- parsed = json.loads(s)
- if isinstance(parsed, list):
- return parsed
- except Exception:
- # Fallback: comma-separated
- if "," in s:
- return [part.strip() for part in s.split(",") if part.strip() != ""]
- # Single scalar value in string -> list of one
- return [s]
- # Fallback
- return [raw_val]
-
-
-def _encode_param(params: dict, name: str, value, declared_type: str):
- """Encodes a single parameter into the params dict.
- - bool -> 1/0
- - float -> keep float
- - list -> name[0]=, name[1]= OR name[0][key]= for list of dicts
- - dict -> name[key]=value
- - other -> as is
- """
- # Normalize declared_type
- dtype = (declared_type or "str").lower()
-
- # Booleans -> 1/0
- if dtype == "bool":
- if isinstance(value, str):
- v = value.strip().lower()
- value = 1 if v in {"1", "true", "on", "yes"} else 0
- else:
- value = 1 if bool(value) else 0
- params[name] = value
- return
-
- # Float mapping
- if dtype in {"float", "double"}:
- try:
- params[name] = float(value)
- except Exception:
- params[name] = value
- return
-
- # Int mapping
- if dtype == "int":
- try:
- params[name] = int(value)
- except Exception:
- params[name] = value
- return
-
- # List mapping (supports list of scalars or list of dicts)
- if dtype == "list":
- items = _parse_list_value(value)
- for idx, item in enumerate(items):
- if isinstance(item, dict):
- for k, v in item.items():
- # Convert nested bools to 1/0, keep numbers/strings
- if isinstance(v, bool):
- v = 1 if v else 0
- params[f"{name}[{idx}][{k}]"] = v
- else:
- params[f"{name}[{idx}]"] = item
- return
-
- # Dict mapping
- if isinstance(value, dict):
- for k, v in value.items():
- params[f"{name}[{k}]"] = v
- return
-
- # Default (string or other scalar)
- params[name] = value
-
-
-# Extract token from Authorization header or query (no sessions)
-async def _resolve_token_from_request(request: Request) -> str:
- # Prefer Authorization header if present
- auth = request.headers.get("Authorization")
- if auth and auth.lower().startswith("bearer "):
- return auth.split(" ", 1)[1].strip()
- # Fallback to query string (?wstoken=)
- qtok = request.query_params.get("wstoken")
- if qtok:
- return qtok
- return ""
-
-
-def create_handler(function_config, endpoint_path: str):
- query_params = function_config.get("query_params", [])
- method = function_config.get("method", "GET").upper()
-
- async def handler(request: Request, response: Response, **kwargs):
- base_url = get_env_variable("MOODLE_URL") or kwargs.get("moodle_url")
- if not base_url:
- raise HTTPException(status_code=400, detail="Moodle URL not provided. Set MOODLE_URL env var or pass moodle_url as query param.")
-
- if not base_url.lower().startswith(("http://", "https://")):
- base_url = f"https://{base_url}"
-
- ep_path = endpoint_path if endpoint_path.startswith("/") else f"/{endpoint_path}"
- url = f"{base_url.rstrip('/')}{ep_path}"
-
- # Build params with support for complex types
- params = {}
- for param in query_params:
- pname = param["name"]
- ptype = param.get("type", "str")
- if pname in kwargs and kwargs[pname] is not None:
- _encode_param(params, pname, kwargs[pname], ptype)
-
- # Token handling: for non-auth routes, resolve from header or query
- is_auth_endpoint = ep_path.endswith("/login/token.php")
- if not is_auth_endpoint:
- token = await _resolve_token_from_request(request)
- if token:
- params["wstoken"] = token
-
- # For core REST endpoint include wsfunction & format
- if ep_path.endswith("/webservice/rest/server.php"):
- params.setdefault("wsfunction", function_config.get("function"))
- params.setdefault("moodlewsrestformat", "json")
-
- from urllib.parse import urlencode as _urlencode
- direct_url = f"{url}?{_urlencode(params, doseq=True)}" if params else url
- response.headers["X-Moodle-Direct-URL"] = direct_url
- response.headers["X-Moodle-Direct-Method"] = method
-
- try:
- async with httpx.AsyncClient(timeout=httpx.Timeout(30.0), follow_redirects=True, headers={"Accept": "application/json, text/plain;q=0.9, */*;q=0.8"}) as client:
- if method == "GET":
- resp = await client.get(url, params=params)
- elif method == "POST":
- # Moodle expects form-encoded for POST
- resp = await client.post(url, data=params)
- else:
- resp = await client.request(method, url, params=params if method in {"DELETE", "HEAD"} else None, data=None if method in {"DELETE", "HEAD"} else params)
-
- resp.raise_for_status()
-
- try:
- return resp.json()
- except ValueError:
- return resp.text
- except httpx.HTTPStatusError as e:
- detail = None
- try:
- detail = e.response.json()
- except Exception:
- detail = e.response.text
- raise HTTPException(status_code=e.response.status_code, detail=detail)
- except httpx.RequestError as e:
- raise HTTPException(status_code=502, detail=f"Error contacting Moodle at {url}: {str(e)}")
-
- # Build dynamic signature for OpenAPI/Docs
- import inspect
- sig_params = [
- inspect.Parameter(
- "request",
- inspect.Parameter.POSITIONAL_OR_KEYWORD,
- annotation=Request
- ),
- inspect.Parameter(
- "response",
- inspect.Parameter.POSITIONAL_OR_KEYWORD,
- annotation=Response
- )
- ]
-
- # moodle_url is only needed if env var not set
- if not get_env_variable("MOODLE_URL"):
- sig_params.append(
- inspect.Parameter(
- "moodle_url",
- inspect.Parameter.KEYWORD_ONLY,
- annotation=str,
- default=Query(..., description="URL of the Moodle instance, e.g., 'https://moodle.example.com'.")
- )
- )
-
- # Map config 'type' to Python types for docs only
- def _py_type(tname: str):
- t = (tname or "str").lower()
- if t == "int":
- return int
- if t in {"float", "double"}:
- return float
- if t == "bool":
- return bool
- # For lists and complex we accept string (JSON or CSV)
- return str
-
- for param in query_params:
- pname = param["name"]
- ptype = _py_type(param.get("type", "str"))
- if param["required"]:
- sig_params.append(
- inspect.Parameter(
- pname,
- inspect.Parameter.KEYWORD_ONLY,
- annotation=ptype,
- default=Query(..., description=param["description"])
- )
- )
- else:
- default_value = param.get("default", None)
- sig_params.append(
- inspect.Parameter(
- pname,
- inspect.Parameter.KEYWORD_ONLY,
- annotation=Optional[ptype],
- default=Query(default_value, description=param["description"])
- )
- )
-
- new_sig = inspect.Signature(sig_params)
- # Avoid static type checker complaints by using setattr
- setattr(handler, "__signature__", new_sig)
-
- return handler
\ No newline at end of file
From b64248da5fa3c843a96bfd78142ad3ec0201340a Mon Sep 17 00:00:00 2001
From: MyDrift
Date: Tue, 16 Sep 2025 10:53:01 +0200
Subject: [PATCH 2/3] Improve MOODLE_URL handling and handler creation
- Introduces _get_env_moodle_url to treat empty or '*' MOODLE_URL as unset, requiring moodle_url query param.
- Adds docstrings for clarity, refines handler logic to use normalized base URL, and updates handler signature construction to reflect new environment variable handling.
---
src/mw_utils/handlers.py | 28 ++++++++++++++++++++++++----
1 file changed, 24 insertions(+), 4 deletions(-)
diff --git a/src/mw_utils/handlers.py b/src/mw_utils/handlers.py
index f063316..66ec87d 100644
--- a/src/mw_utils/handlers.py
+++ b/src/mw_utils/handlers.py
@@ -11,16 +11,28 @@
def _normalize_base_url(base_url: str) -> str:
+ """Normalize a Moodle base URL: ensure it has http/https (default https)."""
if not base_url.lower().startswith(("http://", "https://")):
return f"https://{base_url}"
return base_url
def _is_auth_endpoint(ep_path: str) -> bool:
+ """Detect whether the endpoint path targets Moodle's token endpoint."""
return ep_path.endswith("/login/token.php")
+# Markers considered as 'unset' for MOODLE_URL; requires moodle_url query param
+_def_unset_markers = {"", "*"}
+
+def _get_env_moodle_url() -> str:
+ """Read MOODLE_URL from env; treat '' or '*' as unset and return ''."""
+ val = (get_env_variable("MOODLE_URL") or "").strip()
+ return "" if val in _def_unset_markers else val
+
+
def _build_handler_signature(query_params: List[Dict[str, Any]], require_moodle_url: bool):
+ """Build a FastAPI handler signature from config and moodle_url requirement."""
import inspect
sig_params: List[inspect.Parameter] = [
@@ -66,7 +78,7 @@ def _py_type(tname: str):
inspect.Parameter(
pname,
inspect.Parameter.KEYWORD_ONLY,
- annotation=Any, # Optional type in docs
+ annotation=Any,
default=Query(default_value, description=param["description"]),
)
)
@@ -75,17 +87,25 @@ def _py_type(tname: str):
def create_handler(function_config: Dict[str, Any], endpoint_path: str):
+ """Factory that creates an async FastAPI route handler proxying to Moodle.
+
+ Uses MOODLE_URL from env (unless '*' or empty), otherwise requires a
+ moodle_url query param. Automatically adds wstoken for non-auth endpoints
+ and sets wsfunction/moodlewsrestformat for REST server endpoints.
+ """
query_params: List[Dict[str, Any]] = function_config.get("query_params", [])
method = function_config.get("method", "GET").upper()
async def handler(request: Request, response: Response, **kwargs):
- base_url = get_env_variable("MOODLE_URL") or kwargs.get("moodle_url")
+ """Proxy request to the Moodle instance and return JSON/text response."""
+ env_base = _get_env_moodle_url()
+ base_url = env_base or kwargs.get("moodle_url")
if not base_url:
raise HTTPException(status_code=400, detail="Moodle URL not provided. Set MOODLE_URL env var or pass moodle_url as query param.")
base_url = _normalize_base_url(base_url)
ep_path = endpoint_path if endpoint_path.startswith("/") else f"/{endpoint_path}"
- url = f"{base_url.rstrip('/')}{ep_path}"
+ url = f"{base_url.rstrip('/')}" + ep_path
params: Dict[str, Any] = {}
for param in query_params:
@@ -136,7 +156,7 @@ async def handler(request: Request, response: Response, **kwargs):
except httpx.RequestError as e:
raise HTTPException(status_code=502, detail=f"Error contacting Moodle at {url}: {str(e)}")
- require_moodle_url = not get_env_variable("MOODLE_URL")
+ require_moodle_url = not _get_env_moodle_url()
handler.__signature__ = _build_handler_signature(query_params, require_moodle_url) # type: ignore[attr-defined]
return handler
From 1871f2b7b4205d9e2299a212490031a79e05120f Mon Sep 17 00:00:00 2001
From: MyDrift
Date: Tue, 16 Sep 2025 11:14:34 +0200
Subject: [PATCH 3/3] Revise and clarify README documentation
- Streamlined feature list
- Updated Docker and manual run instructions
- Clarified authentication and usage steps
- Improved configuration documentation.
- Enhanced environment variable explanations and provided clearer examples for API usage.
- Added Credits
---
README.md | 161 ++++++++++++++++++++++++++----------------------------
1 file changed, 78 insertions(+), 83 deletions(-)
diff --git a/README.md b/README.md
index 933dd7c..23b2c7c 100644
--- a/README.md
+++ b/README.md
@@ -14,126 +14,121 @@
## 🚀 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`.
---
-Made with ❤️ by MyDrift
+Made with ❤️ by MyDrift
\ No newline at end of file