diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6dfa2f2 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# 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/requirements.txt b/requirements.txt index 925b3c5..38a3c08 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ httpx pydantic uvicorn colorlog -python-dotenv \ No newline at end of file +python-dotenv +itsdangerous \ No newline at end of file diff --git a/src/app.py b/src/app.py index 1769cb5..6e37ae1 100644 --- a/src/app.py +++ b/src/app.py @@ -1,9 +1,10 @@ import os import json from pathlib import Path -from fastapi import FastAPI +from fastapi import FastAPI, Request, HTTPException, Body, Depends, Security 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 load_dotenv() @@ -24,17 +25,23 @@ allow_headers=["*"], ) +# 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}") 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 + deps = [Security(http_bearer)] if function["path"] != "/auth" else None app.add_api_route( path=function["path"], endpoint=create_handler(function, endpoint_path), methods=[function["method"].upper()], tags=function["tags"], summary=function["description"], - responses=function.get("responses") + responses=function.get("responses"), + dependencies=deps, ) \ No newline at end of file diff --git a/src/utils.py b/src/utils.py index 48045ca..c6b6e23 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,6 +1,6 @@ import os import json -from fastapi import Query, HTTPException, Response +from fastapi import Query, HTTPException, Response, Request from typing import Optional import httpx from urllib.parse import urlencode @@ -119,11 +119,24 @@ def _encode_param(params: dict, name: str, value, declared_type: str): 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(response: Response, **kwargs): + 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.") @@ -142,9 +155,12 @@ async def handler(response: Response, **kwargs): if pname in kwargs and kwargs[pname] is not None: _encode_param(params, pname, kwargs[pname], ptype) - # Token handling - if "wstoken" in kwargs and kwargs["wstoken"] is not None: - params["wstoken"] = kwargs["wstoken"] + # 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"): @@ -185,6 +201,11 @@ async def handler(response: Response, **kwargs): # 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, @@ -203,17 +224,6 @@ async def handler(response: Response, **kwargs): ) ) - param_names = {p["name"] if isinstance(p, dict) else p for p in query_params} - if not {"username", "password"}.issubset(param_names): - sig_params.append( - inspect.Parameter( - "wstoken", - inspect.Parameter.KEYWORD_ONLY, - annotation=str, - default=Query(..., description="Your Moodle Token, obtained from /auth") - ) - ) - # Map config 'type' to Python types for docs only def _py_type(tname: str): t = (tname or "str").lower()