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