diff --git a/.gitignore b/.gitignore index b7faf40..7de6d2e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ __pycache__/ *.py[codz] *$py.class +# Response files +exresponses/ + # C extensions *.so diff --git a/asgi.py b/asgi.py index 9bc2d81..80e5165 100644 --- a/asgi.py +++ b/asgi.py @@ -1,5 +1,5 @@ import uvicorn -from src.api.app import app +from src.app import app if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/compose.yaml b/compose.yaml index 12054ae..a77c655 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,4 +6,6 @@ services: build: . ports: - "8000:8000" + environment: + - MOODLE_URL=http://moodle.school.edu restart: unless-stopped diff --git a/config.json b/config.json index e128913..76cf9bc 100644 --- a/config.json +++ b/config.json @@ -1,440 +1,734 @@ { - "endpoints": [ + "/login/token.php": [ { - "path": "/get-token", - "method": "POST", + "path": "/auth", + "method": "GET", "function": "auth", - "name": "get_moodle_token", - "description": "Get Moodle token for API calls", + "description": "Get Moodle token for API calls.", "tags": ["Authentication"], - "params": [ - {"name": "moodle_url", "type": "str", "required": true, "description": "Moodle site URL"}, - {"name": "username", "type": "str", "required": true, "description": "Moodle username"}, - {"name": "password", "type": "str", "required": true, "description": "Moodle password"}, - {"name": "service", "type": "str", "required": false, "default": "moodle_mobile_app", "description": "Web service name"} - ] - }, + "query_params": [ + { + "name": "username", + "type": "str", + "required": true, + "description": "Moodle username" + }, + { + "name": "password", + "type": "str", + "required": true, + "description": "Moodle password" + }, + { + "name": "service", + "type": "str", + "required": false, + "default": "moodle_mobile_app", + "description": "Web service name" + } + ], + "responses": { + "200": { + "token": "string", + "privatetoken": "string" + } + } + } + ], + "/webservice/rest/server.php": [ { - "path": "/core/webservice/get-site-info", - "method": "POST", + "path": "/core_webservice_get_site_info", + "method": "GET", "function": "core_webservice_get_site_info", - "name": "get_site_info", - "description": "Get Moodle site information", + "description": "Get Moodle site information & user information", "tags": ["Core"], - "params": [] + "query_params": [] }, + { - "path": "/core/course/get-contents", - "method": "POST", - "function": "core_course_get_contents", - "name": "get_course_contents", + "path": "/core_course_get_contents", + "method": "GET", + "function": "core_course_get_contents", "description": "Get course contents (sections and activities)", "tags": ["Courses"], - "params": [ - {"name": "courseid", "type": "int", "required": true, "description": "Course ID"}, - {"name": "exclude_modules", "type": "bool", "required": false, "default": false, "description": "Exclude modules from response"}, - {"name": "exclude_contents", "type": "bool", "required": false, "default": false, "description": "Exclude file contents from response"} - ] - }, - { - "path": "/core/course/get-courses-by-field", - "method": "POST", + "query_params": [ + { + "name": "courseid", + "type": "int", + "required": true, + "description": "Course ID" + }, + { + "name": "exclude_modules", + "type": "bool", + "required": false, + "default": false, + "description": "Exclude modules from response" + }, + { + "name": "exclude_contents", + "type": "bool", + "required": false, + "default": false, + "description": "Exclude file contents from response" + } + ] + }, + + { + "path": "/core_course_search_courses", + "method": "GET", + "function": "core_course_search_courses", + "description": "Search courses by criteria", + "tags": ["Courses"], + "query_params": [ + { + "name": "criterianame", + "type": "str", + "required": false, + "default": "search", + "description": "Search criteria name" + }, + { + "name": "criteriavalue", + "type": "str", + "required": true, + "description": "Search term" + }, + { + "name": "page", + "type": "int", + "required": false, + "default": 0, + "description": "Page number" + }, + { + "name": "perpage", + "type": "int", + "required": false, + "default": 100, + "description": "Number of results per page" + }, + { + "name": "limittoenrolled", + "type": "int", + "required": false, + "default": 0, + "description": "Limit to enrolled courses only" + } + ] + }, + + { + "path": "/core_course_get_courses_by_field", + "method": "GET", "function": "core_course_get_courses_by_field", - "name": "get_courses_by_field", "description": "Get courses by field (id, shortname, fullname, etc.)", "tags": ["Courses"], - "params": [ - {"name": "field", "type": "str", "required": true, "description": "Field to search by"}, - {"name": "value", "type": "str", "required": true, "description": "Value to search for"} - ] - }, - { - "path": "/core/enrol/get-users-courses", - "method": "POST", - "function": "core_enrol_get_users_courses", - "name": "get_user_courses", - "description": "Get courses the user is enrolled in", - "tags": ["Enrollment"], - "params": [ - {"name": "userid", "type": "int", "required": false, "default": 0, "description": "User ID (0 for current user)"} - ] - }, - { - "path": "/core/enrol/get-enrolled-users", - "method": "POST", + "query_params": [ + { + "name": "field", + "type": "str", + "required": true, + "description": "Field to search by" + }, + { + "name": "value", + "type": "str", + "required": true, + "description": "Value to search for" + } + ] + }, + + { + "path": "/core_enrol_get_enrolled_users", + "method": "GET", "function": "core_enrol_get_enrolled_users", - "name": "get_enrolled_users", "description": "Get users enrolled in a course", "tags": ["Enrollment"], - "params": [ - {"name": "courseid", "type": "int", "required": true, "description": "Course ID"} - ] - }, - { - "path": "/core/user/get-users-by-field", - "method": "POST", - "function": "core_user_get_users_by_field", - "name": "get_users_by_field", - "description": "Get users by field (id, username, email, etc.)", - "tags": ["Users"], - "params": [ - {"name": "field", "type": "str", "required": true, "description": "Field to search by"}, - {"name": "values", "type": "list", "required": true, "description": "Values to search for"} - ] - }, - { - "path": "/mod/assign/get-assignments", - "method": "POST", - "function": "mod_assign_get_assignments", - "name": "get_assignments", - "description": "Get assignments from specified courses", - "tags": ["Assignments"], - "params": [ - {"name": "courseids", "type": "list", "required": true, "description": "List of course IDs"} - ] - }, - { - "path": "/core/grades/get-grades", - "method": "POST", - "function": "core_grades_get_grades", - "name": "get_grades", - "description": "Get grades for a course", - "tags": ["Grades"], - "params": [ - {"name": "courseid", "type": "int", "required": true, "description": "Course ID"}, - {"name": "userid", "type": "int", "required": false, "description": "User ID (optional)"} - ] - }, - { - "path": "/mod/forum/get-forums-by-courses", - "method": "POST", - "function": "mod_forum_get_forums_by_courses", - "name": "get_forums_by_courses", - "description": "Get forums in specified courses", - "tags": ["Forums"], - "params": [ - {"name": "courseids", "type": "list", "required": true, "description": "List of course IDs"} - ] - }, - { - "path": "/mod/quiz/get-quizzes-by-courses", - "method": "POST", - "function": "mod_quiz_get_quizzes_by_courses", - "name": "get_quizzes_by_courses", - "description": "Get quizzes in specified courses", - "tags": ["Quizzes"], - "params": [ - {"name": "courseids", "type": "list", "required": true, "description": "List of course IDs"} + "query_params": [ + { + "name": "courseid", + "type": "int", + "required": true, + "description": "Course ID" + } ] }, + { - "path": "/core/calendar/get-calendar-events", - "method": "POST", - "function": "core_calendar_get_calendar_events", - "name": "get_calendar_events", - "description": "Get calendar events", - "tags": ["Calendar"], - "params": [ - {"name": "courseid", "type": "int", "required": false, "description": "Course ID (optional)"} + "path": "/core_enrol_get_users_courses", + "method": "GET", + "function": "core_enrol_get_users_courses", + "description": "Get courses the user is enrolled in", + "tags": ["Enrollment"], + "query_params": [ + { + "name": "userid", + "type": "int", + "required": false, + "default": 0, + "description": "User ID (0 for current user)" + } ] }, + { - "path": "/core/message/get-contacts", - "method": "POST", - "function": "core_message_get_contacts", - "name": "get_message_contacts", - "description": "Get message contacts", - "tags": ["Messages"], - "params": [] - }, - { - "path": "/core/files/get-files", - "method": "POST", + "path": "/core_files_get_files", + "method": "GET", "function": "core_files_get_files", - "name": "get_files", "description": "Get files from specified context", "tags": ["Files"], - "params": [ - {"name": "contextid", "type": "int", "required": true, "description": "Context ID"}, - {"name": "component", "type": "str", "required": true, "description": "Component name"}, - {"name": "filearea", "type": "str", "required": true, "description": "File area"}, - {"name": "itemid", "type": "int", "required": false, "default": 0, "description": "Item ID"}, - {"name": "filepath", "type": "str", "required": false, "default": "/", "description": "File path"}, - {"name": "filename", "type": "str", "required": false, "default": "", "description": "File name"} - ] - }, - { - "path": "/core/course/search-courses", - "method": "POST", - "function": "core_course_search_courses", - "name": "search_courses", - "description": "Search courses by criteria", - "tags": ["Courses"], - "params": [ - {"name": "criterianame", "type": "str", "required": false, "default": "search", "description": "Search criteria name"}, - {"name": "criteriavalue", "type": "str", "required": true, "description": "Search term"}, - {"name": "page", "type": "int", "required": false, "default": 0, "description": "Page number"}, - {"name": "perpage", "type": "int", "required": false, "default": 100, "description": "Number of results per page"}, - {"name": "limittoenrolled", "type": "int", "required": false, "default": 0, "description": "Limit to enrolled courses only"} - ] - }, - { - "path": "/enrol/self/enrol-user", - "method": "POST", - "function": "enrol_self_enrol_user", - "name": "self_enrol_user", - "description": "Self-enrol user in a course", - "tags": ["Enrollment"], - "params": [ - {"name": "courseid", "type": "int", "required": true, "description": "Course ID"}, - {"name": "password", "type": "str", "required": false, "description": "Enrolment password if required"}, - {"name": "instanceid", "type": "int", "required": false, "description": "Enrolment instance ID"} - ] - }, - { - "path": "/core/user/get-course-user-profiles", - "method": "POST", + "query_params": [ + { + "name": "contextid", + "type": "int", + "required": true, + "description": "Context ID" + }, + { + "name": "component", + "type": "str", + "required": true, + "description": "Component name" + }, + { + "name": "filearea", + "type": "str", + "required": true, + "description": "File area" + }, + { + "name": "itemid", + "type": "int", + "required": false, + "default": 0, + "description": "Item ID" + }, + { + "name": "filepath", + "type": "str", + "required": false, + "default": "/", + "description": "File path" + }, + { + "name": "filename", + "type": "str", + "required": false, + "default": "", + "description": "File name" + } + ] + }, + + { + "path": "/core_message_get_messages", + "method": "GET", + "function": "core_message_get_messages", + "description": "Get messages", + "tags": ["Messages"], + "query_params": [ + { + "name": "useridto", + "type": "int", + "required": true, + "description": "User ID to (recipient)" + }, + { + "name": "useridfrom", + "type": "int", + "required": false, + "description": "User ID from (sender)" + }, + { + "name": "type", + "type": "str", + "required": false, + "default": "both", + "description": "Message type (sent, received, both)" + }, + { + "name": "read", + "type": "bool", + "required": false, + "description": "Read status" + }, + { + "name": "newestfirst", + "type": "bool", + "required": false, + "default": true, + "description": "Sort newest first" + }, + { + "name": "limitfrom", + "type": "int", + "required": false, + "default": 0, + "description": "Limit from" + }, + { + "name": "limitnum", + "type": "int", + "required": false, + "default": 100, + "description": "Number of messages" + } + ] + }, + + { + "path": "/core_user_get_course_user_profiles", + "method": "GET", "function": "core_user_get_course_user_profiles", - "name": "get_course_user_profiles", "description": "Get user profiles for users in a course", "tags": ["Users"], - "params": [ - {"name": "courseid", "type": "int", "required": true, "description": "Course ID"} + "query_params": [ + { + "name": "courseid", + "type": "int", + "required": true, + "description": "Course ID" + } ] }, + { - "path": "/core/user/get-user-preferences", - "method": "POST", + "path": "/core_user_get_users_by_field", + "method": "GET", + "function": "core_user_get_users_by_field", + "description": "Get users by field (id, username, email, etc.)", + "tags": ["Users"], + "query_params": [ + { + "name": "field", + "type": "str", + "required": true, + "description": "Field to search by" + }, + { + "name": "values", + "type": "list", + "required": true, + "description": "Values to search for" + } + ] + }, + + { + "path": "/core_user_get_user_preferences", + "method": "GET", "function": "core_user_get_user_preferences", - "name": "get_user_preferences", "description": "Get user preferences", "tags": ["Users"], - "params": [ - {"name": "userid", "type": "int", "required": false, "description": "User ID (optional)"}, - {"name": "name", "type": "str", "required": false, "description": "Preference name"} + "query_params": [ + { + "name": "userid", + "type": "int", + "required": false, + "description": "User ID (optional)" + }, + { + "name": "name", + "type": "str", + "required": false, + "description": "Preference name" + } + ] + }, + + { + "path": "/enrol_self_enrol_user", + "method": "GET", + "function": "enrol_self_enrol_user", + "description": "Self-enrol user in a course | unenrol by using function on enrolled course", + "tags": ["Enrollment"], + "query_params": [ + { + "name": "courseid", + "type": "int", + "required": true, + "description": "Course ID" + } ] }, + { - "path": "/mod/assign/get-submissions", - "method": "POST", + "path": "/gradereport_user_get_grade_items", + "method": "GET", + "function": "gradereport_user_get_grade_items", + "description": "Get grade items for a course", + "tags": ["Grades"], + "query_params": [ + { + "name": "courseid", + "type": "int", + "required": true, + "description": "Course ID" + }, + { + "name": "userid", + "type": "int", + "required": false, + "default": 0, + "description": "User ID" + } + ] + }, + + { + "path": "/mod_assign_get_assignments", + "method": "GET", + "function": "mod_assign_get_assignments", + "description": "Get assignments from specified courses", + "tags": ["Assignments"], + "query_params": [ + { + "name": "courseids", + "type": "list", + "required": true, + "description": "List of course IDs" + } + ] + }, + + { + "path": "/mod_assign_get_submissions", + "method": "GET", "function": "mod_assign_get_submissions", - "name": "get_assignment_submissions", "description": "Get assignment submissions", "tags": ["Assignments"], - "params": [ - {"name": "assignmentids", "type": "list", "required": true, "description": "Assignment IDs"} + "query_params": [ + { + "name": "assignmentids", + "type": "list", + "required": true, + "description": "Assignment IDs" + } ] }, + { - "path": "/mod/assign/get-submission-status", - "method": "POST", + "path": "/mod_assign_get_submission_status", + "method": "GET", "function": "mod_assign_get_submission_status", - "name": "get_assignment_submission_status", "description": "Get assignment submission status", "tags": ["Assignments"], - "params": [ - {"name": "assignid", "type": "int", "required": true, "description": "Assignment ID"}, - {"name": "userid", "type": "int", "required": false, "description": "User ID (optional)"} - ] - }, - { - "path": "/gradereport/user/get-grade-items", - "method": "POST", - "function": "gradereport_user_get_grade_items", - "name": "get_grade_items", - "description": "Get grade items for a course", - "tags": ["Grades"], - "params": [ - {"name": "courseid", "type": "int", "required": true, "description": "Course ID"}, - {"name": "userid", "type": "int", "required": false, "default": 0, "description": "User ID"} - ] - }, - { - "path": "/core/grades/get-grade-definitions", - "method": "POST", - "function": "core_grades_get_gradedefinitions", - "name": "get_grade_definitions_simple", - "description": "Get grade definitions including grade types, scales, and outcomes", - "tags": ["Grades"], - "params": [] - }, - { - "path": "/core/grading/get-definitions", - "method": "POST", - "function": "core_grading_get_definitions", - "name": "get_grading_definitions", - "description": "Get grading definitions for specific activities", - "tags": ["Grades"], - "params": [ - {"name": "cmids", "type": "list", "required": true, "description": "Course module IDs"}, - {"name": "areaname", "type": "str", "required": false, "default": "submission", "description": "Grading area name"} + "query_params": [ + { + "name": "assignid", + "type": "int", + "required": true, + "description": "Assignment ID" + }, + { + "name": "userid", + "type": "int", + "required": false, + "description": "User ID (optional)" + } + ] + }, + + { + "path": "/mod_forum_get_forums_by_courses", + "method": "GET", + "function": "mod_forum_get_forums_by_courses", + "description": "Get forums in specified courses", + "tags": ["Forums"], + "query_params": [ + { + "name": "courseids", + "type": "list", + "required": true, + "description": "List of course IDs" + } ] }, + { - "path": "/mod/forum/get-forum-discussions", - "method": "POST", + "path": "/mod_forum_get_forum_discussions", + "method": "GET", "function": "mod_forum_get_forum_discussions", - "name": "get_forum_discussions", "description": "Get discussions in a forum", "tags": ["Forums"], - "params": [ - {"name": "forumid", "type": "int", "required": true, "description": "Forum ID"} + "query_params": [ + { + "name": "forumid", + "type": "int", + "required": true, + "description": "Forum ID" + } ] }, + { - "path": "/mod/quiz/get-user-attempts", - "method": "POST", - "function": "mod_quiz_get_user_attempts", - "name": "get_quiz_attempts", - "description": "Get quiz attempts", - "tags": ["Quizzes"], - "params": [ - {"name": "quizid", "type": "int", "required": true, "description": "Quiz ID"}, - {"name": "userid", "type": "int", "required": false, "description": "User ID (optional)"} + "path": "/core_calendar_get_calendar_events", + "method": "GET", + "function": "core_calendar_get_calendar_events", + "description": "Get calendar events", + "tags": ["Calendar"], + "query_params": [ + { + "name": "courseid", + "type": "int", + "required": false, + "description": "Course ID (optional)" + } ] }, + { - "path": "/mod/quiz/get-quiz-access-information", - "method": "POST", - "function": "mod_quiz_get_quiz_access_information", - "name": "get_quiz_access_information", - "description": "Get quiz access information", + "path": "/core_calendar_get_action_events_by_course", + "method": "GET", + "function": "core_calendar_get_action_events_by_course", + "description": "Get action events (assignments, quizzes) by course", + "tags": ["Calendar"], + "query_params": [ + { + "name": "courseid", + "type": "int", + "required": true, + "description": "Course ID" + }, + { + "name": "timesortfrom", + "type": "int", + "required": false, + "description": "Time sort from (timestamp)" + }, + { + "name": "timesortto", + "type": "int", + "required": false, + "description": "Time sort to (timestamp)" + }, + { + "name": "aftereventid", + "type": "int", + "required": false, + "description": "After event ID" + }, + { + "name": "limitnum", + "type": "int", + "required": false, + "default": 20, + "description": "Number of events to return" + } + ] + }, + + { + "path": "/mod_quiz_get_quizzes_by_courses", + "method": "GET", + "function": "mod_quiz_get_quizzes_by_courses", + "description": "Get quizzes in specified courses", "tags": ["Quizzes"], - "params": [ - {"name": "quizid", "type": "int", "required": true, "description": "Quiz ID"} + "query_params": [ + { + "name": "courseids", + "type": "list", + "required": true, + "description": "List of course IDs" + } ] }, + { - "path": "/mod/quiz/start-attempt", - "method": "POST", - "function": "mod_quiz_start_attempt", - "name": "start_quiz_attempt", - "description": "Start a new quiz attempt", + "path": "/mod_quiz_get_quiz_access_information", + "method": "GET", + "function": "mod_quiz_get_quiz_access_information", + "description": "Get quiz access information", "tags": ["Quizzes"], - "params": [ - {"name": "quizid", "type": "int", "required": true, "description": "Quiz ID"}, - {"name": "preflightdata", "type": "list", "required": false, "default": [], "description": "Pre-flight check data"}, - {"name": "forcenew", "type": "bool", "required": false, "default": false, "description": "Force new attempt"} + "query_params": [ + { + "name": "quizid", + "type": "int", + "required": true, + "description": "Quiz ID" + } ] }, + { - "path": "/mod/quiz/get-attempt-data", - "method": "POST", + "path": "/mod_quiz_get_attempt_data", + "method": "GET", "function": "mod_quiz_get_attempt_data", - "name": "get_quiz_attempt_data", "description": "Get quiz attempt data including questions", "tags": ["Quizzes"], - "params": [ - {"name": "attemptid", "type": "int", "required": true, "description": "Attempt ID"}, - {"name": "page", "type": "int", "required": false, "default": 0, "description": "Page number (-1 for all pages)"}, - {"name": "preflightdata", "type": "list", "required": false, "default": [], "description": "Pre-flight check data"} - ] - }, - { - "path": "/mod/quiz/save-attempt", - "method": "POST", - "function": "mod_quiz_save_attempt", - "name": "save_quiz_attempt", - "description": "Save quiz attempt responses", + "query_params": [ + { + "name": "attemptid", + "type": "int", + "required": true, + "description": "Attempt ID" + }, + { + "name": "page", + "type": "int", + "required": false, + "default": 0, + "description": "Page number (-1 for all pages)" + }, + { + "name": "preflightdata", + "type": "list", + "required": false, + "default": [], + "description": "Pre-flight check data" + } + ] + }, + + { + "path": "/mod_quiz_get_attempt_summary", + "method": "GET", + "function": "mod_quiz_get_attempt_summary", + "description": "Get quiz attempt summary", "tags": ["Quizzes"], - "params": [ - {"name": "attemptid", "type": "int", "required": true, "description": "Attempt ID"}, - {"name": "data", "type": "list", "required": true, "description": "Question responses data"}, - {"name": "preflightdata", "type": "list", "required": false, "default": [], "description": "Pre-flight check data"} - ] - }, - { - "path": "/mod/quiz/process-attempt", - "method": "POST", + "query_params": [ + { + "name": "attemptid", + "type": "int", + "required": true, + "description": "Attempt ID" + }, + { + "name": "preflightdata", + "type": "list", + "required": false, + "default": [], + "description": "Pre-flight check data" + } + ] + }, + + { + "path": "/mod_quiz_get_user_attempts", + "method": "GET", + "function": "mod_quiz_get_user_attempts", + "description": "Get quiz attempts", + "tags": ["Quizzes"], + "query_params": [ + { + "name": "quizid", + "type": "int", + "required": true, + "description": "Quiz ID" + }, + { + "name": "userid", + "type": "int", + "required": false, + "description": "User ID (optional)" + } + ] + }, + + { + "path": "/mod_quiz_process_attempt", + "method": "GET", "function": "mod_quiz_process_attempt", - "name": "process_quiz_attempt", "description": "Process and optionally finish a quiz attempt", "tags": ["Quizzes"], - "params": [ - {"name": "attemptid", "type": "int", "required": true, "description": "Attempt ID"}, - {"name": "data", "type": "list", "required": false, "default": [], "description": "Question responses data"}, - {"name": "finishattempt", "type": "bool", "required": false, "default": false, "description": "Finish the attempt"}, - {"name": "timeup", "type": "bool", "required": false, "default": false, "description": "Time is up"}, - {"name": "preflightdata", "type": "list", "required": false, "default": [], "description": "Pre-flight check data"} - ] - }, - { - "path": "/mod/quiz/get-attempt-summary", - "method": "POST", - "function": "mod_quiz_get_attempt_summary", - "name": "get_quiz_attempt_summary", - "description": "Get quiz attempt summary", + "query_params": [ + { + "name": "attemptid", + "type": "int", + "required": true, + "description": "Attempt ID" + }, + { + "name": "data", + "type": "list", + "required": false, + "default": [], + "description": "Question responses data" + }, + { + "name": "finishattempt", + "type": "bool", + "required": false, + "default": false, + "description": "Finish the attempt" + }, + { + "name": "timeup", + "type": "bool", + "required": false, + "default": false, + "description": "Time is up" + }, + { + "name": "preflightdata", + "type": "list", + "required": false, + "default": [], + "description": "Pre-flight check data" + } + ] + }, + + { + "path": "/mod_quiz_save_attempt", + "method": "GET", + "function": "mod_quiz_save_attempt", + "description": "Save quiz attempt responses", "tags": ["Quizzes"], - "params": [ - {"name": "attemptid", "type": "int", "required": true, "description": "Attempt ID"}, - {"name": "preflightdata", "type": "list", "required": false, "default": [], "description": "Pre-flight check data"} - ] - }, - { - "path": "/core/calendar/get-action-events-by-course", - "method": "POST", - "function": "core_calendar_get_action_events_by_course", - "name": "get_action_events_by_course", - "description": "Get action events (assignments, quizzes) by course", - "tags": ["Calendar"], - "params": [ - {"name": "courseid", "type": "int", "required": true, "description": "Course ID"}, - {"name": "timesortfrom", "type": "int", "required": false, "description": "Time sort from (timestamp)"}, - {"name": "timesortto", "type": "int", "required": false, "description": "Time sort to (timestamp)"}, - {"name": "aftereventid", "type": "int", "required": false, "description": "After event ID"}, - {"name": "limitnum", "type": "int", "required": false, "default": 20, "description": "Number of events to return"} - ] - }, - { - "path": "/core/message/get-messages", - "method": "POST", - "function": "core_message_get_messages", - "name": "get_messages", - "description": "Get messages", - "tags": ["Messages"], - "params": [ - {"name": "useridto", "type": "int", "required": true, "description": "User ID to (recipient)"}, - {"name": "useridfrom", "type": "int", "required": false, "description": "User ID from (sender)"}, - {"name": "type", "type": "str", "required": false, "default": "both", "description": "Message type (sent, received, both)"}, - {"name": "read", "type": "bool", "required": false, "description": "Read status"}, - {"name": "newestfirst", "type": "bool", "required": false, "default": true, "description": "Sort newest first"}, - {"name": "limitfrom", "type": "int", "required": false, "default": 0, "description": "Limit from"}, - {"name": "limitnum", "type": "int", "required": false, "default": 100, "description": "Number of messages"} - ] - }, - { - "path": "/tool/lp/get-user-competencies-in-course", - "method": "POST", - "function": "tool_lp_get_user_competencies_in_course", - "name": "get_user_competencies_in_course", - "description": "Get user competencies in a course", - "tags": ["Competencies"], - "params": [ - {"name": "courseid", "type": "int", "required": true, "description": "Course ID"} - ] - }, - { - "path": "/tool/lp/get-learning-plans", - "method": "POST", - "function": "tool_lp_get_learning_plans", - "name": "get_learning_plans", - "description": "Get user learning plans", - "tags": ["Competencies"], - "params": [ - {"name": "userid", "type": "int", "required": true, "description": "User ID"} - ] - }, - { - "path": "/call", - "method": "POST", - "function": "universal", - "name": "call_any_function", - "description": "Call any Moodle web service function with custom parameters", - "tags": ["Universal"], - "params": [ - {"name": "function_name", "type": "str", "required": true, "description": "Moodle web service function name"}, - {"name": "parameters", "type": "dict", "required": false, "default": {}, "description": "Function parameters as key-value pairs"} + "query_params": [ + { + "name": "attemptid", + "type": "int", + "required": true, + "description": "Attempt ID" + }, + { + "name": "data", + "type": "list", + "required": true, + "description": "Question responses data" + }, + { + "name": "preflightdata", + "type": "list", + "required": false, + "default": [], + "description": "Pre-flight check data" + } + ] + }, + + { + "path": "/mod_quiz_start_attempt", + "method": "GET", + "function": "mod_quiz_start_attempt", + "description": "Start a new quiz attempt", + "tags": ["Quizzes"], + "query_params": [ + { + "name": "quizid", + "type": "int", + "required": true, + "description": "Quiz ID" + }, + { + "name": "preflightdata", + "type": "list", + "required": false, + "default": [], + "description": "Pre-flight check data" + }, + { + "name": "forcenew", + "type": "bool", + "required": false, + "default": false, + "description": "Force new attempt" + } ] } ] -} +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9e60c38..925b3c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ -fastapi==0.116.1 -httpx>=0.25.0 -pydantic>=2.4.0 -uvicorn==0.35.0 -colorlog==6.9.0 \ No newline at end of file +fastapi +httpx +pydantic +uvicorn +colorlog +python-dotenv \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index c8d94c7..0000000 --- a/src/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Source code root diff --git a/src/api/app.py b/src/api/app.py deleted file mode 100644 index 571ed9c..0000000 --- a/src/api/app.py +++ /dev/null @@ -1,58 +0,0 @@ -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -import json -from pathlib import Path -from ..application.services.moodle_service import MoodleService -from ..application.utils import MoodleResponse, create_param_model - -# Load configuration -config_path = Path(__file__).parent.parent.parent / "config.json" -with open(config_path, 'r') as f: - config = json.load(f) - -# FastAPI app -app = FastAPI( - title="MoodlewareAPI", - description="Easily interact with Moodle API", - version="0.0.1", - docs_url="/", - redoc_url="/redoc" -) - -# Add CORS middleware for browser compatibility -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -def create_endpoint_handler(endpoint_config, param_model): - """Create endpoint handler function""" - async def endpoint_handler(params): - if endpoint_config["function"] == "auth": - return await MoodleService.handle_auth(params) - elif endpoint_config["function"] == "universal": - return await MoodleService.handle_universal(params) - else: - return await MoodleService.handle_regular_endpoint(params, endpoint_config["function"], endpoint_config) - - # Set the parameter annotation - endpoint_handler.__annotations__ = {"params": param_model, "return": MoodleResponse} - return endpoint_handler - -# Dynamically create endpoints -for endpoint_config in config["endpoints"]: - param_model = create_param_model(endpoint_config) - handler = create_endpoint_handler(endpoint_config, param_model) - - # Register the endpoint with FastAPI - app.add_api_route( - endpoint_config["path"], - handler, - methods=[endpoint_config["method"]], - response_model=MoodleResponse, - tags=endpoint_config["tags"], - summary=endpoint_config["description"] - ) diff --git a/src/api/app_backup.py b/src/api/app_backup.py deleted file mode 100644 index 23c3479..0000000 --- a/src/api/app_backup.py +++ /dev/null @@ -1,243 +0,0 @@ -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -import json -from pathlib import Path -from ..application.services.moodle_service import MoodleService -from ..application.utils import MoodleResponse, create_param_model - -# Load configuration -config_path = Path(__file__).parent.parent.parent / "config.json" -with open(config_path, 'r') as f: - config = json.load(f) - -# FastAPI app -app = FastAPI( - title="MoodlewareAPI", - description="Easily interact with Moodle API", - version="0.0.1", - docs_url="/", - redoc_url="/redoc" -) - -# Add CORS middleware for browser compatibility -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -class MoodleResponse(BaseModel): - success: bool - data: Any - function_name: str - execution_time_ms: float - timestamp: datetime - -# Moodle client -class MoodleClient: - def __init__(self, base_url: str, token: str): - self.base_url = base_url.rstrip('/') - self.token = token - self.ws_endpoint = f"{self.base_url}/webservice/rest/server.php" - - async def call_function(self, function_name: str, parameters: Dict[str, Any] = None): - if parameters is None: - parameters = {} - - data = { - "wstoken": self.token, - "wsfunction": function_name, - "moodlewsrestformat": "json" - } - - self._add_parameters(data, parameters) - start_time = datetime.now() - - async with httpx.AsyncClient(timeout=60.0) as client: - try: - response = await client.post(self.ws_endpoint, data=data) - response.raise_for_status() - - end_time = datetime.now() - execution_time = (end_time - start_time).total_seconds() * 1000 - - result = response.json() - - if isinstance(result, dict) and "exception" in result: - raise HTTPException( - status_code=400, - detail=f"Moodle error: {result.get('message', 'Unknown error')}" - ) - - return { - "success": True, - "data": result, - "function_name": function_name, - "execution_time_ms": execution_time, - "timestamp": start_time - } - - except httpx.HTTPStatusError as e: - raise HTTPException(status_code=503, detail=f"HTTP error: {e.response.status_code}") - except httpx.RequestError: - raise HTTPException(status_code=503, detail="Unable to connect to Moodle server") - - def _add_parameters(self, data: Dict[str, Any], parameters: Dict[str, Any], prefix: str = ""): - for key, value in parameters.items(): - param_name = f"{prefix}[{key}]" if prefix else key - - if isinstance(value, dict): - self._add_parameters(data, value, param_name) - elif isinstance(value, list): - for i, item in enumerate(value): - if isinstance(item, dict): - self._add_parameters(data, item, f"{param_name}[{i}]") - else: - data[f"{param_name}[{i}]"] = str(item) - else: - data[param_name] = str(value) - -# Helper functions -def get_python_type(type_str: str): - """Convert string type to Python type""" - type_map = { - "str": str, - "int": int, - "bool": bool, - "list": List[str], - "dict": Dict[str, Any] - } - return type_map.get(type_str, str) - -def create_param_model(endpoint_config: Dict[str, Any]): - """Dynamically create Pydantic model for endpoint parameters""" - fields = {} - - # Always include moodle credentials unless it's auth endpoint - if endpoint_config["function"] != "auth": - fields["moodle_url"] = (str, Field(..., description="Moodle site URL")) - fields["token"] = (str, Field(..., description="Moodle API token")) - - # Add endpoint-specific parameters - for param in endpoint_config["params"]: - python_type = get_python_type(param["type"]) - field_description = param.get("description", f"Parameter {param['name']}") - - if param["required"]: - field_def = Field(..., description=field_description) - fields[param["name"]] = (python_type, field_def) - else: - default_value = param.get("default") - field_def = Field(default_value, description=field_description) - fields[param["name"]] = (Optional[python_type], field_def) - - return create_model(f"{endpoint_config['name']}_params", **fields) - -async def handle_auth(params): - """Handle authentication endpoint""" - login_url = f"{params.moodle_url}/login/token.php" - - async with httpx.AsyncClient(timeout=30.0) as client: - try: - response = await client.post(login_url, data={ - "username": params.username, - "password": params.password, - "service": params.service - }) - response.raise_for_status() - - data = response.json() - if "token" not in data: - raise HTTPException(status_code=401, detail=f"Authentication failed: {data.get('error', 'Unknown error')}") - - token = data["token"] - - # Get site info - client_obj = MoodleClient(params.moodle_url, token) - site_info_response = await client_obj.call_function("core_webservice_get_site_info") - - return { - "success": True, - "data": { - "token": token, - "moodle_url": params.moodle_url, - "site_info": site_info_response["data"] - }, - "function_name": "authentication", - "execution_time_ms": 0, - "timestamp": datetime.now() - } - - except httpx.HTTPStatusError: - raise HTTPException(status_code=401, detail="Failed to connect to Moodle server") - except httpx.RequestError: - raise HTTPException(status_code=503, detail="Unable to connect to Moodle server") - -async def handle_universal(params): - """Handle universal endpoint""" - client = MoodleClient(params.moodle_url, params.token) - return await client.call_function(params.function_name, params.parameters) - -async def handle_regular_endpoint(params, function_name: str, endpoint_config: Dict[str, Any]): - """Handle regular Moodle API endpoints""" - client = MoodleClient(params.moodle_url, params.token) - - # Extract parameters (exclude moodle_url and token) - moodle_params = {} - for param in endpoint_config["params"]: - if hasattr(params, param["name"]): - value = getattr(params, param["name"]) - if value is not None: - moodle_params[param["name"]] = value - - # Handle special parameter transformations - if function_name == "core_course_get_contents": - if "exclude_modules" in moodle_params or "exclude_contents" in moodle_params: - options = [] - if moodle_params.get("exclude_modules"): - options.append({"name": "excludemodules", "value": 1}) - if moodle_params.get("exclude_contents"): - options.append({"name": "excludecontents", "value": 1}) - moodle_params = {"courseid": moodle_params["courseid"]} - if options: - moodle_params["options"] = options - - elif function_name == "core_calendar_get_calendar_events": - if "courseid" in moodle_params and moodle_params["courseid"]: - moodle_params = {"events": [{"courseid": moodle_params["courseid"]}]} - else: - moodle_params = {} - - return await client.call_function(function_name, moodle_params) - -# Dynamically create endpoints -for endpoint_config in config["endpoints"]: - param_model = create_param_model(endpoint_config) - - def create_endpoint_handler(config=endpoint_config, model=param_model): - async def endpoint_handler(params): - if config["function"] == "auth": - return await handle_auth(params) - elif config["function"] == "universal": - return await handle_universal(params) - else: - return await handle_regular_endpoint(params, config["function"], config) - - # Set the parameter annotation - endpoint_handler.__annotations__ = {"params": model, "return": MoodleResponse} - return endpoint_handler - - # Create the endpoint - handler = create_endpoint_handler() - - # Register the endpoint with FastAPI - app.add_api_route( - endpoint_config["path"], - handler, - methods=[endpoint_config["method"]], - response_model=MoodleResponse, - tags=endpoint_config["tags"], - summary=endpoint_config["description"] - ) diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..2ba7a35 --- /dev/null +++ b/src/app.py @@ -0,0 +1,48 @@ +import os +import json +from pathlib import Path +from fastapi import FastAPI +from dotenv import load_dotenv +from fastapi.middleware.cors import CORSMiddleware +from .utils import get_env_variable, load_config, create_handler + +# Load environment variables from .env file +load_dotenv() + + +# FastAPI app +app = FastAPI( + title="MoodlewareAPI", + description="A FastAPI application to wrap Moodle API functions into individual endpoints.", + version="0.1.0", + docs_url="/", + redoc_url=None +) + +# Add CORS middleware for browser compatibility +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +config = load_config("config.json") + +# Dynamically create endpoints +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']}") + + # Register the endpoint with FastAPI + app.add_api_route( + path=function["path"], + endpoint=create_handler(function, endpoint_path), + methods=[function["method"].upper()], + # response_model={WIP}, + tags=function["tags"], + summary=function["description"], + responses=function.get("responses") + ) \ No newline at end of file diff --git a/src/application/__init__.py b/src/application/__init__.py deleted file mode 100644 index 8ac7bd8..0000000 --- a/src/application/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Application layer: use cases and service interfaces diff --git a/src/application/models/moodle_credentials.py b/src/application/models/moodle_credentials.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/application/services/__init__.py b/src/application/services/__init__.py deleted file mode 100644 index 8ac7bd8..0000000 --- a/src/application/services/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Application layer: use cases and service interfaces diff --git a/src/application/services/moodle_request_service.py b/src/application/services/moodle_request_service.py deleted file mode 100644 index d0d95d2..0000000 --- a/src/application/services/moodle_request_service.py +++ /dev/null @@ -1,100 +0,0 @@ -from typing import Dict, Any, Optional -import httpx -from datetime import datetime -from fastapi import HTTPException - - -class MoodleRequestService: - """Low-level HTTP client for making requests to Moodle API""" - - def __init__(self, base_url: str, token: str): - self.base_url = base_url.rstrip('/') - self.token = token - self.ws_endpoint = f"{self.base_url}/webservice/rest/server.php" - - async def call_function(self, function_name: str, parameters: Optional[Dict[str, Any]] = None): - """Make a request to Moodle webservice API""" - if parameters is None: - parameters = {} - - data = { - "wstoken": self.token, - "wsfunction": function_name, - "moodlewsrestformat": "json" - } - - self._add_parameters(data, parameters) - start_time = datetime.now() - - async with httpx.AsyncClient(timeout=60.0) as client: - try: - response = await client.post(self.ws_endpoint, data=data) - response.raise_for_status() - - end_time = datetime.now() - execution_time = (end_time - start_time).total_seconds() * 1000 - - result = response.json() - - if isinstance(result, dict) and "exception" in result: - raise HTTPException( - status_code=400, - detail=f"Moodle error: {result.get('message', 'Unknown error')}" - ) - - return { - "success": True, - "data": result, - "function_name": function_name, - "execution_time_ms": execution_time, - "timestamp": start_time - } - - except httpx.HTTPStatusError as e: - raise HTTPException(status_code=503, detail=f"HTTP error: {e.response.status_code}") - except httpx.RequestError: - raise HTTPException(status_code=503, detail="Unable to connect to Moodle server") - - def _add_parameters(self, data: Dict[str, Any], parameters: Dict[str, Any], prefix: str = ""): - """Recursively add parameters to request data with proper Moodle formatting""" - for key, value in parameters.items(): - param_name = f"{prefix}[{key}]" if prefix else key - - if isinstance(value, dict): - self._add_parameters(data, value, param_name) - elif isinstance(value, list): - for i, item in enumerate(value): - if isinstance(item, dict): - self._add_parameters(data, item, f"{param_name}[{i}]") - else: - data[f"{param_name}[{i}]"] = str(item) - else: - data[param_name] = str(value) - - @staticmethod - async def authenticate(moodle_url: str, username: str, password: str, service: str = "moodle_mobile_app"): - """Authenticate with Moodle and get token""" - login_url = f"{moodle_url}/login/token.php" - - async with httpx.AsyncClient(timeout=30.0) as client: - try: - response = await client.post(login_url, data={ - "username": username, - "password": password, - "service": service - }) - response.raise_for_status() - - data = response.json() - if "token" not in data: - raise HTTPException( - status_code=401, - detail=f"Authentication failed: {data.get('error', 'Unknown error')}" - ) - - return data["token"] - - except httpx.HTTPStatusError: - raise HTTPException(status_code=401, detail="Failed to connect to Moodle server") - except httpx.RequestError: - raise HTTPException(status_code=503, detail="Unable to connect to Moodle server") \ No newline at end of file diff --git a/src/application/services/moodle_service.py b/src/application/services/moodle_service.py deleted file mode 100644 index 54ca155..0000000 --- a/src/application/services/moodle_service.py +++ /dev/null @@ -1,81 +0,0 @@ -from typing import Dict, Any -from datetime import datetime -from .moodle_request_service import MoodleRequestService - - -class MoodleService: - """High-level business logic for Moodle API operations""" - - @staticmethod - async def handle_auth(params): - """Handle authentication endpoint""" - token = await MoodleRequestService.authenticate( - params.moodle_url, - params.username, - params.password, - params.service - ) - - # Get site info - client = MoodleRequestService(params.moodle_url, token) - site_info_response = await client.call_function("core_webservice_get_site_info") - - return { - "success": True, - "data": { - "token": token, - "moodle_url": params.moodle_url, - "site_info": site_info_response["data"] - }, - "function_name": "authentication", - "execution_time_ms": 0, - "timestamp": datetime.now() - } - - @staticmethod - async def handle_universal(params): - """Handle universal endpoint""" - client = MoodleRequestService(params.moodle_url, params.token) - return await client.call_function(params.function_name, params.parameters) - - @staticmethod - async def handle_regular_endpoint(params, function_name: str, endpoint_config: Dict[str, Any]): - """Handle regular Moodle API endpoints""" - client = MoodleRequestService(params.moodle_url, params.token) - - # Extract parameters (exclude moodle_url and token) - moodle_params = {} - for param in endpoint_config["params"]: - if hasattr(params, param["name"]): - value = getattr(params, param["name"]) - if value is not None: - moodle_params[param["name"]] = value - - # Handle special parameter transformations - moodle_params = MoodleService._transform_parameters(function_name, moodle_params) - - return await client.call_function(function_name, moodle_params) - - @staticmethod - def _transform_parameters(function_name: str, moodle_params: Dict[str, Any]) -> Dict[str, Any]: - """Transform parameters for specific Moodle functions""" - if function_name == "core_course_get_contents": - if "exclude_modules" in moodle_params or "exclude_contents" in moodle_params: - options = [] - if moodle_params.get("exclude_modules"): - options.append({"name": "excludemodules", "value": 1}) - if moodle_params.get("exclude_contents"): - options.append({"name": "excludecontents", "value": 1}) - - result = {"courseid": moodle_params["courseid"]} - if options: - result["options"] = options - return result - - elif function_name == "core_calendar_get_calendar_events": - if "courseid" in moodle_params and moodle_params["courseid"]: - return {"events": [{"courseid": moodle_params["courseid"]}]} - else: - return {} - - return moodle_params \ No newline at end of file diff --git a/src/application/utils.py b/src/application/utils.py deleted file mode 100644 index cf755e5..0000000 --- a/src/application/utils.py +++ /dev/null @@ -1,48 +0,0 @@ -from pydantic import BaseModel, Field, create_model -from typing import Dict, Any, List, Optional, Union -from datetime import datetime - - -class MoodleResponse(BaseModel): - success: bool - data: Any - function_name: str - execution_time_ms: float - timestamp: datetime - - -def get_python_type(type_str: str): - """Convert string type to Python type""" - type_map = { - "str": str, - "int": int, - "bool": bool, - "list": List[str], - "dict": Dict[str, Any] - } - return type_map.get(type_str, str) - - -def create_param_model(endpoint_config: Dict[str, Any]): - """Dynamically create Pydantic model for endpoint parameters""" - fields = {} - - # Always include moodle credentials unless it's auth endpoint - if endpoint_config["function"] != "auth": - fields["moodle_url"] = (str, Field(..., description="Moodle site URL")) - fields["token"] = (str, Field(..., description="Moodle API token")) - - # Add endpoint-specific parameters - for param in endpoint_config["params"]: - python_type = get_python_type(param["type"]) - field_description = param.get("description", f"Parameter {param['name']}") - - if param["required"]: - field_def = Field(..., description=field_description) - fields[param["name"]] = (python_type, field_def) - else: - default_value = param.get("default") - field_def = Field(default_value, description=field_description) - fields[param["name"]] = (Optional[python_type], field_def) - - return create_model(f"{endpoint_config['name']}_params", **fields) diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..50981a3 --- /dev/null +++ b/src/utils.py @@ -0,0 +1,166 @@ +import os +import json +from pydantic import BaseModel, Field +from fastapi import Query, HTTPException, Response +from typing import Optional +import httpx +from urllib.parse import urlencode + +# Environment Variable Retrieval +def get_env_variable(var_name: str) -> str: + """Retrieves an environment variable""" + 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 + + +# Load configuration +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) + + +def create_handler(function_config, endpoint_path: str): + """Create a handler function for the Moodle function with dynamic parameters and perform the actual request.""" + # Get query parameters from config + query_params = function_config.get("query_params", []) + method = function_config.get("method", "GET").upper() + + async def handler(response: Response, **kwargs): + # Resolve Moodle base URL: env var takes precedence, else require query param when not set + 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.") + + # Ensure URL has a scheme to avoid implicit redirects (default to https) + if not base_url.lower().startswith(("http://", "https://")): + base_url = f"https://{base_url}" + + # Normalize URL join + ep_path = endpoint_path if endpoint_path.startswith("/") else f"/{endpoint_path}" + url = f"{base_url.rstrip('/')}{ep_path}" + + # Extract the actual parameter values (only those declared in config) + params = {} + for param in query_params: + param_name = param["name"] + if param_name in kwargs and kwargs[param_name] is not None: + params[param_name] = kwargs[param_name] + + # If caller provided a token (for REST endpoints), include it even if not declared in config + if "wstoken" in kwargs and kwargs["wstoken"] is not None: + params["wstoken"] = kwargs["wstoken"] + + # For REST server calls, ensure wsfunction is set to the actual function name + if ep_path.endswith("/webservice/rest/server.php"): + # Do not override if user explicitly provided one + params.setdefault("wsfunction", function_config.get("function")) + # Ensure JSON responses by default + params.setdefault("moodlewsrestformat", "json") + + # Expose the exact direct Moodle URL as a response header + 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 + + # Perform request to Moodle + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(15.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 commonly expects form-encoded data + 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) + + # Raise for non-2xx to surface proper status downstream + resp.raise_for_status() + + # Try to return JSON, fallback to text + try: + return resp.json() + except ValueError: + return resp.text + except httpx.HTTPStatusError as e: + # Try to include server-provided error body + 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)}") + + # Dynamically add parameter annotations to the handler + import inspect + sig_params = [ + inspect.Parameter( + "response", + inspect.Parameter.POSITIONAL_OR_KEYWORD, + annotation=Response + ) + ] + + # Add Moodle URL parameter if not set in environment + 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'.") + ) + ) + + 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") + ) + ) + + for param in query_params: + param_name = param["name"] + param_type = str if param["type"] == "str" else int if param["type"] == "int" else str + + if param["required"]: + # Required parameter + sig_params.append( + inspect.Parameter( + param_name, + inspect.Parameter.KEYWORD_ONLY, + annotation=param_type, + default=Query(..., description=param["description"]) + ) + ) + else: + # Optional parameter with default + default_value = param.get("default", None) + sig_params.append( + inspect.Parameter( + param_name, + inspect.Parameter.KEYWORD_ONLY, + annotation=Optional[param_type], + default=Query(default_value, description=param["description"]) + ) + ) + + # Create new signature and apply it to the handler + new_sig = inspect.Signature(sig_params) + handler.__signature__ = new_sig + + return handler \ No newline at end of file