diff --git a/.upgradenotes/MDL-81031-2024071905193761.yml b/.upgradenotes/MDL-81031-2024071905193761.yml
new file mode 100644
index 00000000000..3573257665f
--- /dev/null
+++ b/.upgradenotes/MDL-81031-2024071905193761.yml
@@ -0,0 +1,13 @@
+issueNumber: MDL-81031
+notes:
+ core:
+ - message: |
+ The following classes have been renamed.
+ Existing classes are currently unaffected.
+
+ | Old class name | New class name |
+
+ | --- | --- |
+
+ | `\core_user` | `\core\user` |
+ type: improved
diff --git a/admin/settings/development.php b/admin/settings/development.php
index a588b5eb24e..15aa7a28d6a 100644
--- a/admin/settings/development.php
+++ b/admin/settings/development.php
@@ -109,7 +109,16 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
// Add the 'profiling' page to admin block.
$ADMIN->add('development', $temp);
- // Web service test clients DO NOT COMMIT : THE EXTERNAL WEB PAGE IS NOT AN ADMIN PAGE !!!!!
+ $ADMIN->add(
+ parentname: 'development',
+ something: new admin_externalpage(
+ name: 'swaggerui',
+ visiblename: new lang_string('swaggerui', 'admin'),
+ url: "$CFG->wwwroot/admin/swaggerui.php",
+ ),
+ );
+
+ // Web service test clients DO NOT COMMIT : THE EXTERNAL WEB PAGE IS NOT AN ADMIN PAGE !!!!!
$ADMIN->add('development', new admin_externalpage('testclient', new lang_string('testclient', 'webservice'), "$CFG->wwwroot/$CFG->admin/webservice/testclient.php"));
diff --git a/admin/swaggerui.php b/admin/swaggerui.php
new file mode 100644
index 00000000000..45a66d1470d
--- /dev/null
+++ b/admin/swaggerui.php
@@ -0,0 +1,87 @@
+.
+
+/**
+ * Swagger UI for Moodle
+ *
+ * @package core_admin
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require('../config.php');
+require_once($CFG->libdir . '/adminlib.php');
+
+$swaggerversion = '5.17.14';
+
+$PAGE->set_url('/admin/swaggerui.php');
+
+admin_externalpage_setup('swaggerui');
+
+$PAGE->requires->css(new moodle_url("https://unpkg.com/swagger-ui-dist@{$swaggerversion}/swagger-ui.css"));
+
+echo $OUTPUT->header();
+
+// These have to be manually added for now because they must be made cross-origin. The `js` method does not yet support this.
+echo html_writer::tag(
+ tagname: 'script',
+ contents: '',
+ attributes: [
+ 'src' => new moodle_url("https://unpkg.com/swagger-ui-dist@{$swaggerversion}/swagger-ui-bundle.js"),
+ 'crossorigin' => 'crossorigin',
+ ],
+);
+echo html_writer::tag(
+ tagname: 'script',
+ contents: '',
+ attributes: [
+ 'src' => new moodle_url("https://unpkg.com/swagger-ui-plugin-hierarchical-tags"),
+ 'crossorigin' => 'crossorigin',
+ ],
+);
+
+$openapipath = moodle_url::routed_path('/api/rest/v2/openapi.json')->out();
+$swaggerinit = <<requires->js_init_code(
+ jscode: $swaggerinit,
+ ondomready: true,
+);
+
+echo html_writer::div('', '', [
+ 'id' => 'swagger-ui',
+]);
+
+echo $OUTPUT->footer();
diff --git a/config-dist.php b/config-dist.php
index 5e0356c2c53..c0fb1163afb 100644
--- a/config-dist.php
+++ b/config-dist.php
@@ -180,6 +180,14 @@ $CFG->wwwroot = 'http://example.com/moodle';
$CFG->dataroot = '/home/example/moodledata';
+// Whether the Moodle router is fully configured.
+//
+// From Moodle 4.5 this is set to false.
+// The default value will change in a future release.
+//
+// When not configured on the web server it must be accessed via https://example.com/moodle/r.php
+// When configured the on the web server the 'r.php' may be removed.
+$CFG->routerconfigured = false;
//=========================================================================
// 4. DATA FILES PERMISSIONS
diff --git a/lang/en/admin.php b/lang/en/admin.php
index 47fe143a29c..c017f789529 100644
--- a/lang/en/admin.php
+++ b/lang/en/admin.php
@@ -1354,6 +1354,7 @@ $string['supportemailsubject'] = 'Site support request - {$a}';
$string['supportavailability'] = 'Support availability';
$string['supportname'] = 'Support name';
$string['supportpage'] = 'Contact site support link';
+$string['swaggerui'] = 'Moodle REST API UI (SwaggerUI)';
$string['suspenduser'] = 'Suspend user account';
$string['switchlang'] = 'Switch lang directory';
$string['systempaths'] = 'System paths';
diff --git a/lang/en/cache.php b/lang/en/cache.php
index 6c7dd1abbdb..ca008b15895 100644
--- a/lang/en/cache.php
+++ b/lang/en/cache.php
@@ -63,6 +63,7 @@ $string['cachedef_eventinvalidation'] = 'Event invalidation';
$string['cachedef_externalbadges'] = 'External badges for particular user';
$string['cachedef_fontawesomeiconmapping'] = 'Mapping of icons for font awesome';
$string['cachedef_file_imageinfo'] = 'File image info e.g. dimensions';
+$string['cachedef_routes'] = 'Route data';
$string['cachedef_suspended_userids'] = 'List of suspended users per course';
$string['cachedef_groupdata'] = 'Course group information';
$string['cachedef_h5p_content_type_translations'] = 'H5P content-type libraries translations';
diff --git a/lang/en/error.php b/lang/en/error.php
index d5c3234420a..fd4d40da25e 100644
--- a/lang/en/error.php
+++ b/lang/en/error.php
@@ -390,6 +390,7 @@ $string['invalidxmlfile'] = '"{$a}" is not a valid XML file';
$string['iplookupfailed'] = 'Cannot find geo information about this IP address {$a}';
$string['iplookupprivate'] = 'Cannot display lookup of private IP address';
$string['ipmismatch'] = 'Client IP address mismatch';
+$string['itemnotfound'] = 'No {$a->itemtype} was found with an identifier of \'{$a->identifier}\'';
$string['listcantmovedown'] = 'Failed to move item down, as it is the last of its peers.';
$string['listcantmoveleft'] = 'Failed to move item left, as it has no parent';
$string['listcantmoveright'] = 'Failed to move item right, as there is no peer to make it a child of. Move it below another peer and then you can move it right.';
diff --git a/lib/amd/build/fetch.min.js b/lib/amd/build/fetch.min.js
new file mode 100644
index 00000000000..e90b107327f
--- /dev/null
+++ b/lib/amd/build/fetch.min.js
@@ -0,0 +1,10 @@
+define("core/fetch",["exports","core/config","./pending"],(function(_exports,_config,_pending){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
+/**
+ * The core/fetch module allows you to make web service requests to the Moodle API.
+ *
+ * @module core/fetch
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.request=_exports.performPut=_exports.performPost=_exports.performHead=_exports.performGet=_exports.performDelete=void 0,_config=_interopRequireDefault(_config),_pending=_interopRequireDefault(_pending);const normaliseComponent=component=>component.replace(/^core_/,""),getRequest=(component,endpoint,_ref)=>{let{params:params={},body:body=null,method:method="GET"}=_ref;const url=new URL("".concat(_config.default.apibase,"rest/v2/").concat(component,"/").concat(endpoint)),options={method:method,headers:{Accept:"application/json","Content-Type":"application/json"}};return Object.entries(params).forEach((_ref2=>{let[key,value]=_ref2;url.searchParams.append(key,value)})),body&&(body instanceof FormData?options.body=body:options.body=body instanceof Object?JSON.stringify(body):body),new Request(url,options)},request=async function(component,action){let{params:params={},body:body=null,method:method="GET"}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};const pending=new _pending.default("Requesting ".concat(component,"/").concat(action," with ").concat(method)),result=await fetch(getRequest(normaliseComponent(component),action,{params:params,method:method,body:body}));if(pending.resolve(),result.ok)return result.json();throw new Error(result.statusText)};_exports.request=request;_exports.performGet=function(component,action){let{params:params={}}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return request(component,action,{params:params,method:"GET"})};_exports.performHead=function(component,action){let{params:params={}}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return request(component,action,{params:params,method:"HEAD"})};_exports.performPost=function(component,action){let{body:body}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return request(component,action,{body:body,method:"POST"})};_exports.performPut=function(component,action){let{body:body}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return request(component,action,{body:body,method:"POST"})};_exports.performDelete=function(component,action){let{params:params={},body:body=null}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return request(component,action,{body:body,params:params,method:"DELETE"})}}));
+
+//# sourceMappingURL=fetch.min.js.map
\ No newline at end of file
diff --git a/lib/amd/build/fetch.min.js.map b/lib/amd/build/fetch.min.js.map
new file mode 100644
index 00000000000..9c770a19164
--- /dev/null
+++ b/lib/amd/build/fetch.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"fetch.min.js","sources":["../src/fetch.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * The core/fetch module allows you to make web service requests to the Moodle API.\n *\n * @module core/fetch\n * @copyright 2023 Andrew Lyons \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Cfg from 'core/config';\nimport PendingPromise from './pending';\n\n/**\n * Normalise the component name to remove the core_ prefix.\n *\n * @param {string} component\n * @returns {string}\n */\nconst normaliseComponent = (component) => component.replace(/^core_/, '');\n\n/**\n * Get the Request object for a given API request.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} endpoint The endpoint within the componet to call\n * @param {object} params\n * @param {object} [params.params = {}] The parameters to pass to the API\n * @param {string|Object|FormData} [params.body = null] The HTTP method to use\n * @param {string} [params.method = \"GET\"] The HTTP method to use\n * @returns {Request}\n */\nconst getRequest = (\n component,\n endpoint,\n {\n params = {},\n body = null,\n method = 'GET',\n }\n) => {\n const url = new URL(`${Cfg.apibase}rest/v2/${component}/${endpoint}`);\n const options = {\n method,\n headers: {\n 'Accept': 'application/json',\n 'Content-Type': 'application/json',\n },\n };\n\n Object.entries(params).forEach(([key, value]) => {\n url.searchParams.append(key, value);\n });\n\n if (body) {\n if (body instanceof FormData) {\n options.body = body;\n } else if (body instanceof Object) {\n options.body = JSON.stringify(body);\n } else {\n options.body = body;\n }\n }\n\n return new Request(url, options);\n};\n\n/**\n * Make a request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {object} [params.params = {}] The parameters to pass to the API\n * @param {string|Object|FormData} [params.body = null] The HTTP method to use\n * @param {string} [params.method = \"GET\"] The HTTP method to use\n * @returns {Promise}\n */\nconst request = async(\n component,\n action,\n {\n params = {},\n body = null,\n method = 'GET',\n } = {},\n) => {\n const pending = new PendingPromise(`Requesting ${component}/${action} with ${method}`);\n const result = await fetch(\n getRequest(\n normaliseComponent(component),\n action,\n {params, method, body},\n ),\n );\n\n pending.resolve();\n\n if (result.ok) {\n return result.json();\n }\n\n throw new Error(result.statusText);\n};\n\n/**\n * Make a request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {object} [params.params = {}] The parameters to pass to the API\n * @returns {Promise}\n */\nconst performGet = (\n component,\n action,\n {\n params = {},\n } = {},\n) => request(\n component,\n action,\n {params, method: 'GET'},\n);\n\n/**\n * Make a request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {object} [params.params = {}] The parameters to pass to the API\n * @returns {Promise}\n */\nconst performHead = (\n component,\n action,\n {\n params = {},\n } = {},\n) => request(\n component,\n action,\n {params, method: 'HEAD'},\n);\n\n/**\n * Make a request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {string|Object|FormData} params.body The HTTP method to use\n * @returns {Promise}\n */\nconst performPost = (\n component,\n action,\n {\n body,\n } = {},\n) => request(\n component,\n action,\n {body, method: 'POST'},\n);\n\n/**\n * Make a request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {string|Object|FormData} params.body The HTTP method to use\n * @returns {Promise}\n */\nconst performPut = (\n component,\n action,\n {\n body,\n } = {},\n) => request(\n component,\n action,\n {body, method: 'POST'},\n);\n\n/**\n * Make a request to the Moodle API.\n *\n * @param {string} component The frankenstyle component name\n * @param {string} action The component action to perform\n * @param {object} params\n * @param {object} [params.params = {}] The parameters to pass to the API\n * @param {string|Object|FormData} [params.body = null] The HTTP method to use\n * @returns {Promise}\n */\nconst performDelete = (\n component,\n action,\n {\n params = {},\n body = null,\n } = {},\n) => request(\n component,\n action,\n {\n body,\n params,\n method: 'DELETE',\n },\n);\n\nexport {\n request,\n performGet,\n performHead,\n performPost,\n performPut,\n performDelete,\n};\n"],"names":["normaliseComponent","component","replace","getRequest","endpoint","params","body","method","url","URL","Cfg","apibase","options","headers","Object","entries","forEach","_ref2","key","value","searchParams","append","FormData","JSON","stringify","Request","request","async","action","pending","PendingPromise","result","fetch","resolve","ok","json","Error","statusText"],"mappings":";;;;;;;sRAgCMA,mBAAsBC,WAAcA,UAAUC,QAAQ,SAAU,IAahEC,WAAa,CACfF,UACAG,qBACAC,OACIA,OAAS,GADbC,KAEIA,KAAO,KAFXC,OAGIA,OAAS,kBAGPC,IAAM,IAAIC,cAAOC,gBAAIC,2BAAkBV,sBAAaG,WACpDQ,QAAU,CACZL,OAAAA,OACAM,QAAS,QACK,kCACM,4BAIxBC,OAAOC,QAAQV,QAAQW,SAAQC,YAAEC,IAAKC,aAClCX,IAAIY,aAAaC,OAAOH,IAAKC,UAG7Bb,OACIA,gBAAgBgB,SAChBV,QAAQN,KAAOA,KAEfM,QAAQN,KADDA,gBAAgBQ,OACRS,KAAKC,UAAUlB,MAEfA,MAIhB,IAAImB,QAAQjB,IAAKI,UActBc,QAAUC,eACZ1B,UACA2B,YACAvB,OACIA,OAAS,GADbC,KAEIA,KAAO,KAFXC,OAGIA,OAAS,8DACT,SAEEsB,QAAU,IAAIC,sCAA6B7B,sBAAa2B,wBAAerB,SACvEwB,aAAeC,MACjB7B,WACIH,mBAAmBC,WACnB2B,OACA,CAACvB,OAAAA,OAAQE,OAAAA,OAAQD,KAAAA,WAIzBuB,QAAQI,UAEJF,OAAOG,UACAH,OAAOI,aAGZ,IAAIC,MAAML,OAAOM,0DAYR,SACfpC,UACA2B,YACAvB,OACIA,OAAS,2DACT,UACHqB,QACDzB,UACA2B,OACA,CAACvB,OAAAA,OAAQE,OAAQ,8BAYD,SAChBN,UACA2B,YACAvB,OACIA,OAAS,2DACT,UACHqB,QACDzB,UACA2B,OACA,CAACvB,OAAAA,OAAQE,OAAQ,+BAYD,SAChBN,UACA2B,YACAtB,KACIA,6DACA,UACHoB,QACDzB,UACA2B,OACA,CAACtB,KAAAA,KAAMC,OAAQ,8BAYA,SACfN,UACA2B,YACAtB,KACIA,6DACA,UACHoB,QACDzB,UACA2B,OACA,CAACtB,KAAAA,KAAMC,OAAQ,iCAaG,SAClBN,UACA2B,YACAvB,OACIA,OAAS,GADbC,KAEIA,KAAO,6DACP,UACHoB,QACDzB,UACA2B,OACA,CACItB,KAAAA,KACAD,OAAAA,OACAE,OAAQ"}
\ No newline at end of file
diff --git a/lib/amd/build/utils.min.js b/lib/amd/build/utils.min.js
index df869fc7fea..4f5052e9b6f 100644
--- a/lib/amd/build/utils.min.js
+++ b/lib/amd/build/utils.min.js
@@ -1,3 +1,3 @@
-define("core/utils",["exports","core/pending"],(function(_exports,_pending){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.throttle=_exports.getNormalisedComponent=_exports.debounce=void 0,_pending=(obj=_pending)&&obj.__esModule?obj:{default:obj};_exports.throttle=(func,wait)=>{let onCooldown=!1,runAgain=null;const run=function(){for(var _len=arguments.length,args=new Array(_len),_key=0;_key<_len;_key++)args[_key]=arguments[_key];runAgain=null!==runAgain,onCooldown||(func.apply(this,args),onCooldown=!0,setTimeout((()=>{const recurse=runAgain;onCooldown=!1,runAgain=null,recurse&&run(args)}),wait))};return run};const debounceMap=new Map;_exports.debounce=function(func,wait){let{pending:pending=!1,cancel:cancel=!1}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},timeout=null;const returnedFunction=function(){for(var _len2=arguments.length,args=new Array(_len2),_key2=0;_key2<_len2;_key2++)args[_key2]=arguments[_key2];pending&&!debounceMap.has(returnedFunction)&&debounceMap.set(returnedFunction,new _pending.default("core/utils:debounce")),clearTimeout(timeout),timeout=setTimeout((async()=>{const pendingPromise=debounceMap.get(returnedFunction);debounceMap.delete(returnedFunction),await func.apply(undefined,args),null==pendingPromise||pendingPromise.resolve()}),wait)};return cancel&&(returnedFunction.cancel=()=>{const pendingPromise=debounceMap.get(returnedFunction);null==pendingPromise||pendingPromise.resolve(),clearTimeout(timeout)}),returnedFunction};_exports.getNormalisedComponent=component=>component&&"moodle"!==component&&"core"!==component?component:"core"}));
+define("core/utils",["exports","core/pending","jquery"],(function(_exports,_pending,_jquery){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.wrapPromiseInWhenable=_exports.throttle=_exports.getNormalisedComponent=_exports.debounce=void 0,_pending=_interopRequireDefault(_pending),_jquery=_interopRequireDefault(_jquery);function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}_exports.throttle=(func,wait)=>{let onCooldown=!1,runAgain=null;const run=function(){for(var _len=arguments.length,args=new Array(_len),_key=0;_key<_len;_key++)args[_key]=arguments[_key];runAgain=null!==runAgain,onCooldown||(func.apply(this,args),onCooldown=!0,setTimeout((()=>{const recurse=runAgain;onCooldown=!1,runAgain=null,recurse&&run(args)}),wait))};return run};const debounceMap=new Map;_exports.debounce=function(func,wait){let{pending:pending=!1,cancel:cancel=!1}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},timeout=null;const returnedFunction=function(){for(var _len2=arguments.length,args=new Array(_len2),_key2=0;_key2<_len2;_key2++)args[_key2]=arguments[_key2];pending&&!debounceMap.has(returnedFunction)&&debounceMap.set(returnedFunction,new _pending.default("core/utils:debounce")),clearTimeout(timeout),timeout=setTimeout((async()=>{const pendingPromise=debounceMap.get(returnedFunction);debounceMap.delete(returnedFunction),await func.apply(undefined,args),null==pendingPromise||pendingPromise.resolve()}),wait)};return cancel&&(returnedFunction.cancel=()=>{const pendingPromise=debounceMap.get(returnedFunction);null==pendingPromise||pendingPromise.resolve(),clearTimeout(timeout)}),returnedFunction};_exports.getNormalisedComponent=component=>component&&"moodle"!==component&&"core"!==component?component:"core";_exports.wrapPromiseInWhenable=promise=>_jquery.default.when(promise)}));
//# sourceMappingURL=utils.min.js.map
\ No newline at end of file
diff --git a/lib/amd/build/utils.min.js.map b/lib/amd/build/utils.min.js.map
index 60a9850f2ef..f36fce1ae49 100644
--- a/lib/amd/build/utils.min.js.map
+++ b/lib/amd/build/utils.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"utils.min.js","sources":["../src/utils.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Utility functions.\n *\n * @module core/utils\n * @copyright 2019 Ryan Wyllie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Pending from 'core/pending';\n\n /**\n * Create a wrapper function to throttle the execution of the given\n *\n * function to at most once every specified period.\n *\n * If the function is attempted to be executed while it's in cooldown\n * (during the wait period) then it'll immediately execute again as\n * soon as the cooldown is over.\n *\n * @method\n * @param {Function} func The function to throttle\n * @param {Number} wait The number of milliseconds to wait between executions\n * @return {Function}\n */\nexport const throttle = (func, wait) => {\n let onCooldown = false;\n let runAgain = null;\n const run = function(...args) {\n if (runAgain === null) {\n // This is the first time the function has been called.\n runAgain = false;\n } else {\n // This function has been called a second time during the wait period\n // so re-run it once the wait period is over.\n runAgain = true;\n }\n\n if (onCooldown) {\n // Function has already run for this wait period.\n return;\n }\n\n func.apply(this, args);\n onCooldown = true;\n\n setTimeout(() => {\n const recurse = runAgain;\n onCooldown = false;\n runAgain = null;\n\n if (recurse) {\n run(args);\n }\n }, wait);\n };\n\n return run;\n};\n\n/**\n * @property {Map} debounceMap A map of functions to their debounced pending promises.\n */\nconst debounceMap = new Map();\n\n/**\n * Create a wrapper function to debounce the execution of the given\n * function. Each attempt to execute the function will reset the cooldown\n * period.\n *\n * @method\n * @param {Function} func The function to debounce\n * @param {Number} wait The number of milliseconds to wait after the final attempt to execute\n * @param {Object} [options]\n * @param {boolean} [options.pending=false] Whether to wrap the debounced method in a pending promise\n * @param {boolean} [options.cancel=false] Whether to add a cancel method to the debounced function\n * @return {Function}\n */\nexport const debounce = (\n func,\n wait,\n {\n pending = false,\n cancel = false,\n } = {},\n) => {\n let timeout = null;\n\n const returnedFunction = (...args) => {\n if (pending && !debounceMap.has(returnedFunction)) {\n debounceMap.set(returnedFunction, new Pending('core/utils:debounce'));\n }\n clearTimeout(timeout);\n timeout = setTimeout(async () => {\n // Get the current pending promise and immediately empty it.\n // This is important to allow the function to be debounced again as soon as possible.\n // We do not resolve it until later - but that's fine because the promise is appropriately scoped.\n const pendingPromise = debounceMap.get(returnedFunction);\n debounceMap.delete(returnedFunction);\n\n // Allow the debounced function to return a Promise.\n // This ensures that Behat will not continue until the function has finished executing.\n await func.apply(this, args);\n\n // Resolve the pending promise if it exists.\n pendingPromise?.resolve();\n }, wait);\n };\n\n if (cancel) {\n returnedFunction.cancel = () => {\n const pendingPromise = debounceMap.get(returnedFunction);\n pendingPromise?.resolve();\n clearTimeout(timeout);\n };\n }\n\n return returnedFunction;\n};\n\n/**\n * Normalise the provided component such that '', 'moodle', and 'core' are treated consistently.\n *\n * @param {String} component\n * @returns {String}\n */\nexport const getNormalisedComponent = (component) => {\n if (component) {\n if (component !== 'moodle' && component !== 'core') {\n return component;\n }\n }\n\n return 'core';\n};\n"],"names":["func","wait","onCooldown","runAgain","run","args","apply","this","setTimeout","recurse","debounceMap","Map","pending","cancel","timeout","returnedFunction","has","set","Pending","clearTimeout","async","pendingPromise","get","delete","resolve","component"],"mappings":"mSAuCwB,CAACA,KAAMC,YACvBC,YAAa,EACbC,SAAW,WACTC,IAAM,yCAAYC,6CAAAA,2BAGhBF,SAFa,OAAbA,SASAD,aAKJF,KAAKM,MAAMC,KAAMF,MACjBH,YAAa,EAEbM,YAAW,WACDC,QAAUN,SAChBD,YAAa,EACbC,SAAW,KAEPM,SACAL,IAAIC,QAETJ,eAGAG,WAMLM,YAAc,IAAIC,sBAeA,SACpBX,KACAC,UACAW,QACIA,SAAU,EADdC,OAEIA,QAAS,0DACT,GAEAC,QAAU,WAERC,iBAAmB,0CAAIV,kDAAAA,6BACrBO,UAAYF,YAAYM,IAAID,mBAC5BL,YAAYO,IAAIF,iBAAkB,IAAIG,iBAAQ,wBAElDC,aAAaL,SACbA,QAAUN,YAAWY,gBAIXC,eAAiBX,YAAYY,IAAIP,kBACvCL,YAAYa,OAAOR,wBAIbf,KAAKM,gBAAYD,MAGvBgB,MAAAA,gBAAAA,eAAgBG,YACjBvB,cAGHY,SACAE,iBAAiBF,OAAS,WAChBQ,eAAiBX,YAAYY,IAAIP,kBACvCM,MAAAA,gBAAAA,eAAgBG,UAChBL,aAAaL,WAIdC,kDAS4BU,WAC/BA,WACkB,WAAdA,WAAwC,SAAdA,UACnBA,UAIR"}
\ No newline at end of file
+{"version":3,"file":"utils.min.js","sources":["../src/utils.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Utility functions.\n *\n * @module core/utils\n * @copyright 2019 Ryan Wyllie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Pending from 'core/pending';\nimport jQuery from 'jquery';\n\n /**\n * Create a wrapper function to throttle the execution of the given\n *\n * function to at most once every specified period.\n *\n * If the function is attempted to be executed while it's in cooldown\n * (during the wait period) then it'll immediately execute again as\n * soon as the cooldown is over.\n *\n * @method\n * @param {Function} func The function to throttle\n * @param {Number} wait The number of milliseconds to wait between executions\n * @return {Function}\n */\nexport const throttle = (func, wait) => {\n let onCooldown = false;\n let runAgain = null;\n const run = function(...args) {\n if (runAgain === null) {\n // This is the first time the function has been called.\n runAgain = false;\n } else {\n // This function has been called a second time during the wait period\n // so re-run it once the wait period is over.\n runAgain = true;\n }\n\n if (onCooldown) {\n // Function has already run for this wait period.\n return;\n }\n\n func.apply(this, args);\n onCooldown = true;\n\n setTimeout(() => {\n const recurse = runAgain;\n onCooldown = false;\n runAgain = null;\n\n if (recurse) {\n run(args);\n }\n }, wait);\n };\n\n return run;\n};\n\n/**\n * @property {Map} debounceMap A map of functions to their debounced pending promises.\n */\nconst debounceMap = new Map();\n\n/**\n * Create a wrapper function to debounce the execution of the given\n * function. Each attempt to execute the function will reset the cooldown\n * period.\n *\n * @method\n * @param {Function} func The function to debounce\n * @param {Number} wait The number of milliseconds to wait after the final attempt to execute\n * @param {Object} [options]\n * @param {boolean} [options.pending=false] Whether to wrap the debounced method in a pending promise\n * @param {boolean} [options.cancel=false] Whether to add a cancel method to the debounced function\n * @return {Function}\n */\nexport const debounce = (\n func,\n wait,\n {\n pending = false,\n cancel = false,\n } = {},\n) => {\n let timeout = null;\n\n const returnedFunction = (...args) => {\n if (pending && !debounceMap.has(returnedFunction)) {\n debounceMap.set(returnedFunction, new Pending('core/utils:debounce'));\n }\n clearTimeout(timeout);\n timeout = setTimeout(async () => {\n // Get the current pending promise and immediately empty it.\n // This is important to allow the function to be debounced again as soon as possible.\n // We do not resolve it until later - but that's fine because the promise is appropriately scoped.\n const pendingPromise = debounceMap.get(returnedFunction);\n debounceMap.delete(returnedFunction);\n\n // Allow the debounced function to return a Promise.\n // This ensures that Behat will not continue until the function has finished executing.\n await func.apply(this, args);\n\n // Resolve the pending promise if it exists.\n pendingPromise?.resolve();\n }, wait);\n };\n\n if (cancel) {\n returnedFunction.cancel = () => {\n const pendingPromise = debounceMap.get(returnedFunction);\n pendingPromise?.resolve();\n clearTimeout(timeout);\n };\n }\n\n return returnedFunction;\n};\n\n/**\n * Normalise the provided component such that '', 'moodle', and 'core' are treated consistently.\n *\n * @param {String} component\n * @returns {String}\n */\nexport const getNormalisedComponent = (component) => {\n if (component) {\n if (component !== 'moodle' && component !== 'core') {\n return component;\n }\n }\n\n return 'core';\n};\n\n/**\n * Wrap a Native Promise in a jQuery Whenable for b/c.\n *\n * @param {*} promise\n * @returns {jQuery}\n */\nexport const wrapPromiseInWhenable = (promise) => jQuery.when(promise);\n"],"names":["func","wait","onCooldown","runAgain","run","args","apply","this","setTimeout","recurse","debounceMap","Map","pending","cancel","timeout","returnedFunction","has","set","Pending","clearTimeout","async","pendingPromise","get","delete","resolve","component","promise","jQuery","when"],"mappings":"qbAwCwB,CAACA,KAAMC,YACvBC,YAAa,EACbC,SAAW,WACTC,IAAM,yCAAYC,6CAAAA,2BAGhBF,SAFa,OAAbA,SASAD,aAKJF,KAAKM,MAAMC,KAAMF,MACjBH,YAAa,EAEbM,YAAW,WACDC,QAAUN,SAChBD,YAAa,EACbC,SAAW,KAEPM,SACAL,IAAIC,QAETJ,eAGAG,WAMLM,YAAc,IAAIC,sBAeA,SACpBX,KACAC,UACAW,QACIA,SAAU,EADdC,OAEIA,QAAS,0DACT,GAEAC,QAAU,WAERC,iBAAmB,0CAAIV,kDAAAA,6BACrBO,UAAYF,YAAYM,IAAID,mBAC5BL,YAAYO,IAAIF,iBAAkB,IAAIG,iBAAQ,wBAElDC,aAAaL,SACbA,QAAUN,YAAWY,gBAIXC,eAAiBX,YAAYY,IAAIP,kBACvCL,YAAYa,OAAOR,wBAIbf,KAAKM,gBAAYD,MAGvBgB,MAAAA,gBAAAA,eAAgBG,YACjBvB,cAGHY,SACAE,iBAAiBF,OAAS,WAChBQ,eAAiBX,YAAYY,IAAIP,kBACvCM,MAAAA,gBAAAA,eAAgBG,UAChBL,aAAaL,WAIdC,kDAS4BU,WAC/BA,WACkB,WAAdA,WAAwC,SAAdA,UACnBA,UAIR,sCAS2BC,SAAYC,gBAAOC,KAAKF"}
\ No newline at end of file
diff --git a/lib/amd/src/fetch.js b/lib/amd/src/fetch.js
new file mode 100644
index 00000000000..f73d0b8d372
--- /dev/null
+++ b/lib/amd/src/fetch.js
@@ -0,0 +1,237 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * The core/fetch module allows you to make web service requests to the Moodle API.
+ *
+ * @module core/fetch
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import Cfg from 'core/config';
+import PendingPromise from './pending';
+
+/**
+ * Normalise the component name to remove the core_ prefix.
+ *
+ * @param {string} component
+ * @returns {string}
+ */
+const normaliseComponent = (component) => component.replace(/^core_/, '');
+
+/**
+ * Get the Request object for a given API request.
+ *
+ * @param {string} component The frankenstyle component name
+ * @param {string} endpoint The endpoint within the componet to call
+ * @param {object} params
+ * @param {object} [params.params = {}] The parameters to pass to the API
+ * @param {string|Object|FormData} [params.body = null] The HTTP method to use
+ * @param {string} [params.method = "GET"] The HTTP method to use
+ * @returns {Request}
+ */
+const getRequest = (
+ component,
+ endpoint,
+ {
+ params = {},
+ body = null,
+ method = 'GET',
+ }
+) => {
+ const url = new URL(`${Cfg.apibase}rest/v2/${component}/${endpoint}`);
+ const options = {
+ method,
+ headers: {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ };
+
+ Object.entries(params).forEach(([key, value]) => {
+ url.searchParams.append(key, value);
+ });
+
+ if (body) {
+ if (body instanceof FormData) {
+ options.body = body;
+ } else if (body instanceof Object) {
+ options.body = JSON.stringify(body);
+ } else {
+ options.body = body;
+ }
+ }
+
+ return new Request(url, options);
+};
+
+/**
+ * Make a request to the Moodle API.
+ *
+ * @param {string} component The frankenstyle component name
+ * @param {string} action The component action to perform
+ * @param {object} params
+ * @param {object} [params.params = {}] The parameters to pass to the API
+ * @param {string|Object|FormData} [params.body = null] The HTTP method to use
+ * @param {string} [params.method = "GET"] The HTTP method to use
+ * @returns {Promise}
+ */
+const request = async(
+ component,
+ action,
+ {
+ params = {},
+ body = null,
+ method = 'GET',
+ } = {},
+) => {
+ const pending = new PendingPromise(`Requesting ${component}/${action} with ${method}`);
+ const result = await fetch(
+ getRequest(
+ normaliseComponent(component),
+ action,
+ {params, method, body},
+ ),
+ );
+
+ pending.resolve();
+
+ if (result.ok) {
+ return result.json();
+ }
+
+ throw new Error(result.statusText);
+};
+
+/**
+ * Make a request to the Moodle API.
+ *
+ * @param {string} component The frankenstyle component name
+ * @param {string} action The component action to perform
+ * @param {object} params
+ * @param {object} [params.params = {}] The parameters to pass to the API
+ * @returns {Promise}
+ */
+const performGet = (
+ component,
+ action,
+ {
+ params = {},
+ } = {},
+) => request(
+ component,
+ action,
+ {params, method: 'GET'},
+);
+
+/**
+ * Make a request to the Moodle API.
+ *
+ * @param {string} component The frankenstyle component name
+ * @param {string} action The component action to perform
+ * @param {object} params
+ * @param {object} [params.params = {}] The parameters to pass to the API
+ * @returns {Promise}
+ */
+const performHead = (
+ component,
+ action,
+ {
+ params = {},
+ } = {},
+) => request(
+ component,
+ action,
+ {params, method: 'HEAD'},
+);
+
+/**
+ * Make a request to the Moodle API.
+ *
+ * @param {string} component The frankenstyle component name
+ * @param {string} action The component action to perform
+ * @param {object} params
+ * @param {string|Object|FormData} params.body The HTTP method to use
+ * @returns {Promise}
+ */
+const performPost = (
+ component,
+ action,
+ {
+ body,
+ } = {},
+) => request(
+ component,
+ action,
+ {body, method: 'POST'},
+);
+
+/**
+ * Make a request to the Moodle API.
+ *
+ * @param {string} component The frankenstyle component name
+ * @param {string} action The component action to perform
+ * @param {object} params
+ * @param {string|Object|FormData} params.body The HTTP method to use
+ * @returns {Promise}
+ */
+const performPut = (
+ component,
+ action,
+ {
+ body,
+ } = {},
+) => request(
+ component,
+ action,
+ {body, method: 'POST'},
+);
+
+/**
+ * Make a request to the Moodle API.
+ *
+ * @param {string} component The frankenstyle component name
+ * @param {string} action The component action to perform
+ * @param {object} params
+ * @param {object} [params.params = {}] The parameters to pass to the API
+ * @param {string|Object|FormData} [params.body = null] The HTTP method to use
+ * @returns {Promise}
+ */
+const performDelete = (
+ component,
+ action,
+ {
+ params = {},
+ body = null,
+ } = {},
+) => request(
+ component,
+ action,
+ {
+ body,
+ params,
+ method: 'DELETE',
+ },
+);
+
+export {
+ request,
+ performGet,
+ performHead,
+ performPost,
+ performPut,
+ performDelete,
+};
diff --git a/lib/amd/src/utils.js b/lib/amd/src/utils.js
index 0a59eab25d8..6a7bef0f65a 100644
--- a/lib/amd/src/utils.js
+++ b/lib/amd/src/utils.js
@@ -22,6 +22,7 @@
*/
import Pending from 'core/pending';
+import jQuery from 'jquery';
/**
* Create a wrapper function to throttle the execution of the given
@@ -147,3 +148,11 @@ export const getNormalisedComponent = (component) => {
return 'core';
};
+
+/**
+ * Wrap a Native Promise in a jQuery Whenable for b/c.
+ *
+ * @param {*} promise
+ * @returns {jQuery}
+ */
+export const wrapPromiseInWhenable = (promise) => jQuery.when(promise);
diff --git a/lib/apis.json b/lib/apis.json
index 7d6288fe648..ce492a5bb8f 100644
--- a/lib/apis.json
+++ b/lib/apis.json
@@ -234,6 +234,16 @@
"allowedlevel2": true,
"allowedspread": true
},
+ "route": {
+ "component": "core",
+ "allowedlevel2": true,
+ "allowedspread": true
+ },
+ "router": {
+ "component": "core",
+ "allowedlevel2": true,
+ "allowedspread": true
+ },
"rss": {
"component": "core_rss",
"allowedlevel2": false,
diff --git a/lib/classes/component.php b/lib/classes/component.php
index c9204931a63..7da97fbf726 100644
--- a/lib/classes/component.php
+++ b/lib/classes/component.php
@@ -102,40 +102,51 @@ class component {
];
/** @var array> associative array of PRS-4 namespaces and corresponding paths. */
protected static $psr4namespaces = [
- 'MaxMind' => 'lib/maxmind/MaxMind',
- 'GeoIp2' => 'lib/maxmind/GeoIp2',
- 'Sabberworm\\CSS' => 'lib/php-css-parser',
- 'MoodleHQ\\RTLCSS' => 'lib/rtlcss',
- 'ScssPhp\\ScssPhp' => 'lib/scssphp',
- 'OpenSpout' => 'lib/openspout/src',
- 'MatthiasMullie\\Minify' => 'lib/minify/matthiasmullie-minify/src/',
- 'MatthiasMullie\\PathConverter' => 'lib/minify/matthiasmullie-pathconverter/src/',
- 'IMSGlobal\LTI' => 'lib/ltiprovider/src',
- 'Packback\\Lti1p3' => 'lib/lti1p3/src',
- 'Phpml' => 'lib/mlbackend/php/phpml/src/Phpml',
- 'PHPMailer\\PHPMailer' => 'lib/phpmailer/src',
- 'RedeyeVentures\\GeoPattern' => 'lib/geopattern-php/GeoPattern',
- 'Firebase\\JWT' => 'lib/php-jwt/src',
- 'ZipStream' => 'lib/zipstream/src/',
- 'MyCLabs\\Enum' => 'lib/php-enum/src',
- 'PhpXmlRpc' => 'lib/phpxmlrpc',
- 'Psr\\Http\\Client' => 'lib/psr/http-client/src',
- 'Psr\\Http\\Message' => [
+ \MaxMind::class => 'lib/maxmind/MaxMind',
+ \GeoIp2::class => 'lib/maxmind/GeoIp2',
+ \Sabberworm\CSS::class => 'lib/php-css-parser',
+ \MoodleHQ\RTLCSS::class => 'lib/rtlcss',
+ \ScssPhp\ScssPhp::class => 'lib/scssphp',
+ \OpenSpout::class => 'lib/openspout/src',
+ \MatthiasMullie\Minify::class => 'lib/minify/matthiasmullie-minify/src/',
+ \MatthiasMullie\PathConverter::class => 'lib/minify/matthiasmullie-pathconverter/src/',
+ \IMSGlobal\LTI::class => 'lib/ltiprovider/src',
+ \Packback\Lti1p3::class => 'lib/lti1p3/src',
+ \Phpml::class => 'lib/mlbackend/php/phpml/src/Phpml',
+ \PHPMailer\PHPMailer::class => 'lib/phpmailer/src',
+ \RedeyeVentures\GeoPattern::class => 'lib/geopattern-php/GeoPattern',
+ \Firebase\JWT::class => 'lib/php-jwt/src',
+ \ZipStream::class => 'lib/zipstream/src/',
+ \MyCLabs\Enum::class => 'lib/php-enum/src',
+ \PhpXmlRpc::class => 'lib/phpxmlrpc',
+ \Psr\Http\Client::class => 'lib/psr/http-client/src',
+ \Psr\Http\Message::class => [
'lib/psr/http-message/src',
'lib/psr/http-factory/src',
],
- 'Psr\\EventDispatcher' => 'lib/psr/event-dispatcher/src',
- 'Psr\\Clock' => 'lib/psr/clock/src',
- 'Psr\\Container' => 'lib/psr/container/src',
- 'GuzzleHttp\\Psr7' => 'lib/guzzlehttp/psr7/src',
- 'GuzzleHttp\\Promise' => 'lib/guzzlehttp/promises/src',
- 'GuzzleHttp' => 'lib/guzzlehttp/guzzle/src',
- 'Kevinrob\\GuzzleCache' => 'lib/guzzlehttp/kevinrob/guzzlecache/src',
- 'Aws' => 'lib/aws-sdk/src',
- 'JmesPath' => 'lib/jmespath/src',
- 'Laravel\\SerializableClosure' => 'lib/laravel/serializable-closure/src',
- 'DI' => 'lib/php-di/php-di/src',
- 'Invoker' => 'lib/php-di/invoker/src',
+ \Psr\Http\Server::class => [
+ "lib/psr/http-server-handler/src",
+ "lib/psr/http-server-middleware/src",
+ ],
+ \Psr\EventDispatcher::class => 'lib/psr/event-dispatcher/src',
+ \Psr\Clock::class => 'lib/psr/clock/src',
+ \Psr\Container::class => 'lib/psr/container/src',
+ \Psr\Log::class => "lib/psr/log/src",
+ \Psr\SimpleCache::class => 'lib/psr/simple-cache/src',
+ \GuzzleHttp\Psr7::class => 'lib/guzzlehttp/psr7/src',
+ \GuzzleHttp\Promise::class => 'lib/guzzlehttp/promises/src',
+ \GuzzleHttp::class => 'lib/guzzlehttp/guzzle/src',
+ \Kevinrob\GuzzleCache::class => 'lib/guzzlehttp/kevinrob/guzzlecache/src',
+ \Aws::class => 'lib/aws-sdk/src',
+ \JmesPath::class => 'lib/jmespath/src',
+ \Laravel\SerializableClosure::class => 'lib/laravel/serializable-closure/src',
+ \DI::class => 'lib/php-di/php-di/src',
+ \Complex::class => 'lib/phpspreadsheet/markbaker/classes/src',
+ \Matrix::class => 'lib/phpspreadsheet/markbaker/classes/src',
+ \PhpOffice\PhpSpreadsheet::class => 'lib/phpspreadsheet/phpspreadsheet/src/PhpSpreadsheet',
+ \Invoker::class => 'lib/php-di/invoker/src',
+ \FastRoute::class => 'lib/nikic/fast-route/src',
+ \Slim::class => 'lib/slim/slim/Slim',
];
/**
@@ -441,7 +452,7 @@ class component {
$keyclasses = [
\core\exception\moodle_exception::class,
\core\output\bootstrap_renderer::class,
- \core_filters\filter_manager::class,
+ \core\router::class,
];
foreach ($keyclasses as $classname) {
if (!array_key_exists($classname, $cache['classmap'])) {
diff --git a/lib/classes/exception/access_denied_exception.php b/lib/classes/exception/access_denied_exception.php
new file mode 100644
index 00000000000..b8e474269b0
--- /dev/null
+++ b/lib/classes/exception/access_denied_exception.php
@@ -0,0 +1,33 @@
+.
+
+namespace core\exception;
+
+use core\router\response\access_denied_response;
+
+/**
+ * An exception to describe the case where access has been denied to a resource.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class access_denied_exception extends moodle_exception implements response_aware_exception {
+ #[\Override]
+ public function get_response_classname(): string {
+ return access_denied_response::class;
+ }
+}
diff --git a/lib/classes/exception/not_found_exception.php b/lib/classes/exception/not_found_exception.php
new file mode 100644
index 00000000000..d85e2ac0265
--- /dev/null
+++ b/lib/classes/exception/not_found_exception.php
@@ -0,0 +1,52 @@
+.
+
+namespace core\exception;
+use core\router\response\not_found_response;
+
+/**
+ * An exception to describe the case where a requested item was not found.
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+
+ */
+class not_found_exception extends \moodle_exception implements response_aware_exception {
+ /**
+ * Constructor for a new not found exception.
+ *
+ * @param string $itemtype The type of item that was not found.
+ * @param string $identifier The identifier of the item that was not found.
+ */
+ public function __construct(
+ string $itemtype,
+ string $identifier,
+ ) {
+ parent::__construct(
+ errorcode: 'itemnotfound',
+ a: [
+ 'itemtype' => $itemtype,
+ 'identifier' => $identifier,
+ ],
+ );
+ }
+
+ #[\Override]
+ public function get_response_classname(): string {
+ return not_found_response::class;
+ }
+}
diff --git a/lib/classes/exception/response_aware_exception.php b/lib/classes/exception/response_aware_exception.php
new file mode 100644
index 00000000000..b6c1ca953cb
--- /dev/null
+++ b/lib/classes/exception/response_aware_exception.php
@@ -0,0 +1,33 @@
+.
+
+namespace core\exception;
+
+/**
+ * An exception which is aware of the response class that should be used to handle it.
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface response_aware_exception {
+ /**
+ * Get the class name of the response that should be used to handle this exception.
+ *
+ * @return string
+ */
+ public function get_response_classname(): string;
+}
diff --git a/lib/classes/hook/di_configuration.php b/lib/classes/hook/di_configuration.php
index 806abec5ae5..e7a7142c30c 100644
--- a/lib/classes/hook/di_configuration.php
+++ b/lib/classes/hook/di_configuration.php
@@ -17,6 +17,7 @@
namespace core\hook;
use DI\ContainerBuilder;
+use DI\Definition;
use core\attribute\label;
/**
@@ -67,13 +68,14 @@ class di_configuration {
*
*
* @param string $id The identifier of the container entry
- * @param callable $definition The definition of the container entry
+ * @param callable|Definition\Definition|Definition\SelfResolvingDefinition|Definition\Helper\DefinitionHelper $definition
+ * The definition of the container entry
* @return self
* @example
*/
public function add_definition(
string $id,
- callable $definition,
+ callable|Definition\Definition|Definition\SelfResolvingDefinition|Definition\Helper\DefinitionHelper $definition,
): self {
$this->builder->addDefinitions([
$id => $definition,
diff --git a/lib/classes/output/requirements/page_requirements_manager.php b/lib/classes/output/requirements/page_requirements_manager.php
index a417d4e9ac6..d58e75995c8 100644
--- a/lib/classes/output/requirements/page_requirements_manager.php
+++ b/lib/classes/output/requirements/page_requirements_manager.php
@@ -298,7 +298,7 @@ class page_requirements_manager {
* @return array List of safe config values that are available to javascript.
*/
public function get_config_for_javascript(moodle_page $page, renderer_base $renderer) {
- global $CFG;
+ global $CFG, $USER;
if (empty($this->M_cfg)) {
$iconsystem = \core\output\icon_system::instance();
@@ -315,6 +315,7 @@ class page_requirements_manager {
$this->M_cfg = [
'wwwroot' => $CFG->wwwroot,
+ 'apibase' => $this->get_api_base(),
'homeurl' => $page->navigation->action,
'sesskey' => sesskey(),
'sessiontimeout' => $CFG->sessiontimeout,
@@ -335,6 +336,7 @@ class page_requirements_manager {
'langrev' => get_string_manager()->get_revision(),
'templaterev' => $this->get_templaterev(),
'siteId' => (int) SITEID,
+ 'userId' => (int) $USER->id,
];
if ($CFG->debugdeveloper) {
$this->M_cfg['developerdebug'] = true;
@@ -346,6 +348,29 @@ class page_requirements_manager {
return $this->M_cfg;
}
+ /**
+ * Return the base URL for the API.
+ *
+ * If the router has been fully configured on the web server then we can use the shortened route, otherwise the r.php.
+ *
+ * @return string
+ */
+ protected function get_api_base(): string {
+ global $CFG;
+
+ if (!empty($CFG->router_configured)) {
+ return sprintf(
+ "%s/api/",
+ $CFG->wwwroot,
+ );
+ }
+
+ return sprintf(
+ "%s/r.php/api/",
+ $CFG->wwwroot,
+ );
+ }
+
/**
* Initialise with the bits of JavaScript that every Moodle page should have.
*
diff --git a/lib/classes/output/routed_error_handler.php b/lib/classes/output/routed_error_handler.php
new file mode 100644
index 00000000000..6a80126e7e7
--- /dev/null
+++ b/lib/classes/output/routed_error_handler.php
@@ -0,0 +1,50 @@
+.
+
+namespace core\output;
+
+use Slim\Interfaces\ErrorRendererInterface;
+use Throwable;
+
+// phpcs:disable moodle.NamingConventions.ValidVariableName.VariableNameLowerCase
+
+/**
+ * Class routed_error_handler
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class routed_error_handler implements ErrorRendererInterface {
+ #[\Override]
+ public function __invoke(Throwable $exception, bool $displayErrorDetails): string {
+ // @codeCoverageIgnoreStart
+ if (defined('ABORT_AFTER_CONFIG') && !defined('ABORT_AFTER_CONFIG_CANCEL')) {
+ define('ABORT_AFTER_CONFIG_CANCEL', true);
+ require(__DIR__ . '/../../setup.php');
+ }
+ // @codeCoverageIgnoreEnd
+
+ if ($whoops = get_whoops()) {
+ $whoops->sendHttpCode($exception->getCode());
+ $whoops->handleException($exception);
+ } else {
+ default_exception_handler($exception);
+ }
+
+ return '';
+ }
+}
diff --git a/lib/classes/param.php b/lib/classes/param.php
index 9c817ff4f5b..91a78bb1b64 100644
--- a/lib/classes/param.php
+++ b/lib/classes/param.php
@@ -36,22 +36,26 @@ enum param: string {
/**
* PARAM_ALPHA - contains only English ascii letters [a-zA-Z].
*/
+ #[param_clientside_regex('^[a-zA-Z]+$')]
case ALPHA = 'alpha';
/**
* PARAM_ALPHAEXT the same contents as PARAM_ALPHA (English ascii letters [a-zA-Z]) plus the chars in quotes: "_-" allowed
* NOTE: originally this allowed "/" too, please use PARAM_SAFEPATH if "/" needed
*/
+ #[param_clientside_regex('^[a-zA-Z_\-]*$')]
case ALPHAEXT = 'alphaext';
/**
* PARAM_ALPHANUM - expected numbers 0-9 and English ascii letters [a-zA-Z] only.
*/
+ #[param_clientside_regex('^[a-zA-Z0-9]*$')]
case ALPHANUM = 'alphanum';
/**
* PARAM_ALPHANUMEXT - expected numbers 0-9, letters (English ascii letters [a-zA-Z]) and _- only.
*/
+ #[param_clientside_regex('^[a-zA-Z0-9_\-]*$')]
case ALPHANUMEXT = 'alphanumext';
/**
@@ -108,6 +112,7 @@ enum param: string {
* This is preferred over PARAM_FLOAT for numbers typed in by the user.
* Cleans localised numbers to computer readable numbers; false for invalid numbers.
*/
+ #[param_clientside_regex('^\d*([\.,])\d+$')]
case LOCALISEDFLOAT = 'localisedfloat';
/**
@@ -165,6 +170,7 @@ enum param: string {
/**
* PARAM_SAFEDIR - safe directory name, suitable for include() and require()
*/
+ #[param_clientside_regex('^[a-zA-Z0-9_\-]*$')]
case SAFEDIR = 'safedir';
/**
@@ -173,11 +179,13 @@ enum param: string {
*
* This is NOT intended to be used for absolute paths or any user uploaded files.
*/
+ #[param_clientside_regex('^[a-zA-Z0-9\/_\-]*$')]
case SAFEPATH = 'safepath';
/**
* PARAM_SEQUENCE - expects a sequence of numbers like 8 to 1,5,6,4,6,8,9. Numbers and comma only.
*/
+ #[param_clientside_regex('^[0-9,]*$')]
case SEQUENCE = 'sequence';
/**
@@ -316,6 +324,7 @@ enum param: string {
* Only lowercase ascii letters, numbers and underscores are allowed, it has to start with a letter.
* NOTE: numbers and underscores are strongly discouraged in plugin names!
*/
+ #[param_clientside_regex('^[a-z][a-z0-9]*(_(?:[a-z][a-z0-9_](?!__))*)?[a-z0-9]+$')]
case COMPONENT = 'component';
/**
@@ -323,6 +332,7 @@ enum param: string {
* It is usually used together with context id and component.
* Only lowercase ascii letters, numbers and underscores are allowed, it has to start with a letter.
*/
+ #[param_clientside_regex('^[a-z](?:[a-z0-9_](?!__))*[a-z0-9]+$')]
case AREA = 'area';
/**
@@ -330,6 +340,7 @@ enum param: string {
* Only lowercase ascii letters, numbers and underscores are allowed, it has to start with a letter.
* NOTE: numbers and underscores are strongly discouraged in plugin names! Underscores are forbidden in module names.
*/
+ #[param_clientside_regex('^[a-z](?:[a-z0-9_](?!__))*[a-z0-9]+$')]
case PLUGIN = 'plugin';
/**
@@ -409,6 +420,21 @@ enum param: string {
return $this->{$methodname}($value);
}
+ /**
+ * Get the clientside regular expression for this parameter.
+ *
+ * @return null|string
+ */
+ public function get_clientside_expression(): ?string {
+ $ref = new \ReflectionClassConstant(self::class, $this->name);
+ $attributes = $ref->getAttributes(param_clientside_regex::class);
+ if (count($attributes) === 0) {
+ return null;
+ }
+
+ return $attributes[0]->newInstance()->regex;
+ }
+
/**
* Returns a value for the named variable, taken from request arguments.
*
diff --git a/lib/classes/param_clientside_regex.php b/lib/classes/param_clientside_regex.php
new file mode 100644
index 00000000000..c2c6009e37d
--- /dev/null
+++ b/lib/classes/param_clientside_regex.php
@@ -0,0 +1,40 @@
+.
+
+namespace core;
+
+use Attribute;
+
+/**
+ * A JS-compatible regular expression to validate the format of a param.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+#[Attribute(Attribute::TARGET_CLASS_CONSTANT)]
+class param_clientside_regex {
+ /**
+ * Create a clientside regular expression for use with a \core\param enum case.
+ *
+ * @param string $regex The Regular Expression that validates the param case
+ */
+ public function __construct(
+ /** @var string The Regular Expression that validates the param case */
+ public readonly string $regex,
+ ) {
+ }
+}
diff --git a/lib/classes/route/api/templates.php b/lib/classes/route/api/templates.php
new file mode 100644
index 00000000000..867c145e026
--- /dev/null
+++ b/lib/classes/route/api/templates.php
@@ -0,0 +1,157 @@
+.
+
+namespace core\route\api;
+
+use core\exception;
+use core\param;
+use core\router\route;
+use core\output\mustache_template_source_loader;
+use core\router\schema\response\payload_response;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Template Controller.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class templates {
+ use \core\router\route_controller;
+
+ /**
+ * Fetch a single template for a component in a theme.
+ *
+ * @param ResponseInterface $response
+ * @param string $themename
+ * @param string $component
+ * @param null|string $identifier
+ * @return payload_response
+ */
+ #[route(
+ path: '/templates/{themename}/{component}/{identifier}',
+ method: ['GET'],
+ title: 'Fetch a single template',
+ description: 'Fetch a single template for a component in a theme',
+ security: [],
+ pathtypes: [
+ new \core\router\parameters\path_themename(),
+ new \core\router\parameters\path_component(),
+ new \core\router\schema\parameters\path_parameter(
+ name: 'identifier',
+ type: param::SAFEPATH,
+ ),
+ ],
+ queryparams: [
+ new \core\router\schema\parameters\query_parameter(
+ name: 'includecomments',
+ type: param::BOOL,
+ description: 'Include comments in the template',
+ default: false,
+ ),
+ ],
+ headerparams: [
+ new \core\router\parameters\header_language(),
+ ],
+ responses: [
+ new \core\router\schema\response\response(
+ statuscode: 200,
+ description: 'OK',
+ content: [
+ new \core\router\schema\response\content\json_media_type(
+ schema: new \core\router\schema\objects\schema_object(
+ content: [
+ 'templates' => new \core\router\schema\objects\array_of_strings(
+ keyparamtype: param::TEXT,
+ valueparamtype: param::RAW,
+ ),
+ 'strings' => new \core\router\schema\objects\array_of_strings(
+ keyparamtype: param::TEXT,
+ valueparamtype: param::RAW,
+ ),
+ ],
+ ),
+ examples: [
+ new \core\router\schema\example(
+ name: 'Single template value',
+ summary: 'A json response containing the template for a single template',
+ value: [
+ 'templates' => [
+ "mod_example/template_identifier" => "Hello World
",
+ "mod_example/other_template" => "Hello World
",
+ ],
+ 'strings' => [
+ 'core/loading' => 'Loading',
+ ],
+ ],
+ ),
+ ]
+ ),
+ ],
+ ),
+ ],
+ )]
+ public function get_templates(
+ ServerRequestInterface $request,
+ ResponseInterface $response,
+ mustache_template_source_loader $loader,
+ string $themename,
+ string $component,
+ string $identifier,
+ ): payload_response {
+ global $PAGE;
+
+ $PAGE->set_context(\core\context\system::instance());
+
+ $params = $request->getQueryParams();
+ $comments = $params['includecomments'];
+
+ try {
+ $dependencies = $loader->load_with_dependencies(
+ templatecomponent: $component,
+ templatename: $identifier,
+ themename: $themename,
+ includecomments: $comments,
+ lang: $request->getHeaderLine('language'),
+ );
+ } catch (\moodle_exception $e) {
+ throw new exception\not_found_exception('template', "{$component}/{$identifier}");
+ }
+
+ $result = [
+ 'templates' => [],
+ 'strings' => [],
+ ];
+
+ foreach ($dependencies['templates'] as $component => $templates) {
+ foreach ($templates as $template => $value) {
+ $result['templates']["{$component}/{$template}"] = $value;
+ }
+ }
+ foreach ($dependencies['strings'] as $component => $templates) {
+ foreach ($templates as $template => $value) {
+ $result['strings']["{$component}/{$template}"] = $value;
+ }
+ }
+
+ return new payload_response(
+ payload: $result,
+ request: $request,
+ );
+ }
+}
diff --git a/lib/classes/router.php b/lib/classes/router.php
new file mode 100644
index 00000000000..2a0a94d3412
--- /dev/null
+++ b/lib/classes/router.php
@@ -0,0 +1,259 @@
+.
+
+namespace core;
+
+use core\output\routed_error_handler;
+use core\router\middleware\cors_middleware;
+use core\router\middleware\error_handling_middleware;
+use core\router\middleware\moodle_bootstrap_middleware;
+use core\router\middleware\moodle_route_attribute_middleware;
+use core\router\middleware\uri_normalisation_middleware;
+use core\router\middleware\validation_middleware;
+use core\router\request_validator_interface;
+use core\router\response_handler;
+use core\router\response_validator_interface;
+use core\router\route_loader_interface;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\App;
+use Slim\Interfaces\RouteGroupInterface;
+
+/**
+ * Moodle Router.
+ *
+ * This class represents the Moodle Router, which handles all aspects of Routing in Moodle.
+ *
+ * It should not normally be accessed or used outside of its own unit tests, the route_testcase, and the `r.php` handler.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class router {
+ /** @var string The base path to use for all requests */
+ public readonly string $basepath;
+
+ /** @var App The SlimPHP App */
+ protected readonly App $app;
+
+ /**
+ * Create a new Router.
+ *
+ * @param response_handler $responsehandler
+ * @param route_loader_interface $routeloader
+ * @param request_validator_interface $requestvalidator
+ * @param response_validator_interface $responsevalidator
+ * @param null|string $basepath
+ */
+ public function __construct(
+ /** @var response_handler */
+ protected response_handler $responsehandler,
+
+ /** @var route_loader_interface The router loader to use */
+ protected readonly route_loader_interface $routeloader,
+
+ /** @var request_validator_interface */
+ protected request_validator_interface $requestvalidator,
+
+ /** @var response_validator_interface */
+ protected response_validator_interface $responsevalidator,
+
+ ?string $basepath = null,
+ ) {
+ if ($basepath === null) {
+ $basepath = $this->guess_basepath();
+ }
+ $this->basepath = $basepath;
+ }
+
+ /**
+ * Guess the basepath for the Router.
+ *
+ * @return string
+ */
+ protected function guess_basepath(): string {
+ global $CFG;
+
+ // Moodle is not guaranteed to exist at the domain root.
+ // Strip out the current script.
+ $scriptroot = parse_url($CFG->wwwroot, PHP_URL_PATH);
+ $relativeroot = sprintf(
+ '%s%s',
+ $scriptroot,
+ str_replace(
+ realpath($CFG->dirroot),
+ '',
+ realpath($_SERVER['SCRIPT_FILENAME']),
+ ),
+ );
+
+ // The server is not configured to rewrite unknown requests to automatically use the router.
+ if ($_SERVER && array_key_exists('REQUEST_URI', $_SERVER)) {
+ if (str_starts_with($_SERVER['REQUEST_URI'], $relativeroot)) {
+ $scriptroot .= '/r.php';
+ }
+ }
+
+ return $scriptroot;
+ }
+
+ /**
+ * Get the configured SlimPHP Application.
+ *
+ * @return App
+ */
+ public function get_app(): App {
+ if (!isset($this->app)) {
+ $this->create_app($this->basepath);
+ }
+
+ return $this->app;
+ }
+
+ /**
+ * Get the Response Factory for the Router.
+ *
+ * @return ResponseFactoryInterface
+ */
+ public function get_response_factory(): ResponseFactoryInterface {
+ return $this->get_app()->getResponseFactory();
+ }
+
+ /**
+ * Create the configured SlimPHP Application.
+ *
+ * @param string $basepath The base path of the Moodle instance
+ */
+ protected function create_app(
+ string $basepath = '',
+ ): void {
+ global $CFG;
+
+ // PHP Does not support autoloading functions.
+ require_once("{$CFG->libdir}/nikic/fast-route/src/functions.php");
+
+ // Create an App using the DI Bridge.
+ $this->app = router\bridge::create();
+
+ // Add Middleware to the App.
+ // Note: App Middleware is called before any Group or Route middleware.
+ $this->add_middleware();
+ $this->configure_caching();
+ $this->configure_routes();
+
+ // Configure the basepath for Moodle.
+ $this->app->setBasePath($basepath);
+ }
+
+ /**
+ * Add Middleware to the App.
+ */
+ protected function add_middleware(): void {
+ // Middleware is added like an onion.
+ // For a Response, the outer-most middleware is executed first, and the inner-most middleware is executed last.
+ // For a Request, the inner-most middleware is executed first, and the outer-most middleware is executed last.
+
+ // Add the body parsing middleware from Slim.
+ // See https://www.slimframework.com/docs/v4/middleware/body-parsing.html for further information.
+ $this->app->addBodyParsingMiddleware();
+
+ // Add Middleware to Bootstrap Moodle from a request.
+ $this->app->add(di::get(moodle_bootstrap_middleware::class));
+
+ // Add the Moodle route attribute to the request.
+ // This must be processed after the Routing Middleware has been processed on the request.
+ $this->app->add(di::get(moodle_route_attribute_middleware::class));
+
+ // Add the Routing Middleware as one of the outer-most middleware.
+ // This allows the Route to be accessed before it is handled.
+ // See https://www.slimframework.com/docs/v4/cookbook/retrieving-current-route.html for further information.
+ $this->app->addRoutingMiddleware();
+
+ // Add request normalisation middleware to standardise the URI.
+ // This must be done before the Routing Middleware to ensure that the route is matched correctly.
+ $this->app->add(di::get(uri_normalisation_middleware::class));
+
+ // Add the Error Handling Middleware and configure it to show Moodle Errors for HTML pages.
+ $errormiddleware = $this->app->addErrorMiddleware(true, true, true);
+ $errorhandler = $errormiddleware->getDefaultErrorHandler();
+ $errorhandler->registerErrorRenderer('text/html', routed_error_handler::class);
+ }
+
+ /**
+ * Configure the API routes.
+ */
+ protected function configure_routes(): void {
+ $routegroups = $this->routeloader->configure_routes($this->app);
+ foreach ($routegroups as $name => $collection) {
+ match ($name) {
+ route_loader_interface::ROUTE_GROUP_API => $this->configure_api_route($collection),
+ default => null,
+ };
+ }
+ }
+
+ /**
+ * Configure the API Route Middleware.
+ *
+ * @param RouteGroupInterface $group
+ */
+ protected function configure_api_route(RouteGroupInterface $group): void {
+ $group
+ ->add(di::get(error_handling_middleware::class))
+ // Add a Middleware to set the CORS headers for all REST Responses.
+ ->add(di::get(cors_middleware::class))
+ ->add(di::get(validation_middleware::class));
+ }
+
+ /**
+ * Configure caching for the routes.
+ */
+ protected function configure_caching(): void {
+ global $CFG;
+
+ // Note: Slim uses a file cache and is not compatible with MUC.
+ $this->app->getRouteCollector()->setCacheFile(
+ sprintf(
+ "%s/routes.%s.cache",
+ $CFG->cachedir,
+ sha1($this->basepath),
+ ),
+ );
+ }
+
+ /**
+ * Handle the specified Request.
+ *
+ * @param ServerRequestInterface $request
+ * @return ResponseInterface
+ */
+ public function handle_request(
+ ServerRequestInterface $request,
+ ): ResponseInterface {
+ return $this->get_app()->handle($request);
+ }
+
+ /**
+ * Serve the current request using global variables.
+ *
+ * @codeCoverageIgnore
+ */
+ public function serve(): void {
+ $this->get_app()->run();
+ }
+}
diff --git a/lib/classes/router/abstract_route_loader.php b/lib/classes/router/abstract_route_loader.php
new file mode 100644
index 00000000000..93e0de3c12d
--- /dev/null
+++ b/lib/classes/router/abstract_route_loader.php
@@ -0,0 +1,209 @@
+.
+
+namespace core\router;
+
+use Slim\Interfaces\RouteInterface;
+
+/**
+ * A base Route Loader
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class abstract_route_loader {
+ /**
+ * Get all routes in the namespace.
+ *
+ * @param string $namespace The namespace to get the routes for
+ * @param callable $componentpathcallback A callback to get the component path for a class
+ * @return array[]
+ */
+ protected function get_all_routes_in_namespace(
+ string $namespace,
+ callable $componentpathcallback,
+ ): array {
+ $routes = [];
+
+ // Get all classes in the namespace.
+ $classes = \core_component::get_component_classes_in_namespace(namespace: $namespace);
+ foreach (array_keys($classes) as $classname) {
+ $classinfo = new \ReflectionClass($classname);
+ $component = \core_component::get_component_from_classname($classname);
+ $componentpath = $componentpathcallback($component);
+
+ // Add all public methods with a #[route] attribute in this class.
+ array_push($routes, ...$this->get_all_routes_in_class(
+ componentpath: $componentpath,
+ classinfo: $classinfo,
+ ));
+ }
+
+ return $routes;
+ }
+
+ /**
+ * Get all routes in a class.
+ *
+ * @param string $componentpath The path to the component that the class belongs to
+ * @param \ReflectionClass $classinfo The class to get the routes for
+ * @return array[]
+ */
+ protected function get_all_routes_in_class(
+ string $componentpath,
+ \ReflectionClass $classinfo,
+ ): array {
+ // Filter out any methods which are public but do not have any route attached.
+ return array_filter(
+ array_map(
+ fn ($methodinfo) => $this->get_route_data_for_method(
+ componentpath: $componentpath,
+ classinfo: $classinfo,
+ methodinfo: $methodinfo,
+ ),
+ $classinfo->getMethods(\ReflectionMethod::IS_PUBLIC),
+ )
+ );
+ }
+
+ /**
+ * Get route data for a single method in a class.
+ *
+ * @param string $componentpath The path to the component that the class belongs to
+ * @param \ReflectionClass $classinfo The class to get the route data for
+ * @param \ReflectionMethod $methodinfo The method to get the route data for
+ * @return null|array[]
+ */
+ protected function get_route_data_for_method(
+ string $componentpath,
+ \ReflectionClass $classinfo,
+ \ReflectionMethod $methodinfo,
+ ): ?array {
+ $routeattribute = $this->get_route_attribute_for_method(
+ $classinfo,
+ $methodinfo,
+ );
+
+ if ($routeattribute === null) {
+ // No route on this method.
+ return null;
+ }
+
+ // Build the pattern for this route.
+ $path = $routeattribute->get_path();
+ $pattern = "/{$componentpath}{$path}";
+
+ // Remove duplicate slashes.
+ $pattern = preg_replace('@/+@', '/', $pattern);
+
+ // Get the HTTP methods for this route.
+ $httpmethods = $routeattribute->get_methods(['GET']);
+
+ return [
+ 'methods' => $httpmethods,
+ 'pattern' => $pattern,
+ 'callable' => [$classinfo->getName(), $methodinfo->getName()],
+ ];
+ }
+
+ /**
+ * Get the route attribute for the specified method.
+ *
+ * Note: If a parent has a route, but the method does not, no route will be returned.
+ *
+ * @param \ReflectionClass $classinfo The class to get the route attribute for
+ * @param \ReflectionMethod $methodinfo The method to get the route attribute for
+ * @return null|route
+ */
+ protected function get_route_attribute_for_method(
+ \ReflectionClass $classinfo,
+ \ReflectionMethod $methodinfo,
+ ): ?route {
+ // Fetch the route attribute from the method.
+ // Each method can only have a single route attribute.
+ $routeattributes = $methodinfo->getAttributes(route::class);
+ if (empty($routeattributes)) {
+ return null;
+ }
+
+ // Get the instance.
+ $methodroute = $routeattributes[0]->newInstance();
+
+ // Set the parent route if the class has one.
+ $classattributes = $classinfo->getAttributes(route::class);
+ if ($classattributes) {
+ // The class has a #route attribute.
+ $methodroute->set_parent($classattributes[0]->newInstance());
+ }
+
+ return $methodroute;
+ }
+
+ /**
+ * Normalise the component for use as part of the path.
+ *
+ * If the component is a subsystem, the `core_` prefix will be removed.
+ * If the component is 'core', it will be kept.
+ * All other components will use their frankenstyle name.
+ *
+ * @param string $component
+ * @return string
+ */
+ protected function normalise_component_path(
+ string $component,
+ ): string {
+ if ($component === 'core') {
+ return $component;
+ }
+ [$type, $subsystem] = \core_component::normalize_component($component);
+ if ($type === 'core') {
+ $component = $subsystem;
+ }
+
+ if ($component === null) {
+ $component = '';
+ }
+
+ return $component;
+ }
+
+ /**
+ * Set a route name for the specified callable.
+ *
+ * @param RouteInterface $slimroute
+ * @param string|array|callable $callable
+ * @return string|null The name of the route if it was set, otherwise null
+ */
+ protected function set_route_name_for_callable(
+ RouteInterface $slimroute,
+ string|array|callable $callable,
+ ): ?string {
+ if (is_string($callable)) {
+ $slimroute->setName($callable);
+ return $callable;
+ }
+
+ if (is_array($callable)) {
+ $name = implode('::', $callable);
+ $slimroute->setName($name);
+ return $name;
+ }
+
+ // Unable to set a name. Return null.
+ return null;
+ }
+}
diff --git a/lib/classes/router/apidocs.php b/lib/classes/router/apidocs.php
new file mode 100644
index 00000000000..f2e1c7940ca
--- /dev/null
+++ b/lib/classes/router/apidocs.php
@@ -0,0 +1,159 @@
+.
+
+namespace core\router;
+
+use core\component;
+use core\router\schema\specification;
+use Psr\Http\Message\ResponseInterface;
+use ReflectionClass;
+use Throwable;
+
+/**
+ * Moodle Router.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class apidocs {
+ /**
+ * Generate the API docs for the API.
+ *
+ * @param ResponseInterface $response
+ * @return ResponseInterface
+ */
+ public function openapi_docs(
+ ResponseInterface $response,
+ ): ResponseInterface {
+ global $PAGE;
+ $PAGE->set_context(\core\context\system::instance());
+
+ $api = new specification();
+
+ $classes = $this->get_openapi_classes_names();
+ foreach (array_keys($classes) as $classname) {
+ $classinfo = new ReflectionClass($classname);
+ [$component] = explode('\\', $classinfo->getNamespaceName());
+
+ $classroutes = $classinfo->getAttributes(route::class);
+
+ if ($classroutes) {
+ foreach ($classroutes as $classroute) {
+ $parentroute = $classroute->newInstance();
+ $this->get_api_docs_for_route(
+ component: $component,
+ classinfo: $classinfo,
+ api: $api,
+ parentcontexts: [$parentroute],
+ );
+ }
+ } else {
+ $this->get_api_docs_for_route(
+ component: $component,
+ classinfo: $classinfo,
+ api: $api,
+ );
+ }
+ }
+
+ // At the moment only json is supported. This could be extended to support other formats in future.
+ return $response
+ ->withHeader('Content-Type', 'application/json')
+ ->withBody(\GuzzleHttp\Psr7\Utils::streamFor(
+ json_encode(
+ $api,
+ JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES,
+ ),
+ ));
+ }
+
+ /**
+ * Get the list of OpenAPI Class Names.
+ *
+ * @return string[]
+ */
+ protected function get_openapi_classes_names(): array {
+ global $CFG;
+
+ $classes = [];
+ foreach (component::get_component_names(true) as $component) {
+ try {
+ $classes = array_merge(
+ $classes,
+ component::get_component_classes_in_namespace(
+ component: $component,
+ namespace: 'route\api',
+ ),
+ );
+ // @codeCoverageIgnoreStart
+ } catch (Throwable $error) {
+ // Some kind of error occurred whilst loading routes in this component.
+ // When debugging, this is useful to know.
+ // When not, log to error_log.
+ if (!$CFG->debugdisplay) {
+ debugging('Error loading route data: ' . $error->getMessage());
+ } else {
+ default_exception_handler($error);
+ }
+ }
+ // @codeCoverageIgnoreEnd
+ }
+ return $classes;
+ }
+
+
+ /**
+ * Get the API Docs for the specified Route.
+ *
+ * @param string $component The component that the route relates to
+ * @param ReflectionClass $classinfo
+ * @param specification $api
+ * @param array $parentcontexts
+ * @return self
+ */
+ protected function get_api_docs_for_route(
+ string $component,
+ ReflectionClass $classinfo,
+ specification $api,
+ array $parentcontexts = [],
+ ): self {
+ $methods = $classinfo->getMethods();
+ foreach ($methods as $method) {
+ if (!$method->isPublic()) {
+ continue;
+ }
+
+ // Get the route attribute for this method.
+ $routeattribute = util::get_route_instance_for_method(
+ [$classinfo->getName(), $method->getName()],
+ );
+
+ if ($routeattribute === null) {
+ // This method has no route attribute. Maybe just a helper method.
+ continue;
+ }
+
+ // Add this path to the OpenAPI specification.
+ $api->add_path(
+ component: $component,
+ route: $routeattribute,
+ );
+ }
+
+ return $this;
+ }
+}
diff --git a/lib/classes/router/bridge.php b/lib/classes/router/bridge.php
new file mode 100644
index 00000000000..f5028240add
--- /dev/null
+++ b/lib/classes/router/bridge.php
@@ -0,0 +1,95 @@
+.
+
+declare(strict_types=1);
+
+namespace core\router;
+
+use core\di;
+use Invoker\Invoker;
+use Invoker\ParameterResolver\AssociativeArrayResolver;
+use Invoker\ParameterResolver\Container\TypeHintContainerResolver;
+use Invoker\ParameterResolver\DefaultValueResolver;
+use Invoker\ParameterResolver\ResolverChain;
+use Psr\Container\ContainerInterface;
+use Slim\App;
+use Slim\Factory\AppFactory;
+use Slim\Interfaces\CallableResolverInterface;
+use Slim\Interfaces\InvocationStrategyInterface;
+
+// phpcs:disable moodle.NamingConventions.ValidFunctionName.LowercaseMethod
+
+/**
+ * This factory creates a Slim application correctly configured with PHP-DI.
+ *
+ * To use this, replace `Slim\Factory\AppFactory::create()`
+ * with `DI\Bridge\Slim\Bridge::create()`.
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class bridge {
+ /**
+ * Create a new Slim application with PHP-DI.
+ *
+ * @param ContainerInterface|null $container
+ * @return App
+ */
+ public static function create(?ContainerInterface $container = null): App {
+ $container = $container ?: di::get_container();
+
+ di::set(
+ CallableResolverInterface::class,
+ new callable_resolver(new \Invoker\CallableResolver($container)),
+ );
+
+ $app = AppFactory::createFromContainer($container);
+
+ di::set(App::class, $app);
+
+ $controllerinvoker = static::create_controller_invoker($container);
+ $app->getRouteCollector()->setDefaultInvocationStrategy($controllerinvoker);
+
+ return $app;
+ }
+
+ /**
+ * Create a controller invoker
+ *
+ * @param ContainerInterface $container
+ * @return InvocationStrategyInterface
+ */
+ protected static function create_controller_invoker(ContainerInterface $container): InvocationStrategyInterface {
+ $resolvers = [
+ // Inject parameters by name first.
+ new AssociativeArrayResolver(),
+
+ // Then inject services by type-hints for those that weren't resolved.
+ new TypeHintContainerResolver($container),
+
+ // Then fall back on parameters default values for optional route parameters.
+ new DefaultValueResolver(),
+ ];
+
+ $invoker = new Invoker(new ResolverChain($resolvers), $container);
+
+ return new controller_invoker(
+ container: $container,
+ invoker: $invoker,
+ );
+ }
+}
diff --git a/lib/classes/router/callable_resolver.php b/lib/classes/router/callable_resolver.php
new file mode 100644
index 00000000000..5eb79f8f0f1
--- /dev/null
+++ b/lib/classes/router/callable_resolver.php
@@ -0,0 +1,106 @@
+.
+
+namespace core\router;
+
+use Invoker\Exception\NotCallableException;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use Slim\Interfaces\AdvancedCallableResolverInterface;
+
+// phpcs:disable moodle.NamingConventions.ValidVariableName.VariableNameLowerCase
+// phpcs:disable moodle.NamingConventions.ValidFunctionName.LowercaseMethod
+
+/**
+ * Resolve middleware and route callables using PHP-DI.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class callable_resolver implements AdvancedCallableResolverInterface {
+ /**
+ * Create a new instance of the Callable Resolver.
+ *
+ * @param \Invoker\CallableResolver $callableresolver The DI Callable Resolver instance
+ */
+ public function __construct(
+ /** @var \Invoker\CallableResolver The DI Callable Resolver instance */
+ protected \Invoker\CallableResolver $callableresolver,
+ ) {
+ }
+
+ #[\Override]
+ public function resolve($toResolve): callable {
+ return $this->callableresolver->resolve($this->translate_notation($toResolve));
+ }
+
+ #[\Override]
+ public function resolveRoute($toResolve): callable {
+ return $this->resolve_possible_signature($toResolve, 'handle', RequestHandlerInterface::class);
+ }
+
+ #[\Override]
+ public function resolveMiddleware($toResolve): callable {
+ return $this->resolve_possible_signature($toResolve, 'process', MiddlewareInterface::class);
+ }
+
+ /**
+ * Translate Slim string callable notation ('nameOrKey:method') to PHP-DI notation ('nameOrKey::method').
+ *
+ * For a full list of supported callables, see the Slim Docs at
+ * https://www.slimframework.com/docs/v4/objects/routing.html#container-resolution.
+ *
+ * @param mixed $toresolve
+ * @return mixed
+ */
+ private function translate_notation(mixed $toresolve): mixed {
+ if (is_string($toresolve) && preg_match(\Slim\CallableResolver::$callablePattern, $toresolve)) {
+ $toresolve = str_replace(':', '::', $toresolve);
+ }
+
+ return $toresolve;
+ }
+
+ /**
+ * Resolve a possible signature for a callable.
+ *
+ * @param mixed $toresolve The callable to resolve
+ * @param string $method The method to resolve
+ * @param string $typename The type name to resolve
+ */
+ private function resolve_possible_signature(
+ mixed $toresolve,
+ string $method,
+ string $typename,
+ ): callable {
+ if (is_string($toresolve)) {
+ $toresolve = $this->translate_notation($toresolve);
+
+ try {
+ $callable = $this->callableresolver->resolve([$toresolve, $method]);
+
+ if (is_array($callable) && $callable[0] instanceof $typename) {
+ return $callable;
+ }
+ } catch (NotCallableException $e) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
+ // Fall back to looking for a generic callable.
+ }
+ }
+
+ return $this->callableresolver->resolve($toresolve);
+ }
+}
diff --git a/lib/classes/router/controller_invoker.php b/lib/classes/router/controller_invoker.php
new file mode 100644
index 00000000000..a3d480d85ef
--- /dev/null
+++ b/lib/classes/router/controller_invoker.php
@@ -0,0 +1,98 @@
+.
+
+namespace core\router;
+
+use Invoker\InvokerInterface;
+use Psr\Container\ContainerInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Controller Invoker for the Moodle Router.
+ *
+ * This class handles invocation of the route callable, and the conversion of the response into an appropriate format.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class controller_invoker implements \Slim\Interfaces\InvocationStrategyInterface {
+ /**
+ * Create a new controller invoker.
+ *
+ * @param ContainerInterface $container
+ * @param InvokerInterface $invoker
+ */
+ public function __construct(
+ /** @var ContainerInterface The DI container */
+ protected ContainerInterface $container,
+ /** @var InvokerInterface The invoker */
+ protected InvokerInterface $invoker,
+ ) {
+ }
+
+ #[\Override]
+ public function __invoke(
+ callable $callable,
+ ServerRequestInterface $request,
+ ResponseInterface $response,
+ array $routeArguments, // phpcs:ignore moodle.NamingConventions.ValidVariableName.VariableNameLowerCase
+ ): ResponseInterface {
+ // Inject the request and response by parameter name.
+ $parameters = [
+ 'request' => self::inject_route_arguments(
+ $request,
+ $routeArguments, // phpcs:ignore moodle.NamingConventions.ValidVariableName.VariableNameLowerCase
+ ),
+ 'response' => $response,
+ ];
+
+ // Inject the route arguments by name.
+ $parameters += $routeArguments; // phpcs:ignore moodle.NamingConventions.ValidVariableName.VariableNameLowerCase
+
+ // Inject the attributes defined on the request.
+ $parameters += $request->getAttributes();
+
+ $result = $this->invoker->call($callable, $parameters);
+
+ return $this->container->get(response_handler::class)->standardise_response($result);
+ }
+
+ /**
+ * Helper to inject route arguments.
+ *
+ * This is based on the ControllerInvoker.
+ *
+ * @param ServerRequestInterface $request
+ * @param array $routeargs
+ * @return ServerRequestInterface
+ */
+ private static function inject_route_arguments(
+ ServerRequestInterface $request,
+ array $routeargs,
+ ): ServerRequestInterface {
+ $args = $request;
+ foreach ($routeargs as $key => $value) {
+ // Note: This differs to upstream where route args always override attributes.
+ // We apply mapped parameters via route attributes and must therefore override the route args.
+ if (!$args->getAttribute($key)) {
+ $args = $args->withAttribute($key, $value);
+ }
+ }
+ return $args;
+ }
+}
diff --git a/lib/classes/router/hook_callbacks.php b/lib/classes/router/hook_callbacks.php
new file mode 100644
index 00000000000..44bf7c2ae21
--- /dev/null
+++ b/lib/classes/router/hook_callbacks.php
@@ -0,0 +1,49 @@
+.
+
+namespace core\router;
+
+/**
+ * Class hook_callbacks
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class hook_callbacks {
+ /**
+ * Provide DI Configuration for the Router system.
+ *
+ * @param \core\hook\di_configuration $hook
+ * @codeCoverageIgnore
+ */
+ public static function provide_di_configuration(
+ \core\hook\di_configuration $hook,
+ ): void {
+ $hook->add_definition(
+ request_validator_interface::class,
+ \DI\get(request_validator::class),
+ );
+ $hook->add_definition(
+ response_validator_interface::class,
+ \DI\get(response_validator::class),
+ );
+ $hook->add_definition(
+ route_loader_interface::class,
+ \DI\get(route_loader::class),
+ );
+ }
+}
diff --git a/lib/classes/router/middleware/cors_middleware.php b/lib/classes/router/middleware/cors_middleware.php
new file mode 100644
index 00000000000..eac27b58033
--- /dev/null
+++ b/lib/classes/router/middleware/cors_middleware.php
@@ -0,0 +1,47 @@
+.
+
+namespace core\router\middleware;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use Slim\Routing\RouteContext;
+
+/**
+ * Middleware to add CORS headers to the response.
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class cors_middleware implements MiddlewareInterface {
+ #[\Override]
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
+ $routecontext = RouteContext::fromRequest($request);
+ $routingresults = $routecontext->getRoutingResults();
+ $methods = $routingresults->getAllowedMethods();
+
+ $response = $handler->handle($request);
+ return $response
+ ->withHeader('Content-Type', 'application/json')
+ ->withHeader('Content-Disposition', 'inline')
+ ->withHeader('Access-Control-Allow-Origin', '*')
+ ->withHeader('Access-Control-Allow-Methods', implode(',', $methods))
+ ->withHeader('Access-Control-Allow-Headers', 'Content-Type, api_key, Authorization');
+ }
+}
diff --git a/lib/classes/router/middleware/error_handling_middleware.php b/lib/classes/router/middleware/error_handling_middleware.php
new file mode 100644
index 00000000000..ea44ebcb254
--- /dev/null
+++ b/lib/classes/router/middleware/error_handling_middleware.php
@@ -0,0 +1,61 @@
+.
+
+namespace core\router\middleware;
+
+use core\router\response_handler;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+
+/**
+ * Middleware to handle errors in a route callable.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class error_handling_middleware implements MiddlewareInterface {
+ /**
+ * Create a new instance of the error handling middleware.
+ *
+ * @param response_handler $responsehandler A handler to standardise a response
+ */
+ public function __construct(
+ /** @var response_handler A handler to standardise a response */
+ protected response_handler $responsehandler,
+ ) {
+ }
+
+ #[\Override]
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
+ try {
+ $response = $handler->handle($request);
+ } catch (\Exception $e) {
+ // @codeCoverageIgnoreStart
+ if (defined('ABORT_AFTER_CONFIG') && !defined('ABORT_AFTER_CONFIG_CANCEL')) {
+ define('ABORT_AFTER_CONFIG_CANCEL', true);
+ require(__DIR__ . '/../../../setup.php');
+ }
+ // @codeCoverageIgnoreEnd
+
+ $response = $this->responsehandler->get_response_from_exception($request, $e);
+ }
+
+ return $response;
+ }
+}
diff --git a/lib/classes/router/middleware/moodle_bootstrap_middleware.php b/lib/classes/router/middleware/moodle_bootstrap_middleware.php
new file mode 100644
index 00000000000..004cfc5f784
--- /dev/null
+++ b/lib/classes/router/middleware/moodle_bootstrap_middleware.php
@@ -0,0 +1,101 @@
+.
+
+namespace core\router\middleware;
+
+use core\router\util;
+use core\router\route_loader_interface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+
+/**
+ * Middleware to set flags and define setup.
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class moodle_bootstrap_middleware implements MiddlewareInterface {
+ #[\Override]
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
+ global $PAGE;
+
+ if (str_contains($request->getUri(), route_loader_interface::ROUTE_GROUP_API)) {
+ // @codeCoverageIgnoreStart
+ if (!defined('AJAX_SCRIPT')) {
+ define('AJAX_SCRIPT', true);
+ }
+ // @codeCoverageIgnoreEnd
+ }
+
+ $routeattribute = util::get_route_instance_for_request($request);
+ if ($routeattribute && !$routeattribute->cookies) {
+ // @codeCoverageIgnoreStart
+ // This request should not access Moodle cookies.
+ if (!defined('NO_MOODLE_COOKIES')) {
+ define('NO_MOODLE_COOKIES', true);
+ }
+ // @codeCoverageIgnoreEnd
+ }
+
+ if (!$routeattribute || !$routeattribute->abortafterconfig) {
+ // Do not load the full Moodle stack. This is a lightweight request.
+ $this->load_full_moodle();
+ }
+
+ $PAGE->set_url((string) $request->getUri());
+
+ return $handler->handle($request);
+ }
+
+ /**
+ * Check whether Moodle is fully loaded.
+ *
+ * @return bool
+ * @codeCoverageIgnore
+ */
+ public function is_full_moodle_loaded(): bool {
+ if (defined('ABORT_AFTER_CONFIG')) {
+ return defined('ABORT_AFTER_CONFIG_CANCEL');
+ }
+
+ return true;
+ }
+
+ /**
+ * Load the full Moodle Framework.
+ *
+ * @codeCoverageIgnore
+ */
+ protected function load_full_moodle(): void {
+ // Note: These globals should be defined even if they are not used as they are used in the require.
+ global $CFG, $DB, $SESSION, $OUTPUT, $PAGE;
+
+ if ($this->is_full_moodle_loaded()) {
+ return;
+ }
+
+ // Ok, now we need to start normal moodle script, we need to load all libs and $DB.
+ if (defined('ABORT_AFTER_CONFIG_CANCEL') && ABORT_AFTER_CONFIG_CANCEL) {
+ return;
+ }
+ define('ABORT_AFTER_CONFIG_CANCEL', true);
+
+ require("{$CFG->dirroot}/lib/setup.php");
+ }
+}
diff --git a/lib/classes/router/middleware/moodle_route_attribute_middleware.php b/lib/classes/router/middleware/moodle_route_attribute_middleware.php
new file mode 100644
index 00000000000..8dbd4e20614
--- /dev/null
+++ b/lib/classes/router/middleware/moodle_route_attribute_middleware.php
@@ -0,0 +1,39 @@
+.
+
+namespace core\router\middleware;
+
+use core\router\route;
+use core\router\util;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+
+/**
+ * Middleware to add the Moodle route attribute.
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class moodle_route_attribute_middleware implements MiddlewareInterface {
+ #[\Override]
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
+ $routeattribute = util::get_route_instance_for_request($request);
+ return $handler->handle($request->withAttribute(route::class, $routeattribute));
+ }
+}
diff --git a/lib/classes/router/middleware/uri_normalisation_middleware.php b/lib/classes/router/middleware/uri_normalisation_middleware.php
new file mode 100644
index 00000000000..453834a4933
--- /dev/null
+++ b/lib/classes/router/middleware/uri_normalisation_middleware.php
@@ -0,0 +1,61 @@
+.
+
+namespace core\router\middleware;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+
+/**
+ * Middleware to normalise the URI path.
+ *
+ * This middleware will:
+ * - remove duplicate /
+ * - remove any trailing /
+ * - ensure that there is a leading /
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class uri_normalisation_middleware implements MiddlewareInterface {
+ #[\Override]
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
+ $uri = $request->getUri();
+ $path = $uri->getPath();
+
+ // Remove duplicate slashes.
+ $path = preg_replace('@/+@', '/', $path);
+
+ // Remove trailing slashes.
+ $path = rtrim($path, '/');
+
+ // Ensure that there is always a path.
+ // Note: This must be performed after handling removal of duplicate and trailing slashes.
+ if ($path === '') {
+ $path = '/';
+ }
+
+ if ($uri->getPath() !== $path) {
+ // Path has changed. Update it.
+ $request = $request->withUri($uri->withPath($path));
+ }
+
+ return $handler->handle($request);
+ }
+}
diff --git a/lib/classes/router/middleware/validation_middleware.php b/lib/classes/router/middleware/validation_middleware.php
new file mode 100644
index 00000000000..ce1eead6e97
--- /dev/null
+++ b/lib/classes/router/middleware/validation_middleware.php
@@ -0,0 +1,72 @@
+.
+
+namespace core\router\middleware;
+
+use core\router\request_validator_interface;
+use core\router\response_handler;
+use core\router\response_validator_interface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+
+/**
+ * Middleware to handle validation of request and response based on the route data.
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class validation_middleware implements MiddlewareInterface {
+ /**
+ * Create a new instance of the validation middleware.
+ *
+ * @param response_handler $responsehandler A handler to standardise a response
+ * @param request_validator_interface $requestvalidator A request validator
+ * @param response_validator_interface $responsevalidator A response validator
+ */
+ public function __construct(
+ /** @var response_handler A handler to standardise a response */
+ protected response_handler $responsehandler,
+
+ /** @var request_validator_interface The request validator used to validate incoming data */
+ protected request_validator_interface $requestvalidator,
+
+ /** @var response_validator_interface The response validator used to validate incoming data */
+ protected response_validator_interface $responsevalidator,
+ ) {
+ }
+
+ #[\Override]
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
+ try {
+ $request = $this->requestvalidator->validate_request($request);
+ } catch (\Exception $e) {
+ return $this->responsehandler->get_response_from_exception($request, $e);
+ }
+
+ $response = $handler->handle($request);
+
+ try {
+ $this->responsevalidator->validate_response($request, $response);
+ } catch (\Exception $e) {
+ return $this->responsehandler->get_response_from_exception($request, $e);
+ }
+
+ return $response;
+ }
+}
diff --git a/lib/classes/router/parameters/header_language.php b/lib/classes/router/parameters/header_language.php
new file mode 100644
index 00000000000..2941d769c2b
--- /dev/null
+++ b/lib/classes/router/parameters/header_language.php
@@ -0,0 +1,69 @@
+.
+
+namespace core\router\parameters;
+
+use core\param;
+use core\router\schema\example;
+use core\router\schema\referenced_object;
+
+/**
+ * A header to accept an optional language for the requested content.
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class header_language extends \core\router\schema\parameters\header_object implements referenced_object {
+ /**
+ * Create a new path_component parameter.
+ *
+ * @param string $name The name of the parameter to use for the component name
+ * @param mixed ...$extra Additional arguments
+ */
+ public function __construct(
+ string $name = 'language',
+ ...$extra,
+ ) {
+ global $CFG;
+
+ $extra['name'] = $name;
+ $extra['type'] = param::LANG;
+ $extra['description'] = 'The language of the requested response.';
+
+ // Generally speaking, the default language should be the site default.
+ // This is a value which is usually stored in DB, so we have a fallback for when the full
+ // Moodle configuration has not been loaded.
+ $extra['default'] = $CFG->lang ?? 'en';
+
+ $extra['examples'] = [
+ new example(
+ name: 'Site default',
+ value: null,
+ ),
+ new example(
+ name: 'English',
+ value: 'en',
+ ),
+ new example(
+ name: 'Deutsch (kids)',
+ value: 'de_kids',
+ ),
+ ];
+
+ parent::__construct(...$extra);
+ }
+}
diff --git a/lib/classes/router/parameters/path_component.php b/lib/classes/router/parameters/path_component.php
new file mode 100644
index 00000000000..0140e0e2725
--- /dev/null
+++ b/lib/classes/router/parameters/path_component.php
@@ -0,0 +1,65 @@
+.
+
+namespace core\router\parameters;
+
+use core\param;
+use core\router\schema\example;
+use core\router\schema\referenced_object;
+
+/**
+ * A component path parameter.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class path_component extends \core\router\schema\parameters\path_parameter implements referenced_object {
+ /**
+ * Create a new path_component parameter.
+ *
+ * @param string $name The name of the parameter to use for the component name
+ * @param mixed ...$extra Additional arguments
+ */
+ public function __construct(
+ string $name = 'component',
+ ...$extra,
+ ) {
+ $extra['name'] = $name;
+ $extra['type'] = param::COMPONENT;
+ $extra['description'] = 'The name of a Moodle component, in frankenstyle format.';
+ $extra['examples'] = [
+ new example(
+ name: 'The core subsystem',
+ value: 'core',
+ ),
+ new example(
+ name: 'The Course subsystem',
+ value: 'core_course',
+ ),
+ new example(
+ name: 'An activity module',
+ value: 'mod_assign',
+ ),
+ new example(
+ name: 'An assignment subplugin',
+ value: 'assignsubmission_file',
+ ),
+ ];
+
+ parent::__construct(...$extra);
+ }
+}
diff --git a/lib/classes/router/parameters/path_course.php b/lib/classes/router/parameters/path_course.php
new file mode 100644
index 00000000000..8db8f9d1f83
--- /dev/null
+++ b/lib/classes/router/parameters/path_course.php
@@ -0,0 +1,135 @@
+.
+
+namespace core\router\parameters;
+
+use core\exception\not_found_exception;
+use core\param;
+use core\router\schema\example;
+use core\router\schema\parameters\mapped_property_parameter;
+use core\router\schema\referenced_object;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * A Moodle parameter referenced in the path.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class path_course extends \core\router\schema\parameters\path_parameter implements
+ mapped_property_parameter,
+ referenced_object
+{
+ /**
+ * Create a new path_course parameter.
+ *
+ * @param string $name The name of the parameter to use for the course identifier
+ * @param mixed ...$extra Additional arguments
+ */
+ public function __construct(
+ string $name = 'course',
+ ...$extra,
+ ) {
+ $extra['name'] = $name;
+ $extra['type'] = param::RAW;
+ $extra['description'] = <<get_record('course', [
+ 'id' => $value,
+ ]);
+ } else if (str_starts_with($value, 'idnumber:')) {
+ $data = $DB->get_record('course', [
+ 'idnumber' => substr($value, strlen('idnumber:')),
+ ]);
+ } else if (str_starts_with($value, 'name:')) {
+ $data = $DB->get_record('course', [
+ 'shortname' => substr($value, strlen('name:')),
+ ]);
+ }
+
+ if ($data) {
+ return $data;
+ }
+
+ throw new not_found_exception('course', $value);
+ }
+
+ #[\Override]
+ public function add_attributes_for_parameter_value(
+ ServerRequestInterface $request,
+ string $value,
+ ): ServerRequestInterface {
+ $course = $this->get_course_for_value($value);
+
+ return $request
+ ->withAttribute($this->name, $course)
+ ->withAttribute("{$this->name}context", \core\context\course::instance($course->id));
+ }
+
+ #[\Override]
+ public function get_schema_from_type(param $type): \stdClass {
+ $schema = parent::get_schema_from_type($type);
+
+ $schema->pattern = "^(";
+ $schema->pattern .= implode("|", [
+ '\d+',
+ 'idnumber:.+',
+ 'name:.+',
+ ]);
+ $schema->pattern .= ")$";
+
+ return $schema;
+ }
+}
diff --git a/lib/classes/router/parameters/path_themename.php b/lib/classes/router/parameters/path_themename.php
new file mode 100644
index 00000000000..fe4bf79668e
--- /dev/null
+++ b/lib/classes/router/parameters/path_themename.php
@@ -0,0 +1,58 @@
+.
+
+namespace core\router\parameters;
+
+use core\param;
+use core\router\schema\referenced_object;
+use core\router\schema\example;
+
+/**
+ * Routing parameter for validation.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class path_themename extends \core\router\schema\parameters\path_parameter implements referenced_object {
+ /**
+ * Create a new path_themename parameter.
+ *
+ * @param string $name The name of the parameter to use for the theme name
+ * @param mixed ...$args Additional arguments
+ */
+ public function __construct(
+ string $name = 'themename',
+ ...$args,
+ ) {
+ $args['name'] = $name;
+
+ $args['type'] = param::ALPHANUMEXT;
+ $args['description'] = 'The name of a Moodle theme.';
+ $args['examples'] = [
+ new example(
+ name: 'The Boost theme',
+ value: 'boost',
+ ),
+ new example(
+ name: 'The Classic theme',
+ value: 'classic',
+ ),
+ ];
+
+ parent::__construct(...$args);
+ }
+}
diff --git a/lib/classes/router/parameters/path_user.php b/lib/classes/router/parameters/path_user.php
new file mode 100644
index 00000000000..3d448063d2e
--- /dev/null
+++ b/lib/classes/router/parameters/path_user.php
@@ -0,0 +1,143 @@
+.
+
+namespace core\router\parameters;
+
+use core\exception\not_found_exception;
+use core\param;
+use core\user;
+use core\router\schema\example;
+use core\router\schema\parameters\mapped_property_parameter;
+use core\router\schema\referenced_object;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * A parameter representing a user.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class path_user extends \core\router\schema\parameters\path_parameter implements
+ mapped_property_parameter,
+ referenced_object
+{
+ /**
+ * Create a new instance of the path_user.
+ *
+ * @param string $name The name of the parameter to use for the identifier
+ * @param mixed ...$extra Additional arguments
+ */
+ public function __construct(
+ string $name = 'user',
+ ...$extra,
+ ) {
+ $extra['name'] = $name;
+ $extra['type'] = param::RAW;
+ $extra['description'] = <<get_user_for_value($value);
+
+ $request = $request->withAttribute($this->name, $user);
+
+ if ($user->id) {
+ $request = $request->withAttribute("{$this->name}context", \core\context\user::instance($user->id));
+ }
+
+ return $request;
+ }
+
+ #[\Override]
+ public function get_schema_from_type(param $type): \stdClass {
+ $schema = parent::get_schema_from_type($type);
+
+ $schema->pattern = "^(";
+ $schema->pattern .= implode("|", [
+ 'current',
+ '\d+',
+ 'idnumber:.+',
+ 'username:.+',
+ ]);
+ $schema->pattern .= ")$";
+
+ return $schema;
+ }
+}
diff --git a/lib/classes/router/request_validator.php b/lib/classes/router/request_validator.php
new file mode 100644
index 00000000000..d37f2cc150d
--- /dev/null
+++ b/lib/classes/router/request_validator.php
@@ -0,0 +1,197 @@
+.
+
+namespace core\router;
+
+use invalid_parameter_exception;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Exception\HttpNotFoundException;
+use Slim\Interfaces\RouteInterface;
+use Slim\Routing\RouteContext;
+
+/**
+ * Routing attribute.
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class request_validator implements request_validator_interface {
+ /**
+ * Validate the request content.
+ *
+ * @param ServerRequestInterface $request
+ * @return ServerRequestInterface
+ */
+ public function validate_request(
+ ServerRequestInterface $request,
+ ): ServerRequestInterface {
+ $moodleroute = $request->getAttribute(route::class);
+ if (!$moodleroute) {
+ return $request;
+ }
+
+ // Add a Route middleware to validate the path, and parameters.
+ $slimroute = RouteContext::fromRequest($request)->getRoute();
+
+ // Validate that the path arguments are valid.
+ // If they are not, then an Exception should be thrown.
+ $request = $this->validate_path($request, $moodleroute, $slimroute);
+
+ // Validate query parameters.
+ $request = $this->validate_query($request, $moodleroute);
+
+ // Validate request headers.
+ $request = $this->validate_request_header($request, $moodleroute);
+
+ // Validate request body parameters.
+ // Found in POST, PUT, DELETE, etc.
+ $request = $this->validate_request_body($request, $moodleroute);
+
+ return $request;
+ }
+
+ /**
+ * Validate that the path arguments match those supplied in the route.
+ *
+ * @param ServerRequestInterface $request
+ * @param route $moodleroute
+ * @param RouteInterface $slimroute The route to validate.
+ * @return ServerRequestInterface
+ * @throws \coding_exception
+ */
+ protected function validate_path(
+ ServerRequestInterface $request,
+ route $moodleroute,
+ RouteInterface $slimroute,
+ ): ServerRequestInterface {
+ $requiredparams = count(array_filter(
+ $moodleroute->get_path_parameters(),
+ fn ($pathtype) => $pathtype->is_required($moodleroute),
+ ));
+ if ($requiredparams > count($slimroute->getArguments())) {
+ throw new \coding_exception(sprintf(
+ "Route %s has %d arguments, but %d pathtypes were specified.",
+ $slimroute->getPattern(),
+ count($slimroute->getArguments()),
+ count($moodleroute->get_path_parameters()),
+ ));
+ }
+
+ foreach ($moodleroute->get_path_parameters() as $pathtype) {
+ try {
+ $request = $pathtype->validate($request, $slimroute);
+ } catch (invalid_parameter_exception $e) {
+ throw new HttpNotFoundException($request, $e->getMessage());
+ }
+ }
+
+ return $request;
+ }
+
+ /**
+ * Validate that the query parameters match those supplied in the route.
+ *
+ * @param ServerRequestInterface $request
+ * @param route $moodleroute
+ * @return ServerRequestInterface
+ */
+ protected function validate_query(
+ ServerRequestInterface $request,
+ route $moodleroute,
+ ): ServerRequestInterface {
+ $requestparams = $request->getQueryParams();
+ $paramnames = array_map(
+ fn ($param) => $param->get_name($this),
+ $moodleroute->get_query_parameters(),
+ );
+
+ // Check for any undeclared parameters.
+ $unknownparams = array_diff(
+ array_keys($requestparams),
+ $paramnames,
+ );
+
+ // Remove these from the URL.
+ // They will still be accessible via optional_param.
+ $request = $request->withQueryParams(
+ array_diff_key(
+ $requestparams,
+ array_flip($unknownparams),
+ ),
+ );
+
+ foreach ($moodleroute->get_query_parameters() as $queryparam) {
+ $request = $queryparam->validate($request, $request->getQueryParams());
+ }
+
+ return $request;
+ }
+
+ /**
+ * Validate that the request headers match the schema.
+ *
+ * @param ServerRequestInterface $request
+ * @param route $moodleroute
+ * @return ServerRequestInterface
+ */
+ protected function validate_request_header(
+ ServerRequestInterface $request,
+ route $moodleroute,
+ ): ServerRequestInterface {
+ $headerparams = $moodleroute->get_header_parameters();
+
+ foreach ($headerparams as $headerparam) {
+ $request = $headerparam->validate($request);
+ }
+
+ return $request;
+ }
+
+ /**
+ * Validate that the request body matches the schema.
+ *
+ * @param ServerRequestInterface $request
+ * @param route $moodleroute
+ * @return ServerRequestInterface
+ */
+ protected function validate_request_body(
+ ServerRequestInterface $request,
+ route $moodleroute,
+ ): ServerRequestInterface {
+ if ($moodleroute->get_request_body() === null) {
+ // Clear the parsed body if there should not be one.
+ return $request->withParsedBody([]);
+ }
+
+ $bodyconfig = $moodleroute->get_request_body()->get_body_for_request($request);
+ $bodyschema = $bodyconfig->get_schema();
+
+ $parsedbody = $request->getParsedBody();
+ if (empty($parsedbody)) {
+ if ($moodleroute->get_request_body()->is_required()) {
+ throw new invalid_parameter_exception('Missing request body.');
+ }
+
+ // No body to validate.
+ return $request;
+ }
+
+ return $request->withParsedBody(
+ $bodyschema->validate_data($request->getParsedBody()),
+ );
+ }
+}
diff --git a/lib/classes/router/request_validator_interface.php b/lib/classes/router/request_validator_interface.php
new file mode 100644
index 00000000000..8668c50218b
--- /dev/null
+++ b/lib/classes/router/request_validator_interface.php
@@ -0,0 +1,38 @@
+.
+
+namespace core\router;
+
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Routing attribute.
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface request_validator_interface {
+ /**
+ * Validate the request content.
+ *
+ * @param ServerRequestInterface $request
+ * @return ServerRequestInterface
+ */
+ public function validate_request(
+ ServerRequestInterface $request,
+ ): ServerRequestInterface;
+}
diff --git a/lib/classes/router/response/access_denied_response.php b/lib/classes/router/response/access_denied_response.php
new file mode 100644
index 00000000000..7199f675ad6
--- /dev/null
+++ b/lib/classes/router/response/access_denied_response.php
@@ -0,0 +1,36 @@
+.
+
+namespace core\router\response;
+
+/**
+ * A response for when access is denied to a resource.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class access_denied_response extends exception_response {
+ #[\Override]
+ public static function get_exception_status_code(): int {
+ return 403;
+ }
+
+ #[\Override]
+ protected static function get_response_description(): string {
+ return 'Access was denied to the resource.';
+ }
+}
diff --git a/lib/classes/router/response/empty_response.php b/lib/classes/router/response/empty_response.php
new file mode 100644
index 00000000000..9e1929d8bce
--- /dev/null
+++ b/lib/classes/router/response/empty_response.php
@@ -0,0 +1,37 @@
+.
+
+namespace core\router\response;
+
+use core\router\schema\referenced_object;
+
+/**
+ * A standard empty 204 response.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class empty_response extends \core\router\schema\response\response implements
+ referenced_object
+{
+ public function __construct(...$args) {
+ parent::__construct(
+ statuscode: 204,
+ description: 'A successful response with no content.',
+ );
+ }
+}
diff --git a/lib/classes/router/response/exception_response.php b/lib/classes/router/response/exception_response.php
new file mode 100644
index 00000000000..8d850097ae0
--- /dev/null
+++ b/lib/classes/router/response/exception_response.php
@@ -0,0 +1,135 @@
+.
+
+namespace core\router\response;
+
+use core\param;
+use core\router\schema\objects\scalar_type;
+use core\router\schema\objects\schema_object;
+use core\router\schema\objects\stacktrace;
+use core\router\schema\referenced_object;
+use core\router\schema\response\content\payload_response_type;
+use core\router\schema\response\payload_response;
+use GuzzleHttp\Psr7\Response;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * A standard response for user preferences.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class exception_response extends \core\router\schema\response\response implements
+ referenced_object
+{
+ /**
+ * Constructor for a new exception-related response.
+ */
+ public function __construct() {
+ parent::__construct(
+ statuscode: static::get_exception_status_code(),
+ description: static::get_response_description(),
+ content: new payload_response_type(
+ schema: static::get_response_schema(),
+ ),
+ );
+ }
+
+ /**
+ * Get the response for the exception.
+ *
+ * @param ServerRequestInterface $request
+ * @param \Exception $exception
+ * @param mixed[] ...$extra
+ * @return payload_response
+ */
+ public static function get_response(
+ ServerRequestInterface $request,
+ \Exception $exception,
+ ...$extra,
+ ): payload_response {
+ return new payload_response(
+ payload: static::get_payload_data($exception, ...$extra),
+ request: $request,
+ response: new Response(
+ status: static::get_exception_status_code(),
+ body: $exception->getMessage(),
+ reason: explode("\n", $exception->getMessage())[0],
+ ),
+ );
+ }
+
+ /**
+ * Get the schema for the response.
+ *
+ * @return schema_object
+ */
+ protected static function get_response_schema(): schema_object {
+ return new schema_object(
+ content: [
+ 'message' => new scalar_type(
+ type: param::ALPHANUMEXT,
+ description: 'The message of the exception.',
+ ),
+ 'errorcode' => new scalar_type(
+ type: param::ALPHANUMEXT,
+ description: 'The error code of the exception.',
+ ),
+ 'stacktrace' => new stacktrace(),
+ ],
+ );
+ }
+
+ /**
+ * The status code that this exception should return.
+ *
+ * @return int
+ */
+ protected static function get_exception_status_code(): int {
+ return 500;
+ }
+
+ /**
+ * Get the description of this response.
+ *
+ * @return string
+ */
+ abstract protected static function get_response_description(): string;
+
+ /**
+ * Get the response payload data.
+ *
+ * @param \Exception $exception
+ * @param mixed ...$extra
+ * @return array
+ */
+ protected static function get_payload_data(
+ \Exception $exception,
+ ...$extra,
+ ): array {
+ $data = [
+ 'message' => $exception->getMessage(),
+ 'stacktrace' => $exception->getTrace(),
+ ];
+
+ if (is_a($exception, \moodle_exception::class)) {
+ $data['errorcode'] = $exception->errorcode;
+ }
+
+ return $data;
+ }
+}
diff --git a/lib/classes/router/response/invalid_parameter_response.php b/lib/classes/router/response/invalid_parameter_response.php
new file mode 100644
index 00000000000..778b6f918f4
--- /dev/null
+++ b/lib/classes/router/response/invalid_parameter_response.php
@@ -0,0 +1,36 @@
+.
+
+namespace core\router\response;
+
+/**
+ * A standard response for user preferences.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class invalid_parameter_response extends exception_response {
+ #[\Override]
+ public static function get_exception_status_code(): int {
+ return 400;
+ }
+
+ #[\Override]
+ protected static function get_response_description(): string {
+ return 'The parameter provided was invalid in some way.';
+ }
+}
diff --git a/lib/classes/router/response/not_found_response.php b/lib/classes/router/response/not_found_response.php
new file mode 100644
index 00000000000..0f5b21c1343
--- /dev/null
+++ b/lib/classes/router/response/not_found_response.php
@@ -0,0 +1,36 @@
+.
+
+namespace core\router\response;
+
+/**
+ * A standard response for user preferences.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class not_found_response extends exception_response {
+ #[\Override]
+ public static function get_exception_status_code(): int {
+ return 404;
+ }
+
+ #[\Override]
+ protected static function get_response_description(): string {
+ return 'The item was not found.';
+ }
+}
diff --git a/lib/classes/router/response_handler.php b/lib/classes/router/response_handler.php
new file mode 100644
index 00000000000..8fe96df96d1
--- /dev/null
+++ b/lib/classes/router/response_handler.php
@@ -0,0 +1,103 @@
+.
+
+namespace core\router;
+
+use core\exception\invalid_parameter_exception;
+use core\exception\response_aware_exception;
+use core\router;
+use core\router\response\exception_response;
+use core\router\response\invalid_parameter_response;
+use core\router\schema\response\response_type;
+use Psr\Container\ContainerInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Controller Invoker for the Moodle Router.
+ *
+ * This class handles invocation of the route callable, and the conversion of the response into an appropriate format.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class response_handler {
+ /**
+ * Create a new response handler.
+ *
+ * @param ContainerInterface $container
+ */
+ public function __construct(
+ /** @var ContainerInterface */
+ private readonly ContainerInterface $container,
+ ) {
+ }
+
+ /**
+ * Invoke a route callable.
+ *
+ * Note: Much of this is copied from the parent class, but we need to handle the response differently.
+ *
+ * @param ResponseInterface|response_type $response The response object.
+ * @return ResponseInterface The response from the callable.
+ */
+ public function standardise_response(
+ ResponseInterface | response_type $response,
+ ): ResponseInterface {
+ if ($response instanceof ResponseInterface) {
+ // An object implementing ResponseInterface is returned, so we can just return it.
+ return $response;
+ }
+
+ $responsefactory = $this->container->get(router::class)->get_response_factory();
+
+ // This must be a response\response_type.
+ return $response->get_response($responsefactory);
+ }
+
+ /**
+ * Get the response from an exception.
+ *
+ * @param ServerRequestInterface $request
+ * @param \Exception $exception
+ *
+ * @return ResponseInterface
+ */
+ public function get_response_from_exception(
+ ServerRequestInterface $request,
+ \Exception $exception,
+ ): ResponseInterface {
+ $response = match (true) {
+ // Newer exceptions may be response-aware, so we can use the response class they specify.
+ (is_a($exception, response_aware_exception::class)) => $exception->get_response_classname()::get_response(
+ $request,
+ $exception,
+ ),
+
+ // Some legacy expressions are here for the moment.
+ is_a($exception, invalid_parameter_exception::class) => invalid_parameter_response::get_response(
+ $request,
+ $exception,
+ ),
+
+ // Otherwise use the default.
+ default => exception_response::get_response($request, $exception),
+ };
+
+ return $this->standardise_response($response);
+ }
+}
diff --git a/lib/classes/router/response_validator.php b/lib/classes/router/response_validator.php
new file mode 100644
index 00000000000..2eb28c0bf23
--- /dev/null
+++ b/lib/classes/router/response_validator.php
@@ -0,0 +1,50 @@
+.
+
+namespace core\router;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Response Validator.
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class response_validator implements response_validator_interface {
+ #[\Override]
+ public function validate_response(
+ ServerRequestInterface $request,
+ ResponseInterface $response,
+ ): void {
+ $moodleroute = $request->getAttribute(route::class);
+ if (!$moodleroute) {
+ return;
+ }
+
+ $expectedresponse = $moodleroute->get_response_with_status_code($response->getStatusCode());
+ if (!$expectedresponse) {
+ // Decide what we should do here.
+ // Probably just throw heaps of debugging information.
+ // Maybe Except with debugging enabled.
+ return;
+ } else {
+ $expectedresponse->validate($response);
+ }
+ }
+}
diff --git a/lib/classes/router/response_validator_interface.php b/lib/classes/router/response_validator_interface.php
new file mode 100644
index 00000000000..67c2bd1216c
--- /dev/null
+++ b/lib/classes/router/response_validator_interface.php
@@ -0,0 +1,40 @@
+.
+
+namespace core\router;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Response Validator interface.
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface response_validator_interface {
+ /**
+ * Validate the request content.
+ *
+ * @param ServerRequestInterface $request
+ * @param ResponseInterface $response
+ */
+ public function validate_response(
+ ServerRequestInterface $request,
+ ResponseInterface $response,
+ ): void;
+}
diff --git a/lib/classes/router/route.php b/lib/classes/router/route.php
new file mode 100644
index 00000000000..b2050f5717a
--- /dev/null
+++ b/lib/classes/router/route.php
@@ -0,0 +1,318 @@
+.
+
+namespace core\router;
+
+use core\exception\coding_exception;
+use core\router\schema\parameter;
+use core\router\schema\response\response;
+use core\router\schema\request_body;
+use Attribute;
+
+/**
+ * Routing attribute.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
+class route {
+ /** @var string[] The list of HTTP Methods */
+ protected null|array $method = null;
+
+ /**
+ * The parent route, if relevant.
+ *
+ * A method-level route may have a class-level route as a parent. The two are combined to provide
+ * a fully-qualified path.
+ *
+ * @var route|null
+ */
+ protected readonly ?route $parentroute;
+
+ /**
+ * Constructor for a new Moodle route.
+ *
+ * @param string $title A title to briefly describe the route (not translated)
+ * @param string $description A verbose explanation of the operation behavior (not translated)
+ * @param string $summary A short summary of what the operation does (not translated)
+ * @param null|string[] $security A list of security mechanisms
+ * @param null|string $path The path to match
+ * @param null|array|string $method The method, or methods, supported
+ * @param parameter[] $pathtypes Validators for the path arguments
+ * @param parameter[] $queryparams Validators for the path arguments
+ * @param parameter[] $headerparams Validators for the path arguments
+ * @param request_body|null $requestbody Validators for the path arguments
+ * @param response[] $responses A list of possible response types
+ * @param bool $deprecated Whether this endpoint is deprecated
+ * @param string[] $tags A list of tags
+ * @param bool $cookies Whether this request requires cookies
+ * @param bool $abortafterconfig Whether to abort after configuration
+ * @param mixed[] ...$extra Any additional arguments not yet supported in this version of Moodle
+ * @throws coding_exception
+ */
+ public function __construct(
+ /** @var string A title to briefly describe the route (not translated) */
+ public readonly string $title = '',
+
+ /** @var string A verbose explanation of the operation behavior (not translated) */
+ public readonly string $description = '',
+
+ /** @var string A short summary of what the operation does (not translated) */
+ public readonly string $summary = '',
+
+ /** @var array A list of security mechanisms */
+ public readonly ?array $security = null,
+
+ /**
+ * The path to the route.
+ *
+ * This is relative to the parent route, if one exists.
+ * A route must be set on one, or both, of the class and method level routes.
+ *
+ * @var string|null
+ */
+ public ?string $path = null,
+
+ null|array|string $method = null,
+
+ /** @var parameter[] A list of param types for path arguments */
+ protected readonly array $pathtypes = [],
+
+ /** @var parameter[] A list of query parameters with matching types */
+ protected readonly array $queryparams = [],
+
+ /** @var parameter[] A list of header parameters */
+ protected readonly array $headerparams = [],
+
+ /** @var null|request_body A list of parameters found in the body */
+ public readonly ?request_body $requestbody = null,
+
+ /** @var response[] A list of possible response types */
+ protected readonly array $responses = [],
+
+ /** @var bool Whether this endpoint is deprecated */
+ public readonly bool $deprecated = false,
+
+ /** @var string[] A list of tags */
+ public readonly array $tags = [],
+
+ /** @var bool Whether this request may use cookies */
+ public readonly bool $cookies = true,
+
+ /** @var bool Whether to abort after configuration */
+ public readonly bool $abortafterconfig = false,
+
+ // Note. We do not make use of these extras.
+ // These allow us to add additional arguments in future versions, whilst allowing plugins to use this version.
+ ...$extra,
+ ) {
+ // Normalise the method.
+ if (is_string($method)) {
+ $method = [$method];
+ }
+ $this->method = $method;
+
+ // Validate the query parameters.
+ if (count(array_filter($this->queryparams, fn($pathtype) => !is_a($pathtype, parameter::class)))) {
+ throw new coding_exception('All query parameters must be an instance of \core\router\parameter.');
+ }
+ if (count(array_filter($this->queryparams, fn($pathtype) => $pathtype->get_in() !== 'query'))) {
+ throw new coding_exception('All query parameters must be in the query.');
+ }
+
+ // Validate the path parameters.
+ if (count(array_filter($this->pathtypes, fn($pathtype) => !is_a($pathtype, parameter::class)))) {
+ throw new coding_exception('All path parameters must be an instance of \core\router\parameter.');
+ }
+ if (count(array_filter($this->pathtypes, fn($pathtype) => $pathtype->get_in() !== 'path'))) {
+ throw new coding_exception('All path properties must be in the path.');
+ }
+
+ // Validate the header parameters.
+ if (count(array_filter($this->headerparams, fn($pathtype) => !is_a($pathtype, parameter::class)))) {
+ throw new coding_exception('All path parameters must be an instance of \core\router\parameter.');
+ }
+ if (count(array_filter($this->headerparams, fn($pathtype) => $pathtype->get_in() !== 'header'))) {
+ throw new coding_exception('All header properties must be in the path.');
+ }
+ }
+
+ /**
+ * Set the parent route, usually a Class-level route.
+ *
+ * @param route $parent
+ * @return self
+ */
+ public function set_parent(route $parent): self {
+ $this->parentroute = $parent;
+ return $this;
+ }
+
+ /**
+ * Get the fully-qualified path for this route relative to root.
+ *
+ * This includes the path of any parent route.
+ *
+ * @return string
+ */
+ public function get_path(): string {
+ $path = $this->path ?? '';
+
+ if (isset($this->parentroute)) {
+ $path = $this->parentroute->get_path() . $path;
+ }
+ return $path;
+ }
+
+ /**
+ * Get the list of HTTP methods associated with this route.
+ *
+ * @param null|string[] $default The default methods to use if none are set
+ * @return null|string[]
+ */
+ public function get_methods(?array $default = null): ?array {
+ $methods = $this->method;
+
+ if (isset($this->parentroute)) {
+ $parentmethods = $this->parentroute->get_methods();
+ if ($methods) {
+ $methods = array_unique(
+ array_merge($parentmethods ?? [], $methods),
+ );
+ } else {
+ $methods = $parentmethods;
+ }
+ }
+
+ // If there are no methods from either this attribute or any parent, use the default.
+ $methods = $methods ?? $default;
+
+ if ($methods) {
+ sort($methods);
+ }
+
+ return $methods;
+ }
+
+ /**
+ * Get the list of path parameters, including any from the parent.
+ *
+ * @return array
+ */
+ public function get_path_parameters(): array {
+ $parameters = [];
+
+ if (isset($this->parentroute)) {
+ $parameters = $this->parentroute->get_path_parameters();
+ }
+ foreach ($this->pathtypes as $parameter) {
+ $parameters[$parameter->get_name()] = $parameter;
+ }
+
+ return $parameters;
+ }
+
+ /**
+ * Get the list of path parameters, including any from the parent.
+ *
+ * @return array
+ */
+ public function get_header_parameters(): array {
+ $parameters = [];
+
+ if (isset($this->parentroute)) {
+ $parameters = $this->parentroute->get_header_parameters();
+ }
+ foreach ($this->headerparams as $parameter) {
+ $parameters[$parameter->get_name()] = $parameter;
+ }
+
+ return $parameters;
+ }
+
+ /**
+ * Get the list of path parameters, including any from the parent.
+ *
+ * @return array
+ */
+ public function get_query_parameters(): array {
+ $parameters = [];
+
+ if (isset($this->parentroute)) {
+ $parameters = $this->parentroute->get_query_parameters();
+ }
+ foreach ($this->queryparams as $parameter) {
+ $parameters[$parameter->get_name()] = $parameter;
+ }
+
+ return $parameters;
+ }
+
+ /**
+ * Get the request body for this route.
+ *
+ * @return request_body|null
+ */
+ public function get_request_body(): ?request_body {
+ return $this->requestbody;
+ }
+
+ /**
+ * Whether this route expects a request body.
+ *
+ * @return bool
+ */
+ public function has_request_body(): bool {
+ return $this->requestbody !== null;
+ }
+
+ /**
+ * Get all responses.
+ *
+ * @return response[]
+ */
+ public function get_responses(): array {
+ return $this->responses;
+ }
+
+ /**
+ * Get the response with the specified response code.
+ *
+ * @param int $statuscode
+ * @return response|null
+ */
+ public function get_response_with_status_code(int $statuscode): ?response {
+ foreach ($this->get_responses() as $response) {
+ if ($response->get_status_code() === $statuscode) {
+ return $response;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Whether this route expects any validatable parameters.
+ * That is, any parameter in the path, query params, or the request body.
+ *
+ * @return bool
+ */
+ public function has_any_validatable_parameter(): bool {
+ return count($this->get_path_parameters()) || count($this->get_query_parameters()) || $this->has_request_body();
+ }
+}
diff --git a/lib/classes/router/route_controller.php b/lib/classes/router/route_controller.php
new file mode 100644
index 00000000000..b0764dbb921
--- /dev/null
+++ b/lib/classes/router/route_controller.php
@@ -0,0 +1,139 @@
+.
+
+namespace core\router;
+
+use moodle_url;
+use Psr\Container\ContainerInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * A controller to make it easier to implement a route.
+ *
+ * This controller adds the Container to the constructor which allows controllers to support DI.
+ *
+ * This trait is entirely optional.
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+trait route_controller {
+ /**
+ * Constructor for Route Controllers.
+ *
+ * @param ContainerInterface $container
+ */
+ public function __construct(
+ /** @var ContainerInterface The DI Container */
+ protected ContainerInterface $container,
+ ) {
+ }
+
+ /**
+ * Generate a Page Not Found result.
+ *
+ * @param ServerRequestInterface $request
+ * @param ResponseInterface $response
+ * @return ResponseInterface
+ * @throws \Slim\Exception\HttpNotFoundException
+ */
+ protected function page_not_found(
+ ServerRequestInterface $request,
+ ResponseInterface $response,
+ ): ResponseInterface {
+ throw new \Slim\Exception\HttpNotFoundException($request);
+ }
+
+ /**
+ * Redirect to a URL.
+ *
+ * @param ResponseInterface $response
+ * @param string|moodle_url $url
+ * @return ResponseInterface
+ */
+ protected function redirect(
+ ResponseInterface $response,
+ string|moodle_url $url,
+ ): ResponseInterface {
+ return $response
+ ->withStatus(302)
+ ->withHeader('Location', (string) $url);
+ }
+
+ /**
+ * Redirect to the requested callable.
+ *
+ * @param ServerRequestInterface $request
+ * @param ResponseInterface $response
+ * @param array|callable|string $callable
+ * @param null|array $pathparams
+ * @param null|array $queryparams
+ * @param null|array $excludeparams A list of any parameters to remove the URI during the redirect
+ * @return ResponseInterface
+ */
+ protected function redirect_to_callable(
+ ServerRequestInterface $request,
+ ResponseInterface $response,
+ array|callable|string $callable,
+ ?array $pathparams = null,
+ ?array $queryparams = null,
+ ?array $excludeparams = null,
+ ): ResponseInterface {
+ // Provide defaults for the path and query params if not specified.
+ if ($pathparams === null) {
+ $pathparams = $request->getQueryParams();
+ }
+ if ($queryparams === null) {
+ $queryparams = $request->getQueryParams();
+ }
+
+ // Generate a URI from the callable and the parameters.
+ $url = util::get_path_for_callable(
+ $callable,
+ $pathparams ?? [],
+ $queryparams ?? [],
+ );
+
+ // Remove any params.
+ $url->remove_params($excludeparams);
+
+ return $this->redirect($response, $url);
+ }
+
+ /**
+ * Get a parameter from the query params after validation.
+ *
+ * @param ServerRequestInterface $request
+ * @param string $key
+ * @param mixed $default
+ * @return mixed
+ */
+ protected function get_param(
+ ServerRequestInterface $request,
+ string $key,
+ mixed $default = null,
+ ): mixed {
+ $params = $request->getQueryParams();
+ if (array_key_exists($key, $params)) {
+ return $params[$key];
+ } else {
+ debugging("Missing parameter: $key");
+ }
+
+ return $default;
+ }
+}
diff --git a/lib/classes/router/route_loader.php b/lib/classes/router/route_loader.php
new file mode 100644
index 00000000000..13b5494e21d
--- /dev/null
+++ b/lib/classes/router/route_loader.php
@@ -0,0 +1,83 @@
+.
+
+namespace core\router;
+
+use Slim\App;
+use Slim\Interfaces\RouteGroupInterface;
+use Slim\Routing\RouteCollectorProxy;
+
+/**
+ * Route Loader and Discovery agent.
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class route_loader extends abstract_route_loader implements route_loader_interface {
+ #[\Override]
+ public function configure_routes(App $app): array {
+ return [
+ route_loader_interface::ROUTE_GROUP_API => $this->configure_api_routes($app, route_loader_interface::ROUTE_GROUP_API),
+ ];
+ }
+
+ /**
+ * Configure all API routes.
+ *
+ * @param App $app
+ * @param string $path
+ * @return RouteGroupInterface
+ */
+ protected function configure_api_routes(App $app, string $path): RouteGroupInterface {
+ return $app->group($path, function (
+ RouteCollectorProxy $group,
+ ): void {
+ // Add all API routes located in the route\api L2\L3 namespace.
+ foreach ($this->get_all_api_routes() as $apiroute) {
+ $slimroute = $group->map(...$apiroute);
+ $this->set_route_name_for_callable($slimroute, $apiroute['callable']);
+ }
+
+ // Add the OpenAPI docs route.
+ $callable = [apidocs::class, 'openapi_docs'];
+ $slimroute = $group->get('/openapi.json', $callable);
+ $this->set_route_name_for_callable($slimroute, $callable);
+ });
+ }
+
+ /**
+ * Fetch all API routes.
+ *
+ * Note: This method caches results in MUC.
+ *
+ * @return array[]
+ */
+ protected function get_all_api_routes(): array {
+ $cache = \cache::make('core', 'routes');
+
+ if (!($routes = $cache->get('api_routes'))) {
+ $routes = $this->get_all_routes_in_namespace(
+ namespace: 'route\api',
+ componentpathcallback: $this->normalise_component_path(...),
+ );
+
+ $cache->set('api_routes', $routes);
+ }
+
+ return $routes;
+ }
+}
diff --git a/lib/classes/router/route_loader_interface.php b/lib/classes/router/route_loader_interface.php
new file mode 100644
index 00000000000..f469710792f
--- /dev/null
+++ b/lib/classes/router/route_loader_interface.php
@@ -0,0 +1,43 @@
+.
+
+namespace core\router;
+
+use Slim\App;
+use Slim\Interfaces\RouteGroupInterface;
+use Slim\Interfaces\RouteInterface;
+
+/**
+ * A route loader.
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface route_loader_interface {
+ /** @var string The route path prefix to use for API calls */
+ public const ROUTE_GROUP_API = '/api/rest/v2';
+
+ /**
+ * Configure all routes for the Application.
+ *
+ * This method returns a set of RouteGroupInterface instances for each route prefix.
+ *
+ * @param App $app The application to configure routes for
+ * @return RouteInterface[]|RouteGroupInterface
+ */
+ public function configure_routes(App $app): array;
+}
diff --git a/lib/classes/router/schema/example.php b/lib/classes/router/schema/example.php
new file mode 100644
index 00000000000..9a2bdf0ac76
--- /dev/null
+++ b/lib/classes/router/schema/example.php
@@ -0,0 +1,109 @@
+.
+
+namespace core\router\schema;
+
+use core\exception\coding_exception;
+
+/**
+ * A Response Example Object.
+ *
+ * https://spec.openapis.org/oas/v3.1.0#example-object
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class example extends openapi_base {
+ /**
+ * Create a new example.
+ *
+ * @param string $name The name of the example.
+ * @param string|null $summary A summary of the example.
+ * @param string|null $description A long description fo the example. CommonMark syntax may be used.
+ * @param mixed $value Embedded literal example.
+ * @param string|null $externalvalue A URI that points to the literal example.
+ * @param mixed ...$extra
+ * @throws coding_exception if both the value and externalvalue are null
+ */
+ public function __construct(
+ /** @var string The name of the example */
+ protected string $name,
+ /** @var string|null A summary of the example */
+ protected ?string $summary = null,
+ /** @var string|null A long description fo the example. CommonMark syntax may be used */
+ protected ?string $description = null,
+ /**
+ * Embedded literal example.
+ *
+ * The value field and externalValue field are mutually exclusive.
+ * To represent examples of media types that cannot naturally represented in JSON or YAML,
+ * use a string value to contain the example, escaping where necessary.
+ *
+ * @var mixed
+ */
+ protected mixed $value = null,
+ /**
+ * A URI that points to the literal example.
+ *
+ * This provides the capability to reference examples that cannot easily be included in JSON or YAML documents.
+ * The value field and externalValue field are mutually exclusive. See the rules for resolving Relative References.
+ *
+ * @var string|null
+ */
+ protected ?string $externalvalue = null,
+ ...$extra,
+ ) {
+ if (!($value === null || $externalvalue === null)) {
+ throw new coding_exception('Only one of value or externalvalue can be specified.');
+ }
+
+ parent::__construct(...$extra);
+ }
+
+ /**
+ * Get the name of this example.
+ *
+ * @return string
+ */
+ public function get_name(): string {
+ return $this->name;
+ }
+
+ #[\Override]
+ public function get_openapi_description(
+ specification $api,
+ ?string $path = null,
+ ): ?\stdClass {
+ $data = (object) [];
+
+ if ($this->summary !== null) {
+ $data->summary = $this->summary;
+ }
+
+ if ($this->description !== null) {
+ $data->description = $this->description;
+ }
+
+ if ($this->value !== null) {
+ $data->value = $this->value;
+ } else if ($this->externalvalue !== null) {
+ $data->externalValue = $this->externalvalue;
+ }
+
+ return $data;
+ }
+}
diff --git a/lib/classes/router/schema/header_object.php b/lib/classes/router/schema/header_object.php
new file mode 100644
index 00000000000..c869359cbac
--- /dev/null
+++ b/lib/classes/router/schema/header_object.php
@@ -0,0 +1,46 @@
+.
+
+namespace core\router\schema;
+
+/**
+ * A Header Object.
+ *
+ * https://spec.openapis.org/oas/v3.1.0#headerObject
+ *
+ * The Header Object follows the structure of the Parameter Object with the following changes:
+ *
+ * - name MUST NOT be specified, it is given in the corresponding headers map.
+ * - in MUST NOT be specified, it is implicitly in header.
+ * - All traits that are affected by the location MUST be applicable to a location of header (for example, style).
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class header_object extends parameters\header_object {
+ #[\Override]
+ final public function get_openapi_description(
+ specification $api,
+ ?string $path = null,
+ ): ?\stdClass {
+ $data = parent::get_openapi_description($api);
+ unset($data->in);
+ unset($data->name);
+
+ return $data;
+ }
+}
diff --git a/lib/classes/router/schema/objects/array_of_strings.php b/lib/classes/router/schema/objects/array_of_strings.php
new file mode 100644
index 00000000000..72b9bd9e4f2
--- /dev/null
+++ b/lib/classes/router/schema/objects/array_of_strings.php
@@ -0,0 +1,78 @@
+.
+
+namespace core\router\schema\objects;
+
+use core\param;
+use core\router\schema\referenced_object;
+
+/**
+ * A schema to describe an array of strings.
+ *
+ * TODO: This should really take a param:: type for validation of both name and value.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+final class array_of_strings extends array_of_things implements referenced_object {
+ /**
+ * Create a new array_of_strings schema.
+ *
+ * @param param $keyparamtype The type of the key parameter
+ * @param param $valueparamtype The type of the value parameter
+ * @param mixed ...$extra Additional arguments
+ */
+ public function __construct(
+ /** @var param The type param type for the key */
+ protected param $keyparamtype = param::RAW,
+ /** @var param The type param type for the value */
+ protected param $valueparamtype = param::RAW,
+ ...$extra,
+ ) {
+ $extra['thingtype'] = 'string';
+ parent::__construct(...$extra);
+ }
+
+ #[\Override]
+ public function validate_data(mixed $data) {
+ foreach ($data as $name => $value) {
+ $this->keyparamtype->validate_param(
+ param: $name,
+ debuginfo: $this->get_debug_info_for_validation_failure($this->keyparamtype, $name),
+ );
+ $this->valueparamtype->validate_param(
+ param: $value,
+ debuginfo: $this->get_debug_info_for_validation_failure($this->valueparamtype, $value),
+ );
+ }
+ return $data;
+ }
+
+ /**
+ * Get the debug info for a validation failure.
+ *
+ * @param param $type
+ * @param string $value
+ * @return string
+ */
+ protected function get_debug_info_for_validation_failure(
+ param $type,
+ string $value,
+ ): string {
+ return "The value '{$value}' was not of type {$type->value}.";
+ }
+}
diff --git a/lib/classes/router/schema/objects/array_of_things.php b/lib/classes/router/schema/objects/array_of_things.php
new file mode 100644
index 00000000000..d8d5dc603fc
--- /dev/null
+++ b/lib/classes/router/schema/objects/array_of_things.php
@@ -0,0 +1,93 @@
+.
+
+namespace core\router\schema\objects;
+
+use core\param;
+use core\router\schema\specification;
+
+/**
+ * A schema to describe an array of things. These could be any type, including other schema definitions.
+ *
+ * See https://spec.openapis.org/oas/v3.0.0#model-with-map-dictionary-properties for relevant documentation.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class array_of_things extends type_base {
+ /**
+ * An array of things.
+ *
+ * @param string|type_base|param|null $thingtype The OpenAPI type, or null if any type is allowed.
+ * @param mixed[] ...$extra
+ */
+ public function __construct(
+ /** @var string|type_base|param|null The child item type */
+ protected string|type_base|param|null $thingtype = null,
+ ...$extra,
+ ) {
+ parent::__construct(...$extra);
+ }
+
+ #[\Override]
+ public function get_openapi_description(
+ specification $api,
+ ?string $path = null,
+ ): ?\stdClass {
+ return $this->get_schema();
+ }
+
+ /**
+ * Get the OpenAPI schema for this object.
+ *
+ * @return \stdClass
+ */
+ public function get_schema(): \stdClass {
+ return (object) [
+ 'type' => 'object',
+ 'additionalProperties' => $this->get_additional_properties($this->thingtype),
+ ];
+ }
+
+ #[\Override]
+ public function validate_data(mixed $data) {
+ if (!is_array($data)) {
+ throw new \invalid_parameter_exception('Invalid data type, expected array.');
+ }
+
+ if ($this->thingtype === null) {
+ return $data;
+ }
+
+ if (is_a($this->thingtype, type_base::class)) {
+ $validator = fn ($value) => $this->thingtype->validate_data($value);
+ } else {
+ if (is_string($this->thingtype)) {
+ $param = param::from($this->thingtype);
+ } else {
+ $param = $this->thingtype;
+ }
+
+ $validator = fn ($value) => $param->validate_param($value);
+ }
+
+ foreach ($data as $value) {
+ $validator($value);
+ }
+ return $data;
+ }
+}
diff --git a/lib/classes/router/schema/objects/scalar_type.php b/lib/classes/router/schema/objects/scalar_type.php
new file mode 100644
index 00000000000..0f7214e93ff
--- /dev/null
+++ b/lib/classes/router/schema/objects/scalar_type.php
@@ -0,0 +1,65 @@
+.
+
+namespace core\router\schema\objects;
+
+use core\param;
+use core\router\schema\specification;
+
+/**
+ * A scalar type.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class scalar_type extends type_base {
+ /**
+ * Instantiate a new Scalar Type
+ *
+ * @param param $type The Moodle PARAM_ type
+ * @param bool $required Whether the value is required or not
+ * @param mixed $default The value used if none was supplied (request bodies only)
+ * @param mixed[] ...$extra
+ */
+ public function __construct(
+ /** @var param The type of the parameter content */
+ protected param $type,
+ /** @var bool Whether the value is required or not */
+ protected bool $required = false,
+ /** @var mixed $default The value used if none was supplied (request bodies only) */
+ protected mixed $default = null,
+ ...$extra,
+ ) {
+ parent::__construct(...$extra);
+ }
+
+ #[\Override]
+ public function get_openapi_description(
+ specification $api,
+ ?string $path = null,
+ ): ?\stdClass {
+ return $this->get_schema_from_type($this->type);
+ }
+
+ #[\Override]
+ public function validate_data(mixed $data) {
+ return $this->type->validate_param(
+ param: $data,
+ allownull: $this->required ? NULL_NOT_ALLOWED : NULL_ALLOWED,
+ );
+ }
+}
diff --git a/lib/classes/router/schema/objects/schema_object.php b/lib/classes/router/schema/objects/schema_object.php
new file mode 100644
index 00000000000..5c586bcc5a5
--- /dev/null
+++ b/lib/classes/router/schema/objects/schema_object.php
@@ -0,0 +1,113 @@
+.
+
+namespace core\router\schema\objects;
+
+use core\exception\coding_exception;
+use core\router\schema\specification;
+
+/**
+ * A schema to describe an array of things. These could be any type, including other schema definitions.
+ *
+ * See https://spec.openapis.org/oas/v3.1.0#model-with-map-dictionary-properties for relevant documentation.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class schema_object extends type_base {
+ /**
+ * An array of things.
+ *
+ * @param type_base[] $content The child content
+ * @param mixed[] ...$extra
+ * @throws coding_exception
+ */
+ public function __construct(
+ /** @var type_base[] The child content */
+ protected array $content,
+
+ ...$extra,
+ ) {
+ foreach ($content as $child) {
+ if (!$child instanceof type_base) {
+ throw new coding_exception('Content must be an array of type_base objects');
+ }
+ }
+
+ parent::__construct(...$extra);
+ }
+
+ /**
+ * Whether this schema object has this key as a type.
+ *
+ * @param string $key
+ * @return bool
+ */
+ public function has(string $key): bool {
+ return isset($this->content[$key]);
+ }
+
+ /**
+ * Get the schema object for this key.
+ *
+ * @param string $key
+ * @return type_base
+ */
+ public function get(string $key): type_base {
+ return $this->content[$key];
+ }
+
+ #[\Override]
+ public function validate_data(mixed $data) {
+ foreach ($data as $key => $values) {
+ if (!$this->has($key)) {
+ // We do not know about this one.
+ // Remove it from the params array.
+ $data = array_diff_key(
+ $data,
+ [$key => $values],
+ );
+ continue;
+ }
+
+ // Validate this parameter.
+ $child = $this->content[$key];
+ $data[$key] = $child->validate_data($values);
+ }
+
+ return $data;
+ }
+
+ #[\Override]
+ public function get_openapi_description(
+ specification $api,
+ ?string $path = null,
+ ): ?\stdClass {
+ $data = (object) [
+ 'type' => 'object',
+ 'properties' => (object) [],
+ ];
+
+ foreach ($this->content as $name => $content) {
+ $data->properties->{$name} = $content->get_openapi_schema(
+ $api,
+ );
+ }
+
+ return $data;
+ }
+}
diff --git a/lib/classes/router/schema/objects/stacktrace.php b/lib/classes/router/schema/objects/stacktrace.php
new file mode 100644
index 00000000000..7a14fd3cb97
--- /dev/null
+++ b/lib/classes/router/schema/objects/stacktrace.php
@@ -0,0 +1,128 @@
+.
+
+namespace core\router\schema\objects;
+
+use core\param;
+use core\router\schema\example;
+use core\router\schema\referenced_object;
+use core\router\schema\specification;
+
+/**
+ * A standard response for user preferences.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class stacktrace extends type_base implements referenced_object {
+ /** @var array The stacks in the trace */
+ protected array $content;
+
+ /**
+ * Constructor for a new stacktrace object.
+ */
+ public function __construct() {
+ $this->content = [
+ 'file' => new scalar_type(param::PATH),
+ 'line' => new scalar_type(param::INT),
+ 'function' => new scalar_type(param::RAW),
+ 'args' => new array_of_things(),
+ 'class' => new scalar_type(param::RAW),
+ 'type' => new scalar_type(param::RAW),
+ ];
+
+ $pathroot = '/Users/example/Sites/moodle';
+ parent::__construct(
+ examples: [
+ new example(
+ name: 'A sample stacktrace',
+ value: [
+ [
+ "file" => "{$pathroot}/lib/classes/router/schema/objects/array_of_strings.php",
+ "line" => 48,
+ "function" => "validate_param",
+ "args" => [
+ "string",
+ "int",
+ false,
+ "The value 'string' was not of type string.",
+ ],
+ ],
+ [
+ "file" => "{$pathroot}/lib/classes/router/schema/objects/schema_object.php",
+ "line" => 85,
+ "function" => "validate_data",
+ "class" => "core\\router\\schema\\objects\\array_of_strings",
+ "type" => "->",
+ "args" => [
+ [
+ "additionalProp1" => "string",
+ "additionalProp2" => "string",
+ "additionalProp3" => "string",
+ ],
+ ],
+ ],
+ [
+ "file" => "{$pathroot}/lib/classes/router/route.php",
+ "line" => 264,
+ "function" => "validate_data",
+ "class" => "core\\router\\schema\\objects\\schema_object",
+ "type" => "->",
+ "args" => [
+ [
+ "preferences" => [
+ "additionalProp1" => "string",
+ "additionalProp2" => "string",
+ "additionalProp3" => "string",
+ ],
+ ],
+ ],
+ ],
+ ],
+ ),
+ ],
+ );
+ }
+
+ #[\Override]
+ public function get_openapi_description(
+ specification $api,
+ ?string $path = null,
+ ): ?\stdClass {
+ $additionalproperties = new \stdClass();
+
+ foreach ($this->content as $name => $content) {
+ $additionalproperties->{$name} = $content->get_openapi_description($api, $path);
+ }
+
+ $data = parent::get_openapi_description($api, $path);
+ $data->type = 'array';
+ $data->items = (object) [
+ 'type' => 'object',
+ 'properties' => $additionalproperties,
+ ];
+
+ return $data;
+ }
+
+ #[\Override]
+ public function validate_data($data) {
+ // Do not validate the data at all.
+ // Stacktraces tend to be used with exceptions and we want whatever was passed through to come out.
+ return $data;
+ }
+}
diff --git a/lib/classes/router/schema/objects/type_base.php b/lib/classes/router/schema/objects/type_base.php
new file mode 100644
index 00000000000..537a912ef3c
--- /dev/null
+++ b/lib/classes/router/schema/objects/type_base.php
@@ -0,0 +1,95 @@
+.
+
+namespace core\router\schema\objects;
+
+use core\router\schema\openapi_base;
+use core\router\schema\specification;
+
+/**
+ * Part of the OpenAPI Schema.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class type_base extends openapi_base {
+ /**
+ * Note: We do not implement the $example, because it has been deprecated in OpenApi 3.0.
+ *
+ * @param array $examples
+ * @param mixed[] ...$extra
+ */
+ public function __construct(
+ /** @var array Any examples that may be present for the type */
+ protected array $examples = [],
+ ...$extra,
+ ) {
+ parent::__construct(...$extra);
+ }
+
+ #[\Override]
+ public function get_openapi_description(
+ specification $api,
+ ?string $path = null,
+ ): ?\stdClass {
+ $data = (object) [];
+
+ if (count($this->examples)) {
+ $data->examples = [];
+ foreach ($this->examples as $example) {
+ $data->examples[] = $example->get_openapi_schema(
+ api: $api,
+ );
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Get the additional OpenAPI properties if relevant.
+ *
+ * @param string|null|type_base $type
+ * @return bool|array
+ */
+ protected function get_additional_properties(string|null|type_base $type): bool|array {
+ // The additionalProperties are described here:
+ // https://spec.openapis.org/oas/v3.1.0#schema-object-examples.
+ if ($type === null) {
+ return true;
+ }
+
+ if (is_a($type, self::class)) {
+ // This type is a reference to another schema object.
+ return [
+ '$ref' => $type->get_reference(),
+ ];
+ }
+
+ // TODO MDL-82243: Validate against supported OpenAPI types.
+ return [
+ 'type' => $type,
+ ];
+ }
+
+ /**
+ * Validate the data against the type.
+ *
+ * @param mixed $data
+ */
+ abstract public function validate_data(mixed $data);
+}
diff --git a/lib/classes/router/schema/openapi_base.php b/lib/classes/router/schema/openapi_base.php
new file mode 100644
index 00000000000..a8a61fed2a9
--- /dev/null
+++ b/lib/classes/router/schema/openapi_base.php
@@ -0,0 +1,243 @@
+.
+
+namespace core\router\schema;
+
+use coding_exception;
+use core\param;
+use core\router\schema\objects\type_base;
+use core\router\schema\response\response;
+use stdClass;
+
+/**
+ * A generic part of the OpenAPI Schema object.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class openapi_base {
+ /**
+ * Base constructor which does nothing.
+ *
+ * We keep an $extra parameter here for future-proofing.
+ * This allows named parameters to be used and allows contrib plugins to
+ * make use of parameters in newer versions even if they don't exist in older versions.
+ *
+ * @param mixed ...$extra Extra arguments to allow for future versions of Moodle to add options without breaking plugins
+ */
+ public function __construct(
+ mixed ...$extra,
+ ) {
+ }
+
+ /**
+ * Get the $ref for this class.
+ *
+ * @param bool $qualify Whether to qualify the reference with the #/components/ part.
+ * @return string
+ */
+ public function get_reference(
+ bool $qualify = true,
+ ): string {
+ return static::get_reference_for_class(
+ classname: get_class($this),
+ qualify: $qualify,
+ );
+ }
+
+ /**
+ * Get the OpenAPI data to include in the OpenAPI specification.
+ *
+ * @param specification $api
+ * @param null|string $path
+ * @return null|stdClass
+ * @throws coding_exception
+ */
+ final public function get_openapi_schema(
+ specification $api,
+ ?string $path = null,
+ ): ?stdClass {
+ if (is_a($this, referenced_object::class)) {
+ // This class is a referenced object, so we need to add it to the specification.
+ if (!$api->is_reference_defined($this->get_reference())) {
+ $api->add_component($this);
+ }
+
+ return (object) [
+ '$ref' => $this->get_reference(),
+ ];
+ }
+
+ return $this->get_openapi_description(
+ api: $api,
+ path: $path,
+ );
+ }
+
+ /**
+ * Get the OpenAPI data to include in the OpenAPI specification.
+ *
+ * @param specification $api
+ * @param null|string $path
+ * @return null|stdClass
+ */
+ abstract public function get_openapi_description(
+ specification $api,
+ ?string $path = null,
+ ): ?stdClass;
+
+ /**
+ * Get the $ref a class name.
+ *
+ * https://swagger.io/docs/specification/using-ref/
+ *
+ * @param string $classname The class to get a reference for
+ * @param bool $qualify Whether to qualify the reference with the #/components/ part
+ * @return string The reference
+ * @throws coding_exception
+ */
+ public static function get_reference_for_class(
+ string $classname,
+ bool $qualify = true,
+ ): string {
+ $reference = static::escape_reference($classname);
+ if (!$qualify) {
+ return $reference;
+ }
+
+ // Note: The following list must be kept in-sync with specification::add_component().
+ return match (true) {
+ is_a($classname, header_object::class, true) => static::get_reference_for_header($reference),
+ is_a($classname, parameter::class, true) => static::get_reference_for_parameter($reference),
+ is_a($classname, response::class, true) => static::get_reference_for_response($reference),
+ is_a($classname, example::class, true) => static::get_reference_for_example($reference),
+ is_a($classname, request_body::class, true) => static::get_reference_for_request_body($reference),
+ is_a($classname, type_base::class, true) => static::get_reference_for_schema($reference),
+ default => throw new coding_exception("Class {$classname} is not a schema."),
+ };
+ }
+
+
+ /**
+ * Get the qualified $ref for a parameter.
+ *
+ * @param string $reference
+ * @return string
+ */
+ public static function get_reference_for_header(string $reference): string {
+ return "#/components/headers/{$reference}";
+ }
+
+ /**
+ * Get the qualified $ref for a parameter.
+ *
+ * @param string $reference
+ * @return string
+ */
+ public static function get_reference_for_parameter(string $reference): string {
+ return "#/components/parameters/{$reference}";
+ }
+
+ /**
+ * Get the qualified $ref for a response.
+ *
+ * @param string $reference
+ * @return string
+ */
+ public static function get_reference_for_response(string $reference): string {
+ return "#/components/responses/{$reference}";
+ }
+
+ /**
+ * Get the qualified $ref for an example.
+ *
+ * @param string $reference
+ * @return string
+ */
+ public static function get_reference_for_example(string $reference): string {
+ return "#/components/examples/{$reference}";
+ }
+
+ /**
+ * Get the qualified $ref for a request body.
+ *
+ * @param string $reference
+ * @return string
+ */
+ public static function get_reference_for_request_body(string $reference): string {
+ return "#/components/requestBodies/{$reference}";
+ }
+
+ /**
+ * Get the qualified $ref for a schema.
+ *
+ * @param string $reference
+ * @return string
+ */
+ public static function get_reference_for_schema(string $reference): string {
+ return "#/components/schemas/{$reference}";
+ }
+
+ /**
+ * Escape a reference following rules defined at https://swagger.io/docs/specification/using-ref/.
+ *
+ * @param string $reference
+ * @return string
+ */
+ public static function escape_reference(string $reference): string {
+ // Note https://swagger.io/docs/specification/using-ref/ defines the following replacements:
+ // ~ => ~0
+ // / => ~1
+ // We also add some other replacements:
+ // \ => --
+ // These must be used in all reference names.
+ // See also https://spec.openapis.org/oas/v3.1.0#components-object
+ // And the following regular expression:
+ // ^[a-zA-Z0-9\.\-_]+$.
+ return str_replace(
+ ['~', '/', '\\'],
+ ['~0', '~1', '--'],
+ $reference,
+ );
+ }
+
+ /**
+ * Get the schema for a given type.
+ *
+ * @param param $type
+ * @return stdClass
+ */
+ public function get_schema_from_type(param $type): stdClass {
+ $data = new stdClass();
+
+ $data->type = match ($type) {
+ // OpenAPI uses an extension of the JSON Schema to define both integers and numbers (float).
+ param::INT => 'integer',
+ param::FLOAT => 'number',
+ param::BOOL => 'boolean',
+
+ // All other types are string types and most have a pattern.
+ default => 'string',
+ };
+
+ if ($pattern = $type->get_clientside_expression()) {
+ $data->pattern = $pattern;
+ }
+
+ return $data;
+ }
+}
diff --git a/lib/classes/router/schema/parameter.php b/lib/classes/router/schema/parameter.php
new file mode 100644
index 00000000000..7e0aa0303ed
--- /dev/null
+++ b/lib/classes/router/schema/parameter.php
@@ -0,0 +1,176 @@
+.
+
+namespace core\router\schema;
+
+use core\exception\coding_exception;
+use core\param;
+use core\router\route;
+use core\router\schema\objects\type_base;
+use stdClass;
+
+/**
+ * OpenAPI parameter.
+ *
+ * https://spec.openapis.org/oas/v3.1.0#parameter-object
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class parameter extends openapi_base {
+ /** @var string A query parameter */
+ public const IN_QUERY = 'query';
+
+ /** @var string A header parameter */
+ public const IN_HEADER = 'header';
+
+ /** @var string A URI path parameter */
+ public const IN_PATH = 'path';
+
+ /** @var string A cookie parameter */
+ public const IN_COOKIE = 'cookie';
+
+ /**
+ * Constructor for a Parameter Object.
+ *
+ * @param string $name The name of the parameter. Parameter names are case sensitive.
+ * - If in is "path", the name field MUST correspond to a template expression occurring within the
+ * path field in the Paths Object.
+ * See Path Templating for further information.
+ * - If in is "header" and the name field is "Accept", "Content-Type" or "Authorization",
+ * the parameter definition SHALL be ignored.
+ * - For all other cases, the name corresponds to the parameter name used by the in property.
+ * @param string $in The location of the parameter. Possible values are "query", "header", "path" or "cookie".
+ * @param null|string $description
+ * @param null|bool $required
+ * @param null|bool $deprecated Specifies that a parameter is deprecated and SHOULD be transitioned out of usage.
+ * @param null|param $type A Moodle parameter type, which can be used instead of a schema.
+ * @param mixed $default The default value
+ * @param null|type_base $schema
+ * @param null|example $example
+ * @param example[] $examples
+ * @param mixed[] ...$extra
+ * @throws coding_exception
+ */
+ public function __construct(
+ /** @var string The name of the parameter. Parameter names are case sensitive */
+ protected string $name,
+ /** @var string The location of the parameter */
+ protected string $in,
+ /** @var string|null A description of the parameter */
+ protected ?string $description = null,
+ /** @var bool|null Whether the parameter is required */
+ protected ?bool $required = null,
+ /** @var bool|null Whether the parameter is deprecated */
+ protected ?bool $deprecated = false,
+ /** @var param|null A Moodle parameter type */
+ protected ?param $type = null,
+ /** @var mixed|null The default value of the parameter */
+ protected mixed $default = null,
+ /** @var type_base|null The schema */
+ protected ?type_base $schema = null,
+ /** @var example|null An example */
+ protected ?example $example = null,
+ /** @var example[] An array of examples */
+ protected array $examples = [],
+ ...$extra,
+ ) {
+ if ($example) {
+ if (count($examples)) {
+ throw new coding_exception('Only one of example or examples can be specified.');
+ }
+ $this->examples[$example->get_name()] = $example;
+ }
+
+ if ($required === true && $default !== null) {
+ throw new coding_exception('A parameter cannot be required and have a default value.');
+ }
+
+ parent::__construct(...$extra);
+ }
+
+ #[\Override]
+ public function get_openapi_description(
+ specification $api,
+ ?string $path = null,
+ ): ?stdClass {
+ $data = (object) [
+ // The `name`, and `in` values are required.
+ 'name' => $this->name,
+ 'in' => $this->in,
+ ];
+
+ if ($this->description !== null) {
+ $data->description = $this->description;
+ }
+
+ // Allow another schema to be passed.
+ if ($this->schema !== null) {
+ $data->schema = $this->schema->get_openapi_schema($api, $path);
+ } else {
+ $data->schema = $this->get_schema_from_type($this->type);
+ }
+
+ if (count($this->examples) > 0) {
+ $data->examples = [];
+ foreach ($this->examples as $example) {
+ $data->examples[$example->get_name()] = $example->get_openapi_schema(
+ api: $api,
+ );
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Get the OpenAPI 'in' property.
+ *
+ * @return string
+ */
+ public function get_in(): string {
+ return $this->in;
+ }
+
+ /**
+ * Fetch the underlying param.
+ *
+ * @return param
+ */
+ public function get_type(): param {
+ return $this->type;
+ }
+
+ /**
+ * Whether this property is required.
+ *
+ * @param route $route
+ * @return bool
+ */
+ public function is_required(route $route): bool {
+ return $this->required ?? false;
+ }
+
+ /**
+ * Get the name of the parameter.
+ *
+ * @return string
+ */
+ public function get_name(): string {
+ return $this->name;
+ }
+}
diff --git a/lib/classes/router/schema/parameters/header_object.php b/lib/classes/router/schema/parameters/header_object.php
new file mode 100644
index 00000000000..e5028528a1c
--- /dev/null
+++ b/lib/classes/router/schema/parameters/header_object.php
@@ -0,0 +1,106 @@
+.
+
+namespace core\router\schema\parameters;
+
+use core\exception\invalid_parameter_exception;
+use core\param;
+use core\router\schema\parameter;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * A Header Object.
+ *
+ * https://spec.openapis.org/oas/v3.1.0#headerObject
+ *
+ * The Header Object follows the structure of the Parameter Object with the following changes:
+ *
+ * - name MUST NOT be specified, it is given in the corresponding headers map.
+ * - in MUST NOT be specified, it is implicitly in header.
+ * - All traits that are affected by the location MUST be applicable to a location of header (for example, style).
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class header_object extends parameter {
+ /**
+ * Create a new header object.
+ *
+ * @param bool $multiple Whether this parameter can be specified multiple times.
+ * @param mixed ...$extra Header arguments to pass to the parameter constructor.
+ */
+ public function __construct(
+ /** @var bool Whether multiple instances of this header are supported */
+ protected bool $multiple = false,
+ ...$extra,
+ ) {
+ $extra['in'] = parameter::IN_HEADER;
+ parent::__construct(...$extra);
+ }
+
+ /**
+ * Validate the parameter.
+ *
+ * @param ServerRequestInterface $request The request to validate.
+ * @return ServerRequestInterface The request with the validated parameter.
+ * @throws invalid_parameter_exception If the parameter is invalid.
+ */
+ public function validate(
+ ServerRequestInterface $request,
+ ): ServerRequestInterface {
+ if ($request->hasHeader($this->name)) {
+ $headervalues = $request->getHeader($this->name);
+
+ if (!$this->multiple && count($headervalues) > 1) {
+ throw new invalid_parameter_exception(
+ "The parameter {$this->name} was specified multiple times, but it can only be specified once",
+ );
+ }
+
+ // This parameter was specified. Validate it.
+ if ($this->get_type() === param::BOOL) {
+ $headervalues = array_map(fn ($headervalue) => match ($headervalue) {
+ 'true' => 1,
+ 'false' => 0,
+ default => throw new \ValueError('Invalid boolean value.'),
+ }, $headervalues);
+ return $request->withHeader($this->name, $headervalues);
+ }
+
+ foreach ($headervalues as $headervalue) {
+ $this->type->validate_param($headervalue);
+ }
+
+ return $request;
+ }
+
+ if ($this->required) {
+ throw new invalid_parameter_exception(
+ "A required parameter {$this->name} was not provided and must be specified",
+ );
+ }
+
+ if ($this->default !== null) {
+ // This parameter is optional. Fill the default.
+ return $request->withHeader($this->name, $this->default);
+ }
+
+ // This parameter is optional and there is no default.
+ // Fill a null value.
+ return $request->withHeader($this->name, null);
+ }
+}
diff --git a/lib/classes/router/schema/parameters/mapped_property_parameter.php b/lib/classes/router/schema/parameters/mapped_property_parameter.php
new file mode 100644
index 00000000000..c9a2943d776
--- /dev/null
+++ b/lib/classes/router/schema/parameters/mapped_property_parameter.php
@@ -0,0 +1,40 @@
+.
+
+namespace core\router\schema\parameters;
+
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * An OpenAPI Parameter which supports validation.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface mapped_property_parameter {
+ /**
+ * Add attribute for the current parameter to the request.
+ *
+ * @param ServerRequestInterface $request
+ * @param string $value
+ * @return ServerRequestInterface
+ */
+ public function add_attributes_for_parameter_value(
+ ServerRequestInterface $request,
+ string $value,
+ ): ServerRequestInterface;
+}
diff --git a/lib/classes/router/schema/parameters/path_parameter.php b/lib/classes/router/schema/parameters/path_parameter.php
new file mode 100644
index 00000000000..5cf7e2c9039
--- /dev/null
+++ b/lib/classes/router/schema/parameters/path_parameter.php
@@ -0,0 +1,113 @@
+.
+
+namespace core\router\schema\parameters;
+
+use core\router\route;
+use core\router\schema\parameter;
+use core\router\schema\specification;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Routing\Route as RoutingRoute;
+use stdClass;
+
+/**
+ * Routing parameter for validation.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class path_parameter extends parameter {
+ /**
+ * Create an instance of a new path parameter.
+ *
+ * @param mixed ...$extra Additional parameters for the parameter
+ */
+ public function __construct(
+ ...$extra,
+ ) {
+ $extra['in'] = parameter::IN_PATH;
+ parent::__construct(
+ ...$extra,
+ );
+ }
+
+ /**
+ * Validate the path parameter.
+ *
+ * @param ServerRequestInterface $request
+ * @param RoutingRoute $route
+ * @return ServerRequestInterface The modified request with parameters updated
+ */
+ public function validate(
+ ServerRequestInterface $request,
+ RoutingRoute $route,
+ ): ServerRequestInterface {
+ $args = $route->getArguments();
+
+ $value = $route->getArgument($this->name);
+
+ $this->type->validate_param(
+ param: $value,
+ allownull: NULL_ALLOWED,
+ );
+
+ if (is_a($this, mapped_property_parameter::class)) {
+ // Unfortunately args must be a string, but mapped properties can be an object.
+ // Remove the argument, and instead provide the mapped property as an attribute.
+ unset($args[$this->name]);
+ $route->setArguments($args);
+ $request = $this->add_attributes_for_parameter_value($request, $value);
+ }
+
+ return $request;
+ }
+
+ #[\Override]
+ final public function get_openapi_description(
+ specification $api,
+ ?string $path = null,
+ ): ?stdClass {
+ if ($path && !str_contains($path, "{{$this->name}}")) {
+ // In OpenAPI, Path parameters can never be optional.
+ return null;
+ }
+ $data = parent::get_openapi_description(
+ api: $api,
+ path: $path,
+ );
+ $data->required = true;
+
+ return $data;
+ }
+
+ /**
+ * Check whether this parameter is required for the given route.
+ *
+ * @param route $route
+ * @return bool
+ */
+ public function is_required(route $route): bool {
+ $path = $route->get_path();
+
+ // Find the position of the parameter in the path.
+ $paramposition = strpos($path, '{' . $this->name . '}');
+
+ // If _any_ part of the path before the parameter contains a '[' character, then this _must_ be optional.
+ // A required parameter cannot follow an optional parameter.
+ return !str_contains(substr($path, 0, $paramposition), '[');
+ }
+}
diff --git a/lib/classes/router/schema/parameters/query_parameter.php b/lib/classes/router/schema/parameters/query_parameter.php
new file mode 100644
index 00000000000..e3f7c402206
--- /dev/null
+++ b/lib/classes/router/schema/parameters/query_parameter.php
@@ -0,0 +1,147 @@
+.
+
+namespace core\router\schema\parameters;
+
+use core\exception\coding_exception;
+use core\param;
+use core\router\schema\parameter;
+use core\router\schema\specification;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Routing query parameter for validation.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class query_parameter extends parameter {
+ /**
+ * Query parameter constructor to override the location of the parameter.
+ *
+ * @param bool|null $allowreserved Determines whether the parameter value SHOULD allow reserved characters.
+ * @param array ...$extra
+ */
+ public function __construct(
+ /**
+ * Determines whether the parameter value SHOULD allow reserved characters.
+ *
+ * As defined by [RFC3986], these characters are :/?#[]@!$&'()*+,;= to be included without percent-encoding.
+ * This property only applies to parameters with an in value of query. The default value is false.
+ *
+ * @var bool|null
+ */
+ protected ?bool $allowreserved = null,
+ ...$extra,
+ ) {
+ $extra['in'] = parameter::IN_QUERY;
+ parent::__construct(...$extra);
+ }
+
+ /**
+ * Validate query parameters.
+ *
+ * @param ServerRequestInterface $request
+ * @param array $params
+ * @return ServerRequestInterface
+ * @throws coding_exception
+ * @throws \ValueError
+ */
+ public function validate(
+ ServerRequestInterface $request,
+ array $params,
+ ): ServerRequestInterface {
+ if (array_key_exists($this->name, $params)) {
+ // This parameter was specified. Validate it.
+ if ($this->get_type() === param::BOOL) {
+ match ($params[$this->name]) {
+ 'true' => $params[$this->name] = 1,
+ 'false' => $params[$this->name] = 0,
+ default => throw new \ValueError('Invalid boolean value.'),
+ };
+ }
+ $this->type->validate_param($params[$this->name]);
+
+ return $this->update_request_params(
+ $request,
+ array_merge(
+ $params,
+ [$this->name => $params[$this->name]],
+ ),
+ );
+ }
+
+ if ($this->required) {
+ throw new coding_exception(
+ "A required parameter {$this->name} was not provided and must be specified",
+ );
+ }
+
+ if ($this->default !== null) {
+ // This parameter is optional. Fill the default.
+ return $this->update_request_params(
+ $request,
+ array_merge(
+ $params,
+ [$this->name => $this->default],
+ ),
+ );
+ }
+
+ // This parameter is optional and there is no default.
+ // Fill a null value.
+ return $this->update_request_params(
+ $request,
+ array_merge(
+ $params,
+ [$this->name => null],
+ ),
+ );
+ }
+
+ /**
+ * Update the request parameters.
+ *
+ * @param ServerRequestInterface $request
+ * @param array $params
+ * @return ServerRequestInterface
+ */
+ protected function update_request_params(
+ ServerRequestInterface $request,
+ array $params,
+ ): ServerRequestInterface {
+ return $request->withQueryParams($params);
+ }
+
+ #[\Override]
+ final public function get_openapi_description(
+ specification $api,
+ ?string $path = null,
+ ): ?\stdClass {
+ $data = parent::get_openapi_description($api, $path);
+
+ if ($this->allowreserved) {
+ // Determines whether the parameter value SHOULD allow reserved characters, as defined by [RFC3986]
+ // :/?#[]@!$&'()*+,;=
+ // to be included without percent-encoding.
+ // This property only applies to parameters with an in value of query. The default value is false.
+ $data->allowReserved = $this->allowreserved;
+ }
+
+ return $data;
+ }
+}
diff --git a/lib/classes/router/schema/referenced_object.php b/lib/classes/router/schema/referenced_object.php
new file mode 100644
index 00000000000..92c9d9011a6
--- /dev/null
+++ b/lib/classes/router/schema/referenced_object.php
@@ -0,0 +1,29 @@
+.
+
+namespace core\router\schema;
+
+/**
+ * An OpenAPI Schema Object which is referenced rather than inserted directly.
+ *
+ * This mean that it is inserted into the /components/ part of the schema rather than duplicated.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface referenced_object {
+}
diff --git a/lib/classes/router/schema/request_body.php b/lib/classes/router/schema/request_body.php
new file mode 100644
index 00000000000..d800b087a78
--- /dev/null
+++ b/lib/classes/router/schema/request_body.php
@@ -0,0 +1,141 @@
+.
+
+namespace core\router\schema;
+
+use core\router\schema\response\content\media_type;
+use core\router\schema\response\content\payload_response_type;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Routing request body for validation.
+ *
+ * https://spec.openapis.org/oas/v3.1.0#request-body-object
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class request_body extends openapi_base {
+ /**
+ * Create a new request body.
+ *
+ * @param string $description A brief description of the request body.
+ * @param payload_response_type|payload_response_type[] $content The content of the request body.
+ * @param bool $required Whether the request body is required
+ * @param mixed ...$args Extra args for future compatibility.
+ * @throws \coding_exception if the content is not an instance of media_type.
+ */
+ public function __construct(
+ /**
+ * A brief description of the request body.
+ *
+ * This could contain examples of use. CommonMark syntax MAY be used for rich text representation.
+ * @var string
+ */
+ protected string $description = '',
+
+ /**
+ * The content of the request body.
+ *
+ * @var payload_response_type|media_type[]
+ */
+ protected array|payload_response_type $content = [],
+
+ /** @var bool Whether the request body is required */
+ protected bool $required = false,
+ ...$args,
+ ) {
+ if (!empty($content)) {
+ if (is_array($content)) {
+ foreach ($content as $contentitem) {
+ if (!($contentitem instanceof media_type)) {
+ throw new \coding_exception('Content must be an instance of media_type.');
+ }
+ }
+ }
+ }
+ parent::__construct(...$args);
+ }
+
+ #[\Override]
+ public function get_openapi_description(
+ specification $api,
+ ?string $path = null,
+ ): ?\stdClass {
+ $data = (object) [
+ 'description' => $this->description,
+ 'required' => $this->required,
+ 'content' => [],
+ ];
+
+ if ($this->content instanceof response\content\payload_response_type) {
+ $data->content = $this->content->get_openapi_schema(
+ api: $api,
+ );
+ return $data;
+ }
+
+ foreach ($this->content as $content) {
+ $data->content[$content->get_encoding()] = $content->get_openapi_schema(
+ api: $api,
+ );
+ }
+
+ return $data;
+ }
+
+ /**
+ * Get the relevant body for the specified request.
+ *
+ * Request bodies can be different for different content-types, as noted in the request.
+ *
+ * @param ServerRequestInterface $request
+ * @return media_type
+ * @throws \invalid_parameter_exception
+ */
+ public function get_body_for_request(
+ ServerRequestInterface $request,
+ ): media_type {
+ if ($this->content instanceof payload_response_type) {
+ $content = $this->content->get_media_type_instance(
+ mimetype: $request->getHeaderLine('Content-Type'),
+ required: $this->is_required(),
+ );
+
+ if ($content) {
+ return $content;
+ }
+ } else {
+ foreach ($this->content as $content) {
+ if ($content::get_encoding() === $request->getHeaderLine('Content-Type')) {
+ return $content;
+ }
+ }
+ }
+
+ throw new \invalid_parameter_exception('No matching content type found.');
+ }
+
+ /**
+ * Whether this query parameter is required.
+ *
+ * @return bool
+ */
+ public function is_required(): bool {
+ return $this->required;
+ }
+}
diff --git a/lib/classes/router/schema/response/abstract_response.php b/lib/classes/router/schema/response/abstract_response.php
new file mode 100644
index 00000000000..e080e09f27a
--- /dev/null
+++ b/lib/classes/router/schema/response/abstract_response.php
@@ -0,0 +1,59 @@
+.
+
+namespace core\router\schema\response;
+
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * An abstract response to a request.
+ *
+ * This approach is inspired and based upon slim-routing https://github.com/juliangut/slim-routing
+ * We only need a fraction of this functionality.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class abstract_response implements response_type {
+ /**
+ * Create a new abstract response.
+ *
+ * @param ServerRequestInterface $request The request
+ * @param ResponseInterface|null $response The response
+ */
+ public function __construct(
+ /** @var ServerRequestInterface The Request */
+ public readonly ServerRequestInterface $request,
+ /** @var ResponseInterface|null The Response */
+ public readonly ?ResponseInterface $response = null,
+ ) {
+ }
+
+ #[\Override]
+ public function get_request(): ServerRequestInterface {
+ return $this->request;
+ }
+
+ #[\Override]
+ public function get_response(
+ ResponseFactoryInterface $responsefactory,
+ ): ?ResponseInterface {
+ return $this->response ?? $responsefactory->createResponse();
+ }
+}
diff --git a/lib/classes/router/schema/response/content/json_media_type.php b/lib/classes/router/schema/response/content/json_media_type.php
new file mode 100644
index 00000000000..22d41fc0501
--- /dev/null
+++ b/lib/classes/router/schema/response/content/json_media_type.php
@@ -0,0 +1,31 @@
+.
+
+namespace core\router\schema\response\content;
+
+/**
+ * A JSON Message body.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class json_media_type extends media_type {
+ #[\Override]
+ public static function get_encoding(): string {
+ return 'application/json';
+ }
+}
diff --git a/lib/classes/router/schema/response/content/media_type.php b/lib/classes/router/schema/response/content/media_type.php
new file mode 100644
index 00000000000..7204cbba5d5
--- /dev/null
+++ b/lib/classes/router/schema/response/content/media_type.php
@@ -0,0 +1,128 @@
+.
+
+namespace core\router\schema\response\content;
+
+use core\exception\coding_exception;
+use core\router\schema\example;
+use core\router\schema\openapi_base;
+use core\router\schema\objects\type_base;
+use core\router\schema\specification;
+
+/**
+ * An OpenAPI MediaType.
+ * https://swagger.io/specification/#media-type-object
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class media_type extends openapi_base {
+ /**
+ * Create a new instance of a media_type definition.
+ *
+ * @param type_base|null $schema The OpenAPI Schema to use
+ * @param example|null $example An example of the media type
+ * @param example[] $examples An array of examples of the media type
+ * @param bool $required Whether the media_type is required
+ * @param mixed[] ...$extra
+ * @throws coding_exception
+ */
+ public function __construct(
+ /** @var type_base|null The OpenAPI Schema to use */
+ protected ?type_base $schema = null,
+ /** @var example|null An example of the media type */
+ protected ?example $example = null,
+ /** @var example[] An array of examples of the media type */
+ protected array $examples = [],
+
+ /** @var bool Whether the media_type is required */
+ protected bool $required = false,
+
+ ...$extra,
+ ) {
+ if ($example) {
+ if (count($examples)) {
+ throw new coding_exception('Only one of example or examples can be specified.');
+ }
+ $this->examples[$example->get_name()] = $example;
+ }
+
+ parent::__construct(...$extra);
+ }
+
+ #[\Override]
+ public function get_openapi_description(
+ specification $api,
+ ?string $path = null,
+ ): ?\stdClass {
+ $data = (object) [];
+
+ if ($this->schema) {
+ $data->schema = $this->schema->get_openapi_schema(
+ api: $api,
+ );
+ }
+
+ if (count($this->examples)) {
+ $data->examples = [];
+ foreach ($this->examples as $example) {
+ $data->examples[$example->get_name()] = $example->get_openapi_schema($api);
+ }
+ }
+
+ if ($this->required) {
+ $data->required = true;
+ }
+
+ return $data;
+ }
+
+ /**
+ * Get the schema for this media type.
+ *
+ * @return type_base
+ */
+ public function get_schema(): type_base {
+ return $this->schema;
+ }
+
+ /**
+ * Get the mimetype for this media type.
+ *
+ * @return string
+ */
+ public function get_mimetype(): string {
+ return static::get_encoding();
+ }
+
+ /**
+ * Get the encoding for this media type.
+ *
+ * @return string
+ */
+ abstract public static function get_encoding(): string;
+
+
+ /**
+ * Whether this query parameter is required.
+ *
+ * @return bool
+ */
+ public function is_required(): bool {
+ return $this->required;
+ }
+}
diff --git a/lib/classes/router/schema/response/content/payload_response_type.php b/lib/classes/router/schema/response/content/payload_response_type.php
new file mode 100644
index 00000000000..693bd65c41c
--- /dev/null
+++ b/lib/classes/router/schema/response/content/payload_response_type.php
@@ -0,0 +1,112 @@
+.
+
+namespace core\router\schema\response\content;
+
+use core\router\schema\openapi_base;
+use core\router\schema\specification;
+
+/**
+ * A standard Moodle response for all supported payload types.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class payload_response_type extends openapi_base {
+ /** @var array Arguments to pass the media instantiator */
+ protected array $args;
+
+ /**
+ * Crate a new payload response type.
+ *
+ * @param bool $required Whether this query parameter is required.
+ * @param array ...$args Extra args for future compatibility.
+ */
+ public function __construct(
+ /** @var bool Whether a payload response is required */
+ protected bool $required = false,
+ ...$args,
+ ) {
+ parent::__construct();
+ $this->args = $args;
+ }
+
+ /**
+ * Get the supported content types.
+ *
+ * @return \class-string[]
+ */
+ public function get_supported_content_types(): array {
+ return [
+ json_media_type::class,
+ ];
+ }
+
+ /**
+ * Get a media type instance for the given mimetype.
+ *
+ * @param string|null $mimetype The mimetype to get the instance for.
+ * @param string|null $classname The classname to get the instance for.
+ * @param bool $required Whether the media type is required.
+ * @return media_type|null
+ */
+ public function get_media_type_instance(
+ ?string $mimetype = null,
+ ?string $classname = null,
+ bool $required = false,
+ ): ?media_type {
+ if ($classname) {
+ return new $classname(...$this->args);
+ }
+
+ foreach ($this->get_supported_content_types() as $contenttypeclass) {
+ if (empty($mimetype) || $contenttypeclass::get_encoding() === $mimetype) {
+ $args = $this->args;
+ $args['required'] = $required;
+ return new $contenttypeclass(...$args);
+ }
+ }
+
+ return null; // @codeCoverageIgnore
+ }
+
+ #[\Override]
+ public function get_openapi_description(
+ specification $api,
+ ?string $path = null,
+ ): ?\stdClass {
+ $content = (object) [];
+
+ foreach ($this->get_supported_content_types() as $contenttypeclass) {
+ $contenttype = new $contenttypeclass(...$this->args);
+ $content->{$contenttype->get_mimetype()} = $contenttype->get_openapi_schema(
+ api: $api,
+ );
+ }
+
+ return $content;
+ }
+
+ /**
+ * Whether this query parameter is required.
+ *
+ * @return bool
+ */
+ public function is_required(): bool {
+ return $this->required;
+ }
+}
diff --git a/lib/classes/router/schema/response/payload_response.php b/lib/classes/router/schema/response/payload_response.php
new file mode 100644
index 00000000000..ada5d380edb
--- /dev/null
+++ b/lib/classes/router/schema/response/payload_response.php
@@ -0,0 +1,83 @@
+.
+
+namespace core\router\schema\response;
+
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * A Payload Response for a Routed request.
+ *
+ * This response is a container for a response which contains a set of data.
+ * It is used to pass data from a controller to the routing engine, where it will be formatted into the
+ * response type requested by the client.
+ *
+ * This approach is inspired and based upon slim-routing https://github.com/juliangut/slim-routing
+ * We only need a fraction of this functionality.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class payload_response extends abstract_response {
+ /**
+ * Create a new payload response.
+ *
+ * @param array $payload The payload
+ * @param ServerRequestInterface $request The request
+ * @param ResponseInterface|null $response The response
+ */
+ public function __construct(
+ /** @var array The payload */
+ public readonly array $payload,
+ ServerRequestInterface $request,
+ ?ResponseInterface $response = null,
+ ) {
+ parent::__construct($request, $response);
+ }
+
+ #[\Override]
+ public function get_response(
+ ResponseFactoryInterface $responsefactory,
+ ): ?ResponseInterface {
+ $response = parent::get_response($responsefactory);
+
+ $response->getBody()->write((string) json_encode(
+ $this->payload,
+ $this->get_json_flags(),
+ ));
+ return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
+ }
+
+ /**
+ * Get the flags to use when encoding JSON.
+ *
+ * @return int
+ */
+ private function get_json_flags(): int {
+ global $CFG;
+
+ $flags = \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_PRESERVE_ZERO_FRACTION;
+
+ if ($CFG->debugdeveloper) {
+ $flags |= \JSON_PRETTY_PRINT;
+ }
+
+ return $flags;
+ }
+}
diff --git a/lib/classes/router/schema/response/response.php b/lib/classes/router/schema/response/response.php
new file mode 100644
index 00000000000..bb6cb87f333
--- /dev/null
+++ b/lib/classes/router/schema/response/response.php
@@ -0,0 +1,135 @@
+.
+
+namespace core\router\schema\response;
+
+use core\exception\coding_exception;
+use core\router\schema\openapi_base;
+use core\router\schema\response\content\media_type;
+use core\router\schema\specification;
+use core\router\schema\response\content\payload_response_type;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * An OpenAPI Response.
+ *
+ * https://spec.openapis.org/oas/v3.1.0#response-object
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class response extends openapi_base {
+ /**
+ * Create a new described response.
+ *
+ * @param int $statuscode The status code for this response
+ * @param string $description A description of this response
+ * @param array $headers The headers associated with this response
+ * @param array|payload_response_type $content The content of this response
+ * @param mixed ...$extra Any extra data to store
+ * @throws coding_exception
+ */
+ public function __construct(
+ /** @var int The status code for this response */
+ public readonly int $statuscode = 200,
+ /** @var string A description of this response */
+ public readonly string $description = '',
+ /** @var array The headers associated with this response */
+ private readonly array $headers = [],
+ /** @var array|payload_response_type The content of this response */
+ public readonly array|payload_response_type $content = [],
+ ...$extra,
+ ) {
+ if (is_array($content)) {
+ foreach ($content as $contentitem) {
+ if (!$contentitem instanceof media_type) {
+ throw new coding_exception('Content must be an array of payload response types');
+ }
+ }
+ }
+
+ parent::__construct(...$extra);
+ }
+
+ /**
+ * Validate the response.
+ *
+ * @param ResponseInterface $response The response to validate
+ */
+ public function validate(
+ ResponseInterface $response,
+ ): void {
+ $response;
+ }
+
+ /**
+ * Get the description for this response.
+ *
+ * @return string
+ */
+ protected function get_description(): string {
+ if ($this->description !== '') {
+ return $this->description;
+ }
+
+ return match ($this->statuscode) {
+ 200 => 'OK',
+ default => '',
+ };
+ }
+
+ #[\Override]
+ public function get_openapi_description(
+ specification $api,
+ ?string $path = null,
+ ): ?\stdClass {
+ $data = (object) [
+ 'description' => $this->get_description(),
+ ];
+
+ if (count($this->headers)) {
+ foreach ($this->headers as $header) {
+ $data->headers[$header->get_name()] = $header->get_openapi_schema(
+ api: $api,
+ );
+ }
+ }
+
+ if ($this->content instanceof content\payload_response_type) {
+ $data->content = $this->content->get_openapi_schema(
+ api: $api,
+ );
+ } else if (count($this->content)) {
+ foreach ($this->content as $body) {
+ $data->content[$body->get_mimetype()] = $body->get_openapi_schema(
+ api: $api,
+ );
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Get the status code for this response.
+ *
+ * @return int
+ */
+ public function get_status_code(): int {
+ return $this->statuscode;
+ }
+}
diff --git a/lib/classes/router/schema/response/response_type.php b/lib/classes/router/schema/response/response_type.php
new file mode 100644
index 00000000000..3743af53343
--- /dev/null
+++ b/lib/classes/router/schema/response/response_type.php
@@ -0,0 +1,47 @@
+.
+
+namespace core\router\schema\response;
+
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * An abstract response to a request.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface response_type {
+ /**
+ * Get the Request object for this response.
+ *
+ * @return ServerRequestInterface
+ */
+ public function get_request(): ServerRequestInterface;
+
+ /**
+ * Get the Response object for this response.
+ *
+ * @param ResponseFactoryInterface $responsefactory The response factory
+ * @return null|ResponseInterface
+ */
+ public function get_response(
+ ResponseFactoryInterface $responsefactory,
+ ): ?ResponseInterface;
+}
diff --git a/lib/classes/router/schema/response/view_response.php b/lib/classes/router/schema/response/view_response.php
new file mode 100644
index 00000000000..af408832728
--- /dev/null
+++ b/lib/classes/router/schema/response/view_response.php
@@ -0,0 +1,88 @@
+.
+
+namespace core\router\schema\response;
+
+use GuzzleHttp\Psr7\Utils;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * A response which will render the specified template.
+ *
+ * This approach is inspired and based upon slim-routing https://github.com/juliangut/slim-routing
+ * We only need a fraction of this functionality.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class view_response extends abstract_response {
+ /**
+ * Create a new view response.
+ *
+ * @param string $template The template name
+ * @param array $parameters The parameters to pass
+ * @param ServerRequestInterface $request The request
+ * @param ResponseInterface|null $response The response
+ */
+ public function __construct(
+ /** @var string The template name */
+ private readonly string $template,
+ /** @var array The parameters to pass */
+ private readonly array $parameters,
+ ServerRequestInterface $request,
+ ?ResponseInterface $response = null,
+ ) {
+ parent::__construct($request, $response);
+ }
+
+ /**
+ * Get the template name.
+ *
+ * @return string
+ */
+ public function get_template_name(): string {
+ return $this->template;
+ }
+
+ /**
+ * Get the parameters.
+ *
+ * @return array
+ */
+ public function get_parameters(): array {
+ return $this->parameters;
+ }
+
+ #[\Override]
+ public function get_response(
+ ResponseFactoryInterface $responsefactory,
+ ): ?ResponseInterface {
+ global $OUTPUT;
+
+ $response = parent::get_response($responsefactory);
+ return $response
+ ->withHeader('Content-Type', 'text/html; charset=utf-8')
+ ->withBody(Utils::streamFor(
+ $OUTPUT->render_from_template(
+ $this->get_template_name(),
+ $this->get_parameters(),
+ ),
+ ));
+ }
+}
diff --git a/lib/classes/router/schema/specification.php b/lib/classes/router/schema/specification.php
new file mode 100644
index 00000000000..0bd3488d31e
--- /dev/null
+++ b/lib/classes/router/schema/specification.php
@@ -0,0 +1,521 @@
+.
+
+namespace core\router\schema;
+
+use coding_exception;
+use core\router\response\invalid_parameter_response;
+use core\router\response\not_found_response;
+use core\router\route;
+use core\router\route_loader_interface;
+use core\router\schema\objects\type_base;
+use core\router\schema\response\response;
+use core\url;
+use stdClass;
+
+/**
+ * Moodle OpenApi Specification class.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class specification implements
+ \JsonSerializable
+{
+ /** @var string The OpenAPI version represented in this specification */
+ public const OPENAPI_VERSION = '3.1.0';
+
+ /** @var stdClass The data which forms the specification */
+ protected stdClass $data;
+
+ /** @var bool Whether the data has been finalised for output yet */
+ protected bool $finalised = false;
+
+ /** @var callable[] A list of common responses that are frequently found in paths */
+ protected array $commonresponses = [];
+
+ /**
+ * Constructor to configure base information.
+ */
+ public function __construct() {
+ $this->data = (object) [
+ 'openapi' => self::OPENAPI_VERSION,
+ 'info' => (object) [
+ 'title' => 'Moodle LMS',
+ 'description' => 'REST API for Moodle LMS',
+ 'summary' => 'Moodle LMS REST API',
+ 'license' => (object) [
+ 'name' => 'GNU GPL v3 or later',
+ 'url' => 'https://www.gnu.org/licenses/gpl-3.0.html',
+ ],
+ ],
+
+ // Servers are added during output.
+ 'servers' => [],
+
+ // Paths are added after initialisation.
+ 'paths' => (object) [],
+
+ 'components' => (object) [
+ // Note: This list must be kept in-sync with add_component.
+ 'schemas' => (object) [],
+ 'responses' => (object) [],
+ 'parameters' => (object) [],
+ 'examples' => (object) [],
+ 'requestBodies' => (object) [],
+ 'headers' => (object) [],
+
+ // The add_component method does not support securitySchemes because we hard-code these.
+ 'securitySchemes' => (object) [
+ 'api_key' => (object) [
+ 'type' => 'apiKey',
+ 'name' => 'api_key',
+ 'in' => parameter::IN_HEADER,
+ ],
+ 'cookie' => (object) [
+ 'type' => 'apiKey',
+ 'name' => 'MoodleSession',
+ 'in' => parameter::IN_COOKIE,
+ ],
+ // TODO MDL-82242: Add support for OAuth2.
+ ],
+ ],
+ // TODO MDL-82242: Add support for OAuth2.
+ 'security' => [
+ (object) [
+ 'api_key' => [],
+ 'cookie' => [],
+ ],
+ ],
+ 'externalDocs' => (object) [
+ 'description' => 'Moodle Developer Docs',
+ 'url' => 'https://moodledev.io',
+ ],
+ ];
+
+ $this->generate_common_responses();
+ }
+
+ /**
+ * Generate the callables for common responses that are frequently found in paths.
+ *
+ * @return specification
+ */
+ protected function generate_common_responses(): self {
+ $invalidresponse = new invalid_parameter_response();
+ $notfoundresponse = new not_found_response();
+
+ $this->commonresponses[] = function (
+ route $route,
+ stdClass $data
+ ) use (
+ $invalidresponse,
+ $notfoundresponse,
+ ): stdClass {
+ if ($route->has_any_validatable_parameter()) {
+ if (!array_key_exists($invalidresponse::get_exception_status_code(), $data->responses)) {
+ $data->responses[$invalidresponse::get_exception_status_code()] = $invalidresponse->get_openapi_schema($this);
+ }
+ if (!array_key_exists($notfoundresponse::get_exception_status_code(), $data->responses)) {
+ $data->responses[$notfoundresponse::get_exception_status_code()] = $notfoundresponse->get_openapi_schema($this);
+ }
+ }
+
+ return $data;
+ };
+ return $this;
+ }
+
+ /**
+ * Get the common request responses.
+ *
+ * @return callable[]
+ */
+ public function get_common_request_responses(): array {
+ if (empty($this->commonresponses)) {
+ $this->generate_common_responses(); // @codeCoverageIgnore
+ }
+
+ return $this->commonresponses;
+ }
+
+ /**
+ * Finalise the data and prepare it for consumption.
+ */
+ protected function finalise(): self {
+ global $CFG;
+
+ if ($this->finalised) {
+ return $this;
+ }
+
+ // Add the Moodle site version here.
+ $this->data->info->version = $CFG->version;
+
+ // Add the server configuration.
+ $serverdescription = str_replace("'", "\'", format_string(get_site()->fullname));
+ $this->add_server(
+ url::routed_path(route_loader_interface::ROUTE_GROUP_API)->out(),
+ $serverdescription,
+ );
+
+ $this->finalised = true;
+
+ return $this;
+ }
+
+ /**
+ * Implement the json serialisation interface.
+ *
+ * @return mixed
+ */
+ public function jsonSerialize(): mixed {
+ return $this->get_schema();
+ }
+
+ /**
+ * Get the OpenAPI schema.
+ *
+ * @return stdClass
+ */
+ final public function get_schema(): stdClass {
+ return $this
+ ->finalise()
+ ->data;
+ }
+
+ /**
+ * Add a component to the components object.
+ *
+ * https://spec.openapis.org/oas/v3.1.0#components-object
+ *
+ * Note: The following component types are supported:
+ *
+ * - schemas
+ * - responses
+ * - parameters
+ * - examples
+ * - requestBodies
+ * - headers
+ *
+ * At this time, other component types are not supported.
+ *
+ * @param openapi_base $object
+ * @return specification
+ * @throws coding_exception If the component type is unknown.
+ */
+ public function add_component(openapi_base $object): self {
+ match (true) {
+ is_a($object, header_object::class) => $this->add_header($object),
+ is_a($object, parameter::class) => $this->add_parameter($object),
+ is_a($object, response::class) => $this->add_response($object),
+ is_a($object, example::class) => $this->add_example($object),
+ is_a($object, request_body::class) => $this->add_request_body($object),
+ is_a($object, type_base::class) => $this->add_schema($object),
+ default => throw new coding_exception("Unknown object type."),
+ };
+
+ return $this;
+ }
+
+ /**
+ * Add a server to the specification.
+ *
+ * @param string $url The URL of the API base
+ * @param string $description
+ * @return specification
+ */
+ public function add_server(
+ string $url,
+ string $description,
+ ): self {
+ $this->data->servers[] = (object) [
+ 'url' => $url,
+ 'description' => $description,
+ ];
+
+ return $this;
+ }
+
+ /**
+ * Add an API Path.
+ *
+ * @param string $component The Moodle component
+ * @param route $route The route which handles this request
+ * @return specification
+ */
+ public function add_path(
+ string $component,
+ route $route,
+ ): self {
+ // Compile the final path, complete with component prefix.
+ [$type, $subsystem] = \core_component::normalize_component($component);
+
+ if ($type === 'core') {
+ if ($subsystem) {
+ $path = "/{$subsystem}";
+ } else {
+ $path = "/core";
+ }
+ } else {
+ $path = "/{$component}";
+ }
+ $path .= $route->get_path();
+
+ // Helper to add the path to the specification.
+ // Note: We use this helper because OpenAPI does not support optional parameters.
+ // Therefore we must handle that in Moodle, adding path variants with and without each optional parameter.
+ $addpath = function (string $path) use ($route, $component) {
+ // Remove the optional parameters delimiters from the path.
+ $path = str_replace(
+ ['[', ']'],
+ '',
+ $path,
+ );
+
+ // Get the OpenAPI description for this path with the updated path.
+ $pathdocs = $this->get_openapi_schema_for_route(
+ route: $route,
+ component: $component,
+ path: $path,
+ );
+
+ if (!property_exists($this->data->paths, $path)) {
+ $this->data->paths->$path = (object) [];
+ }
+
+ foreach ((array) $pathdocs as $method => $methoddata) {
+ // Copy each of the pathdocs into place.
+ $this->data->paths->{$path}->{$method} = $methoddata;
+ }
+ };
+
+ // First add the entire path complete with all optional parameters.
+ // The optional parameter delimiters are `[` and `]`, and are removed in `$addpath`.
+ $addpath($path);
+
+ // Check for any optional parameters.
+ // OpenAPI does not support optional parameters so we have to duplicate routes instead.
+ // We can determine if this is optional if there is any `[` character before it in the path.
+ // There can be no required parameter after any optional parameter.
+ $optionalparameters = array_filter(
+ array: $route->get_path_parameters(),
+ callback: fn ($parameter) => !$parameter->is_required($route),
+ );
+
+ if (!empty($optionalparameters)) {
+ // Go through the path from end to start removing optional parameters and adding them to the path list.
+ while (strrpos($path, '[') !== false) {
+ $path = substr($path, 0, strrpos($path, '['));
+ $addpath($path);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add a schema to the shared components section of the specification.
+ *
+ * @param type_base $schema
+ * @return specification
+ */
+ protected function add_schema(
+ type_base $schema,
+ ): self {
+ $name = $schema->get_reference(qualify: false);
+ if (!property_exists($this->data->components->schemas, $name)) {
+ $this->data->components->schemas->$name = $schema->get_openapi_description($this);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add a schema to the shared components section of the specification.
+ *
+ * @param parameter $parameter
+ * @return specification
+ */
+ protected function add_parameter(
+ parameter $parameter,
+ ): self {
+ $name = $parameter->get_reference(qualify: false);
+ $this->data->components->parameters->$name = $parameter->get_openapi_description($this);
+
+ return $this;
+ }
+
+ /**
+ * Add a header to the shared components section of the specification.
+ *
+ * @param header_object $header
+ * @return self
+ */
+ protected function add_header(
+ header_object $header,
+ ): self {
+ $name = $header->get_reference(qualify: false);
+ $this->data->components->headers->$name = $header->get_openapi_description($this);
+
+ return $this;
+ }
+
+ /**
+ * Add a response to the shared components section of the specification.
+ *
+ * @param response $response
+ * @return specification
+ */
+ protected function add_response(
+ response $response,
+ ): self {
+ $name = $response->get_reference(qualify: false);
+ $this->data->components->responses->$name = $response->get_openapi_description($this);
+
+ return $this;
+ }
+
+ /**
+ * Add an example to the shared components section of the specification.
+ *
+ * @param example $example
+ * @return specification
+ */
+ protected function add_example(
+ example $example,
+ ): self {
+ $name = $example->get_reference(qualify: false);
+ $this->data->components->examples->$name = $example->get_openapi_description($this);
+
+ return $this;
+ }
+
+ /**
+ * Add a request body to the shared components section of the specification.
+ *
+ * @param request_body $body
+ * @return specification
+ */
+ protected function add_request_body(
+ request_body $body,
+ ): self {
+ $name = $body->get_reference(qualify: false);
+ $this->data->components->requestBodies->$name = $body->get_openapi_description($this);
+
+ return $this;
+ }
+
+ /**
+ * Check whether a reference is defined
+ *
+ * @param string $ref
+ * @return bool
+ */
+ public function is_reference_defined(
+ string $ref,
+ ): bool {
+ if (!str_starts_with($ref, '#/components/')) {
+ return false;
+ }
+
+ // Remove the leading #/components/ part.
+ $ref = substr($ref, strlen('#/components/'));
+
+ // Split the path and name.
+ [$path, $name] = explode('/', $ref, 2);
+
+ if (!property_exists($this->data->components, $path)) {
+ return false;
+ }
+
+ return property_exists($this->data->components->$path, $name);
+ }
+
+
+ /**
+ * Get the OpenAPI description for this route.
+ *
+ * @param route $route
+ * @param string $component
+ * @param string $path
+ * @return stdClass
+ */
+ public function get_openapi_schema_for_route(
+ route $route,
+ string $component,
+ string $path,
+ ): stdClass {
+ $data = (object) [
+ 'description' => $route->description,
+ 'summary' => $route->title,
+ 'tags' => [$component, ...$route->tags],
+ 'parameters' => [],
+ 'responses' => [],
+ ];
+
+ if ($route->get_request_body()) {
+ $data->requestBody = $route->get_request_body()->get_openapi_schema(
+ api: $this,
+ path: $path,
+ );
+ }
+
+ if ($route->security !== null) {
+ $data->security = $route->security;
+ }
+
+ if ($route->deprecated) {
+ $data->deprecated = true;
+ }
+
+ foreach ($route->get_responses() as $response) {
+ $data->responses[$response->get_status_code()] = $response->get_openapi_schema(
+ api: $this,
+ path: $path,
+ );
+ }
+
+ $data->parameters = array_values(array_filter(
+ array_map(
+ fn($param) => $param->get_openapi_schema(
+ api: $this,
+ path: $path,
+ ),
+ array_merge(
+ $route->get_path_parameters(),
+ $route->get_query_parameters(),
+ $route->get_header_parameters(),
+ ),
+ ),
+ fn($param) => $param !== null,
+ ));
+
+ foreach ($this->get_common_request_responses() as $callable) {
+ $data = $callable($route, $data);
+ }
+
+ $methoddata = [];
+ $methods = $route->get_methods(['GET']);
+
+ foreach ($methods as $method) {
+ $methoddata[strtolower($method)] = $data;
+ }
+
+ return (object) $methoddata;
+ }
+}
diff --git a/lib/classes/router/util.php b/lib/classes/router/util.php
new file mode 100644
index 00000000000..1d9fe8bf6cc
--- /dev/null
+++ b/lib/classes/router/util.php
@@ -0,0 +1,230 @@
+.
+
+namespace core\router;
+
+use moodle_url;
+use GuzzleHttp\Psr7\Uri;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Routing\RouteContext;
+
+/**
+ * Routing Helper Utilities.
+ *
+ * This class includes a variety of helpers for working with routes, including:
+ * - redirectors
+ * - callable to route name converters
+ * - callable to path converters
+ * - helpers to fetch the \core\router\route instance
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class util {
+ /**
+ * Redirect to the specified URL, carrying all parameters across too.
+ *
+ * @param string|moodle_url $path
+ * @param array $excludeparams Any parameters to exclude from the query params
+ * @codeCoverageIgnore
+ */
+ public static function redirect_with_params(
+ string|moodle_url $path,
+ array $excludeparams = [],
+ ): never {
+ $params = $_GET;
+ $url = new moodle_url(
+ $path,
+ $params,
+ );
+ $url->remove_params($excludeparams);
+
+ redirect($url);
+ }
+
+ /**
+ * Redirect to the route at the callable supplied.
+ *
+ * @param callable|array|string $callable
+ * @param array $params Any parameters to include in the path
+ * @codeCoverageIgnore
+ */
+ public static function redirect_to_callable(
+ callable|array|string $callable,
+ array $params = [],
+ ): never {
+ $params = array_merge(
+ $_GET,
+ $params,
+ );
+
+ $url = self::get_path_for_callable($callable, $params, $params);
+
+ redirect($url);
+ }
+
+ /**
+ * Get the route name for the specified callable.
+ *
+ * @param callable|array|string $callable
+ * @return string
+ * @throws \coding_exception If the callable could not be resolved into an Array format
+ */
+ public static function get_route_name_for_callable(
+ callable|array|string $callable,
+ ): string {
+ $resolver = \core\di::get(\Invoker\CallableResolver::class);
+ $callable = $resolver->resolve($callable);
+
+ if (!is_array($callable)) {
+ throw new \coding_exception('Resolved callable must be in array form');
+ }
+
+ return get_class($callable[0]) . '::' . $callable[1];
+ }
+
+ /**
+ * Get the URI path for the specified callable.
+ *
+ * @param string|array|callable $callable the Callable to get the URI for
+ * @param array $params Any parameters to include in the path
+ * @param array $queryparams Any parameters to include in the query string
+ * @return moodle_url
+ */
+ public static function get_path_for_callable(
+ string|array|callable $callable,
+ array $params,
+ array $queryparams,
+ ): moodle_url {
+ global $CFG;
+
+ $router = \core\di::get(\core\router::class);
+ $app = $router->get_app();
+ $parser = $app->getRouteCollector()->getRouteParser();
+
+ $routename = self::get_route_name_for_callable($callable);
+
+ return new moodle_url(
+ url: $parser->fullUrlFor(
+ new Uri($CFG->wwwroot),
+ $routename,
+ $params,
+ $queryparams,
+ ),
+ );
+ }
+
+ /**
+ * Get the route attribute for the specified request.
+ *
+ * @param ServerRequestInterface $request
+ * @return null|route
+ */
+ public static function get_route_instance_for_request(ServerRequestInterface $request): ?route {
+ if ($route = $request->getAttribute(route::class)) {
+ return $route;
+ }
+
+ $context = RouteContext::fromRequest($request);
+ if ($slimroute = $context->getRoute()) {
+ return self::get_route_instance_for_method($slimroute->getCallable());
+ }
+
+ // This should not be encountered - the route should always be set.
+ return null; // @codeCoverageIgnore
+ }
+
+ /**
+ * Get the instance of the \route\router\route attribute for the specified callable if one is available.
+ *
+ * @param callable|array|string $callable
+ * @return null|route The route if one was found.
+ */
+ public static function get_route_instance_for_method(callable|array|string $callable): ?route {
+ // Normalise the callable using the resolver.
+ // This happens in the same way that Slim does so.
+ $resolver = \core\di::get(\Invoker\CallableResolver::class);
+ $callable = $resolver->resolve($callable);
+
+ if (!is_array($callable)) {
+ // The callable could not be resolved into an array.
+ return null;
+ }
+
+ // Locate the Class for this callable.
+ $classinfo = new \ReflectionClass($callable[0]);
+
+ // Locate the method for this callable.
+ $methodinfo = $classinfo->getMethod($callable[1]);
+ if (!$methodinfo) {
+ // The method does not exist. This shouldn't be possible because the resolver will throw an exception.
+ return null; // @codeCoverageIgnore
+ }
+
+ return self::attempt_get_route_instance_for_method($classinfo, $methodinfo);
+ }
+
+ /**
+ * Attempt to get the route instance for the specified method, handling any errors in the code along the way.
+ *
+ * @param \ReflectionClass $classinfo
+ * @param \ReflectionMethod $methodinfo
+ * @return null|route
+ */
+ private static function attempt_get_route_instance_for_method(
+ \ReflectionClass $classinfo,
+ \ReflectionMethod $methodinfo,
+ ): ?route {
+ $instantiator = function (array $attributes) {
+ global $CFG;
+ try {
+ return $attributes ? $attributes[0]->newInstance() : null;
+ // @codeCoverageIgnoreStart
+ } catch (\Throwable $e) {
+ // The route attribute could not be instantiated.
+ // When debugging, this is useful to know.
+ // When not, log to error_log.
+ if (!$CFG->debugdisplay) {
+ debugging('Could not instantiate route attribute: ' . $e->getMessage());
+ return null;
+ }
+
+ default_exception_handler($e);
+ }
+ // @codeCoverageIgnoreEnd
+ };
+
+ $methodattributes = $methodinfo->getAttributes(route::class);
+ $methodroute = $instantiator($methodattributes);
+
+ if (!$methodroute) {
+ // No route found.
+ return null;
+ }
+
+ $classattributes = $classinfo->getAttributes(route::class);
+ if ($classattributes) {
+ $classinstance = $instantiator($classattributes);
+ if ($classinstance) {
+ // The class has a #route attribute.
+ $methodroute->set_parent($classinstance);
+ }
+ }
+
+ return $methodroute;
+ }
+}
diff --git a/lib/classes/tests/route_testcase.php b/lib/classes/tests/route_testcase.php
new file mode 100644
index 00000000000..22b75459d86
--- /dev/null
+++ b/lib/classes/tests/route_testcase.php
@@ -0,0 +1,549 @@
+.
+
+namespace core\tests;
+
+use core\router;
+use core\router\bridge;
+use core\router\mocking_route_loader;
+use core\router\route_loader_interface;
+use core\router\schema\openapi_base;
+use core\router\schema\referenced_object;
+use core\router\schema\specification;
+use stdClass;
+use GuzzleHttp\Psr7\Response;
+use GuzzleHttp\Psr7\ServerRequest;
+use GuzzleHttp\Psr7\Uri;
+use PHPUnit\Framework\ExpectationFailedException;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Message\StreamInterface;
+use Slim\App;
+use Slim\Middleware\RoutingMiddleware;
+use Slim\Routing\Route;
+use Slim\Routing\RouteContext;
+
+/**
+ * Tests for user preference API handler.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class route_testcase extends \advanced_testcase {
+ /**
+ * Update the test route loader using the supplied callback.
+ *
+ * @param callable $modifier
+ */
+ protected function update_test_route_loader(
+ callable $modifier,
+ ): void {
+ self::load_fixture('core', 'router/mocking_route_loader.php');
+
+ $routeloader = \core\di::get(mocking_route_loader::class);
+ $modifier($routeloader);
+ \core\di::set(route_loader_interface::class, $routeloader);
+ }
+
+ /**
+ * Add a route from a class method.
+ *
+ * @param string $classname The class to add the route from
+ * @param string $methodname The method name to add
+ * @param null|string $grouppath The path to the route group
+ */
+ protected function add_route_to_route_loader(
+ string $classname,
+ string $methodname,
+ ?string $grouppath = null,
+ ) {
+ $grouppath = $grouppath ?? $this->guess_group_path_from_classname($classname);
+ $this->update_test_route_loader(fn (mocking_route_loader $routeloader) => $routeloader->mock_route_from_class_method(
+ $grouppath,
+ new \ReflectionMethod($classname, $methodname),
+ ));
+ }
+
+ /**
+ * Add all routes from the specified class to the test loader.
+ *
+ * Only methods within the class with a #[route] attribute will be added.
+ *
+ * @param string $classname The class to add routes from
+ * @param null|string $grouppath The path of the route group
+ */
+ protected function add_class_routes_to_route_loader(
+ string $classname,
+ ?string $grouppath = null,
+ ): void {
+ $this->update_test_route_loader(
+ fn (mocking_route_loader $routeloader) => $routeloader->add_all_routes_in_class(
+ grouppath: $grouppath ?? $this->guess_group_path_from_classname($classname),
+ class: $classname,
+ ),
+ );
+ }
+
+ /**
+ * Guess the group path from a class name.
+ *
+ * @param string $classname
+ * @return string
+ */
+ protected function guess_group_path_from_classname(
+ string $classname,
+ ): string {
+ [, , $l3] = explode('\\', $classname, 4);
+
+ if ($l3 === 'api') {
+ return route_loader_interface::ROUTE_GROUP_API;
+ }
+
+ throw new \coding_exception("Unable to determine route path for '{$classname}'");
+ }
+
+ /**
+ * Mock a route from a route attribute.
+ *
+ * @param string $grouppath
+ * @param \core\router\route $route
+ * @param string $name
+ * @param callable|null $callable
+ */
+ protected function mock_route_from_route_attribute(
+ string $grouppath,
+ \core\router\route $route,
+ string $name = 'route',
+ ?callable $callable = null,
+ ): void {
+ if ($callable === null) {
+ $callable = fn ($request, $response) => $response->withStatus(200);
+ }
+
+ $this->update_test_route_loader(fn (mocking_route_loader $routeloader) => $routeloader->mock_route_from_callable(
+ grouppath: $grouppath,
+ methods: $route->get_methods(['GET']),
+ pattern: $route->get_path(),
+ callable: $callable,
+ name: $name,
+ ));
+ }
+
+ /**
+ * Get a fully-configured instance of the Moodle Routing Application.
+ *
+ * @return App
+ */
+ protected function get_app(): App {
+ $router = $this->get_router();
+
+ return $router->get_app();
+ }
+
+ /**
+ * Get a fully-configured instance of the Moodle Routing Application.
+ *
+ * @param string $basepath The basepath for the router
+ * @return router
+ */
+ protected function get_router(string $basepath = ''): router {
+ \core\di::set(
+ router::class,
+ \DI\autowire(router::class)->constructorParameter('basepath', $basepath),
+ );
+
+ return \core\di::get(router::class);
+ }
+
+ /**
+ * Get an unconfigured instance of the Slim Application.
+ *
+ * @return App
+ */
+ protected function get_simple_app(): App {
+ global $CFG;
+ require_once("{$CFG->libdir}/nikic/fast-route/src/functions.php");
+ $app = bridge::create(
+ container: \core\di::get_container(),
+ );
+
+ return $app;
+ }
+
+ /**
+ * Get the request for a route which is known to the router.
+ *
+ * @param \core\router\route $route
+ * @param string $path
+ * @return ServerRequestInterface
+ */
+ protected function get_request_for_routed_route(
+ \core\router\route $route,
+ string $path,
+ ): ServerRequestInterface {
+ $this->mock_route_from_route_attribute('', $route);
+
+ // Grab just one method.
+ $methods = $route->get_methods();
+ $method = $methods ? reset($methods) : 'GET';
+
+ $request = $this->create_request(
+ method: $method,
+ path: $path,
+ prefix: '',
+ route: $route,
+ );
+
+ $request = $this->route_request(
+ $this->get_app(),
+ $request,
+ );
+
+ return $request;
+ }
+
+ /**
+ * Create a Request object.
+ *
+ * @param string $method
+ * @param string $path
+ * @param string $prefix
+ * @param array $headers
+ * @param array $cookies
+ * @param array $serverparams
+ * @param null|\core\router\route $route
+ * @return ServerRequestInterface
+ */
+ protected function create_request(
+ string $method,
+ string $path,
+ string $prefix = route_loader_interface::ROUTE_GROUP_API,
+ array $headers = ['Content-Type' => 'application/json'],
+ array $cookies = [],
+ array $serverparams = [],
+ ?\core\router\route $route = null,
+ ): ServerRequestInterface {
+ $uri = new Uri($prefix . $path);
+
+ $request = new ServerRequest(
+ method: $method,
+ headers: $headers,
+ uri: $uri,
+ serverParams: $serverparams,
+ );
+
+ // Sadly Guzzle's Uri only deals with query strings, not query params.
+ $query = $uri->getQuery();
+ if ($query) {
+ $queryparams = [];
+ foreach (explode('&', $query) as $queryparam) {
+ [$key, $value] = explode('=', $queryparam, 2);
+ $queryparams[$key] = $value;
+ }
+ $request = $request->withQueryParams($queryparams);
+ }
+
+ if ($route) {
+ $request = $request->withAttribute(\core\router\route::class, $route);
+ }
+
+ return $request
+ ->withCookieParams($cookies);
+ }
+
+ /**
+ * Process a request with the app.
+ *
+ * @param string $method
+ * @param string $path
+ * @param string $prefix
+ * @param array $headers
+ * @param null|StreamInterface $body
+ * @param null|string $contenttype
+ * @param array $cookies
+ * @param array $serverparams
+ * @return ResponseInterface
+ */
+ protected function process_request(
+ string $method,
+ string $path,
+ string $prefix = '',
+ array $headers = ['HTTP_ACCEPT' => 'application/json'],
+ ?StreamInterface $body = null,
+ ?string $contenttype = 'application/json',
+ array $cookies = [],
+ array $serverparams = [],
+ ): ResponseInterface {
+ $app = $this->get_app();
+ if ($contenttype !== null) {
+ $headers['Content-Type'] = $contenttype;
+ }
+ $request = $this->create_request(
+ $method,
+ $path,
+ $prefix,
+ $headers,
+ $cookies,
+ $serverparams,
+ );
+
+ if ($body) {
+ $request = $request->withBody($body);
+ }
+
+ return $app->handle($request);
+ }
+
+ /**
+ * Process a request with the app.
+ *
+ * @param string $method
+ * @param string $path
+ * @param array $headers
+ * @param null|StreamInterface $body
+ * @param array $cookies
+ * @param array $serverparams
+ * @return ResponseInterface
+ */
+ protected function process_api_request(
+ string $method,
+ string $path,
+ array $headers = ['HTTP_ACCEPT' => 'application/json'],
+ ?StreamInterface $body = null,
+ array $cookies = [],
+ array $serverparams = [],
+ ): ResponseInterface {
+ return $this->process_request(
+ method: $method,
+ path: $path,
+ prefix: route_loader_interface::ROUTE_GROUP_API,
+ headers: $headers,
+ body: $body,
+ cookies: $cookies,
+ serverparams: $serverparams,
+ );
+ }
+
+ /**
+ * Route a request within the app.
+ *
+ * @param App $app
+ * @param ServerRequestInterface $request
+ * @return ServerRequestInterface
+ */
+ protected function route_request(
+ App $app,
+ ServerRequestInterface $request,
+ ): ServerRequestInterface {
+ $routingmiddleware = new RoutingMiddleware(
+ $app->getRouteResolver(),
+ $app->getRouteCollector()->getRouteParser(),
+ );
+
+ return $routingmiddleware->performRouting($request);
+ }
+
+ /**
+ * Create a route and route it to create a request.
+ *
+ * @param string $routepath
+ * @param string $requestpath
+ * @return ServerRequestInterface
+ */
+ protected function create_route(
+ string $routepath,
+ string $requestpath,
+ ): ServerRequestInterface {
+ $app = $this->get_simple_app();
+ $app->get($routepath, fn () => new Response());
+ $request = $this->route_request($app, new ServerRequest('GET', $requestpath));
+
+ return $request;
+ }
+
+ /**
+ * Get the Slim Route object from a Request object.
+ *
+ * @param ServerRequestInterface $request
+ * @return Route
+ */
+ protected function get_slim_route_from_request(
+ ServerRequestInterface $request,
+ ): Route {
+ return $request->getAttribute(RouteContext::ROUTE);
+ }
+
+ /**
+ * Assert that a Response object was valid.
+ *
+ * @param ResponseInterface $response
+ * @param null|int $statuscode The expected status code
+ * @throws ExpectationFailedException
+ */
+ protected function assert_valid_response(
+ ResponseInterface $response,
+ ?int $statuscode = 200,
+ ): void {
+ $this->assertInstanceOf(Response::class, $response);
+ $this->assertEquals(
+ $statuscode,
+ $response->getStatusCode(),
+ "Response status code is not $statuscode",
+ );
+ }
+
+ /**
+ * Assert that the supplied response related to an exception.
+ *
+ * @param ResponseInterface $response
+ * @param null|int $responsecode The expected response code
+ */
+ protected function assert_exception_response(
+ ResponseInterface $response,
+ ?int $responsecode = null,
+ ): void {
+ $this->assertInstanceOf(Response::class, $response);
+ $this->assertNotEquals(
+ 200,
+ $response->getStatusCode(),
+ );
+
+ if ($responsecode !== null) {
+ $this->assertEquals(
+ $responsecode,
+ $response->getStatusCode(),
+ );
+ }
+
+ $payload = $this->decode_response($response);
+ $this->assertObjectHasProperty('message', $payload);
+ $this->assertObjectHasProperty('stacktrace', $payload);
+ }
+
+ /**
+ * Assert that the supplied response was an invalid_parameter_exception response.
+ *
+ * @param ResponseInterface $response
+ */
+ protected function assert_invalid_parameter_response(
+ ResponseInterface $response,
+ ): void {
+ $this->assert_exception_response($response, 400);
+
+ $payload = $this->decode_response($response);
+ $this->assertObjectHasProperty('errorcode', $payload);
+ $this->assertEquals('invalidparameter', $payload->errorcode);
+ }
+
+ /**
+ * Assert that the supplied response was an access_denied exception response.
+ *
+ * @param ResponseInterface $response
+ */
+ protected function assert_access_denied_response(
+ ResponseInterface $response,
+ ): void {
+ $this->assert_exception_response($response, 403);
+
+ $payload = $this->decode_response($response);
+ $this->assertObjectHasProperty('errorcode', $payload);
+ }
+
+ /**
+ * Assert that the supplied response was a not_found exception response.
+ *
+ * @param \Psr\Http\Message\ResponseInterface $response
+ */
+ protected function assert_not_found_response(
+ ResponseInterface $response,
+ ): void {
+ $this->assert_exception_response($response, 404);
+
+ $payload = $this->decode_response($response);
+ $this->assertObjectHasProperty('errorcode', $payload);
+ }
+
+ /**
+ * Decode the JSON response for a Response object.
+ *
+ * @param ResponseInterface $response
+ * @param bool $forcearray Force the contents to Array instead of Object
+ * @return stdClass|array
+ */
+ protected function decode_response(
+ ResponseInterface $response,
+ bool $forcearray = false,
+ ): stdClass|array {
+ if ($forcearray) {
+ return json_decode(
+ json: (string) $response->getBody(),
+ associative: true,
+ );
+ } else {
+ return (object) json_decode(
+ json: (string) $response->getBody(),
+ associative: false,
+ flags: JSON_FORCE_OBJECT,
+ );
+ }
+ }
+
+ /**
+ * Get the schema for an OpenAPI Component.
+ *
+ * Components include headers, parameters, responses, examples, requestBodies, and schemas.
+ *
+ * All components are subclasses of the openapi_base class and may be referenced.
+ *
+ * Any component which implements the referenced_object interface will return a reference
+ * to the stored internal object.
+ *
+ * @param specification $api
+ * @param openapi_base $component
+ * @return stdClass|null
+ */
+ protected function get_api_component_schema(
+ specification $api,
+ openapi_base $component,
+ ): ?stdClass {
+ $this->assertInstanceOf(referenced_object::class, $component);
+
+ if (is_a($component, \core\router\schema\header_object::class)) {
+ $type = 'headers';
+ } else if (is_a($component, \core\router\schema\parameter::class)) {
+ $type = 'parameters';
+ } else if (is_a($component, \core\router\schema\response\response::class)) {
+ $type = 'responses';
+ } else if (is_a($component, \core\router\schema\example::class)) {
+ $type = 'examples';
+ } else if (is_a($component, \core\router\schema\request_body::class)) {
+ $type = 'requestBodies';
+ } else if (is_a($component, \core\router\schema\objects\type_base::class)) {
+ $type = 'schemas';
+ } else {
+ $this->fail('Component is not a recognised type');
+ }
+
+ $ref = $component->get_reference(false);
+
+ $schema = $api->get_schema();
+ $components = $schema->components;
+ $component = $components->{$type}->{$ref} ?? null;
+
+ return $component;
+ }
+}
diff --git a/lib/classes/url.php b/lib/classes/url.php
index af29e06d832..d55ee3688db 100644
--- a/lib/classes/url.php
+++ b/lib/classes/url.php
@@ -622,6 +622,28 @@ class url {
return $url;
}
+ /**
+ * Create a new moodle_url instance from routed path.
+ *
+ * @param string $path The routed path
+ * @param null|array $params The path parameters
+ * @param null|string $anchor The anchor
+ * @return self
+ */
+ public static function routed_path(
+ string $path,
+ ?array $params = null,
+ ?string $anchor = null,
+ ): self {
+ global $CFG;
+
+ if (!$CFG->routerconfigured) {
+ $path = '/r.php/' . ltrim($path, '/');
+ }
+ $url = new self($path, $params, $anchor);
+ return $url;
+ }
+
/**
* General moodle file url.
*
diff --git a/lib/classes/user.php b/lib/classes/user.php
index 1f1a0be23ee..9c12cd6aa7e 100644
--- a/lib/classes/user.php
+++ b/lib/classes/user.php
@@ -14,27 +14,30 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see .
-/**
- * User class
- *
- * @package core
- * @copyright 2013 Rajesh Taneja
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
+namespace core;
+use core\context\user as context_user;
+use core\context\course as context_course;
+use core\context\system as context_system;
use core_user\fields;
-
-defined('MOODLE_INTERNAL') || die();
+use core\exception\invalid_parameter_exception;
+use core\exception\moodle_exception;
+use core\exception\coding_exception;
+use core\output\theme_config;
+use core\output\user_picture;
+use core_date;
+use dml_exception;
+use stdClass;
/**
* User class to access user details.
*
- * @todo move api's from user/lib.php and deprecate old ones.
+ * @todo MDL-82650 Move api's from user/lib.php and deprecate old ones.
* @package core
* @copyright 2013 Rajesh Taneja
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class core_user {
+class user {
/**
* No reply user id.
*/
@@ -80,7 +83,7 @@ class core_user {
'firstnamephonetic',
'lastnamephonetic',
'middlename',
- 'alternatename'
+ 'alternatename',
];
/** @var int Indicates that user profile view should be prevented */
@@ -104,7 +107,7 @@ class core_user {
/**
* Return user object from db or create noreply or support user,
- * if userid matches corse_user::NOREPLY_USER or corse_user::SUPPORT_USER
+ * if userid matches \core\user::NOREPLY_USER or \core\user::SUPPORT_USER
* respectively. If userid is not found, then return false.
*
* @param int $userid user id
@@ -128,7 +131,7 @@ class core_user {
return self::get_support_user();
break;
default:
- return $DB->get_record('user', array('id' => $userid), $fields, $strictness);
+ return $DB->get_record('user', ['id' => $userid], $fields, $strictness);
}
}
@@ -153,7 +156,7 @@ class core_user {
$mnethostid = $CFG->mnet_localhost_id;
}
- return $DB->get_record('user', array('email' => $email, 'mnethostid' => $mnethostid), $fields, $strictness);
+ return $DB->get_record('user', ['email' => $email, 'mnethostid' => $mnethostid], $fields, $strictness);
}
/**
@@ -177,7 +180,38 @@ class core_user {
$mnethostid = $CFG->mnet_localhost_id;
}
- return $DB->get_record('user', array('username' => $username, 'mnethostid' => $mnethostid), $fields, $strictness);
+ return $DB->get_record('user', ['username' => $username, 'mnethostid' => $mnethostid], $fields, $strictness);
+ }
+
+ /**
+ * Return User object based on their idnumber.
+ *
+ * @param string $idnumber The idnumber of the user searched.
+ * @param string $fields A comma separated list of user fields to be returned, support and noreply user.
+ * @param null|int $mnethostid The id of the remote host.
+ * @param int $strictness IGNORE_MISSING means compatible mode, false returned if user not found, debug message if more found;
+ * IGNORE_MULTIPLE means return first user, ignore multiple user records found(not recommended);
+ * MUST_EXIST means throw an exception if no user record or multiple records found.
+ * @return stdClass|bool user record if found, else false.
+ */
+ public static function get_user_by_idnumber(
+ string $idnumber,
+ string $fields = '*',
+ ?int $mnethostid = null,
+ int $strictness = IGNORE_MISSING,
+ ): stdClass|bool {
+ global $DB, $CFG;
+
+ // Because we use the username as the search criteria, we must also restrict our search based on mnet host.
+ if (empty($mnethostid)) {
+ // If empty, we restrict to local users.
+ $mnethostid = $CFG->mnet_localhost_id;
+ }
+
+ return $DB->get_record('user', [
+ 'idnumber' => $idnumber,
+ 'mnethostid' => $mnethostid,
+ ], $fields, $strictness);
}
/**
@@ -210,13 +244,17 @@ class core_user {
* user identity fields.
*
* @param string $query Search query text
- * @param \context_course|null $coursecontext Course context or null if system-wide
+ * @param context_course|null $coursecontext Course context or null if system-wide
* @param int $max Max number of users to return, default 30 (zero = no limit)
* @param int $querylimit Max number of database queries, default 5 (zero = no limit)
* @return array Array of user objects with limited fields
*/
- public static function search($query, \context_course $coursecontext = null,
- $max = 30, $querylimit = 5) {
+ public static function search(
+ $query,
+ ?context_course $coursecontext = null,
+ $max = 30,
+ $querylimit = 5
+ ) {
global $CFG, $DB;
require_once($CFG->dirroot . '/user/lib.php');
@@ -229,15 +267,16 @@ class core_user {
}
// Check permission to view profiles at each context.
- $systemcontext = \context_system::instance();
+ $systemcontext = context_system::instance();
$viewsystem = has_capability('moodle/user:viewdetails', $systemcontext);
if ($viewsystem) {
$userquery = 'SELECT id FROM {user}';
$userparams = [];
}
if (!$viewsystem) {
- list($userquery, $userparams) = self::get_enrolled_sql_on_courses_with_capability(
- 'moodle/user:viewdetails');
+ [$userquery, $userparams] = self::get_enrolled_sql_on_courses_with_capability(
+ 'moodle/user:viewdetails'
+ );
if (!$userquery) {
// No permissions anywhere, return nothing.
return [];
@@ -245,7 +284,7 @@ class core_user {
}
// Start building the WHERE clause based on name.
- list ($where, $whereparams) = users_search_sql($query, 'u');
+ [$where, $whereparams] = users_search_sql($query, 'u');
// We allow users to search with extra identity fields (as well as name) but only if they
// have the permission to display those identity fields.
@@ -278,8 +317,9 @@ class core_user {
$whereparams = array_merge($whereparams, $extraparams);
} else {
// Get all courses where user can view full user identity.
- list($sql, $params) = self::get_enrolled_sql_on_courses_with_capability(
- 'moodle/site:viewuseridentity');
+ [$sql, $params] = self::get_enrolled_sql_on_courses_with_capability(
+ 'moodle/site:viewuseridentity'
+ );
if ($sql) {
// Join that with the user query to get an extra field indicating if we can.
$userquery = "
@@ -299,9 +339,9 @@ class core_user {
// Default order is just name order. But if searching within a course then we show users
// within the course first.
- list ($order, $orderparams) = users_order_by_sql('u', $query, $systemcontext);
+ [$order, $orderparams] = users_order_by_sql('u', $query, $systemcontext);
if ($coursecontext) {
- list ($sql, $params) = get_enrolled_sql($coursecontext);
+ [$sql, $params] = get_enrolled_sql($coursecontext);
$mainfield = 'innerusers2.id';
if ($usingshowidentity) {
$mainfield .= ', innerusers2.showidentity';
@@ -323,13 +363,17 @@ class core_user {
$pos = 0;
$readcount = $max + 2;
for ($i = 0; $i < $querylimit; $i++) {
- $rawresult = $DB->get_records_sql("
+ $rawresult = $DB->get_records_sql(
+ "
SELECT $selectfields
FROM ($userquery) users
JOIN {user} u ON u.id = users.id
WHERE $where
- ORDER BY $order", array_merge($userparams, $whereparams, $orderparams),
- $pos, $readcount);
+ ORDER BY $order",
+ array_merge($userparams, $whereparams, $orderparams),
+ $pos,
+ $readcount
+ );
foreach ($rawresult as $user) {
// Skip guest.
if ($user->username === 'guest') {
@@ -371,8 +415,12 @@ class core_user {
*/
protected static function get_enrolled_sql_on_courses_with_capability($capability) {
// Get all courses where user have the capability.
- $courses = get_user_capability_course($capability, null, true,
- implode(',', array_values(context_helper::get_preload_record_columns('ctx'))));
+ $courses = get_user_capability_course(
+ $capability,
+ null,
+ true,
+ implode(',', array_values(context_helper::get_preload_record_columns('ctx')))
+ );
if (!$courses) {
return [null, null];
}
@@ -384,8 +432,8 @@ class core_user {
$unionparams = [];
foreach ($courses as $course) {
// Get SQL to list user ids enrolled in this course.
- \context_helper::preload_from_record($course);
- list ($sql, $params) = get_enrolled_sql(\context_course::instance($course->id));
+ context_helper::preload_from_record($course);
+ [$sql, $params] = get_enrolled_sql(context_course::instance($course->id));
// Combine to a big union query.
if ($unionsql) {
@@ -528,7 +576,7 @@ class core_user {
return false;
}
if ($checkdb) {
- return $DB->record_exists('user', array('id' => $userid));
+ return $DB->record_exists('user', ['id' => $userid]);
} else {
return true;
}
@@ -573,11 +621,11 @@ class core_user {
throw new moodle_exception('guestsarenotallowed', 'error');
}
- if ($checksuspended and $user->suspended) {
+ if ($checksuspended && $user->suspended) {
throw new moodle_exception('suspended', 'auth');
}
- if ($checknologin and $user->auth == 'nologin') {
+ if ($checknologin && $user->auth == 'nologin') {
throw new moodle_exception('suspended', 'auth');
}
}
@@ -589,12 +637,12 @@ class core_user {
* @param array $filemanageroptions
* @return bool True if the user was updated, false if it stayed the same.
*/
- public static function update_picture(stdClass $usernew, $filemanageroptions = array()) {
+ public static function update_picture(stdClass $usernew, $filemanageroptions = []) {
global $CFG, $DB;
require_once("$CFG->libdir/gdlib.php");
$context = context_user::instance($usernew->id, MUST_EXIST);
- $user = core_user::get_user($usernew->id, 'id, picture', MUST_EXIST);
+ $user = self::get_user($usernew->id, 'id, picture', MUST_EXIST);
$newpicture = $user->picture;
// Get file_storage to process files.
@@ -636,7 +684,7 @@ class core_user {
}
if ($newpicture != $user->picture) {
- $DB->set_field('user', 'picture', $newpicture, array('id' => $user->id));
+ $DB->set_field('user', 'picture', $newpicture, ['id' => $user->id]);
return true;
} else {
return false;
@@ -669,66 +717,112 @@ class core_user {
// Array of user fields properties and expected parameters.
// Every new field on the user table should be added here otherwise it won't be validated.
- $fields = array();
- $fields['id'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED);
- $fields['auth'] = array('type' => PARAM_AUTH, 'null' => NULL_NOT_ALLOWED);
- $fields['confirmed'] = array('type' => PARAM_BOOL, 'null' => NULL_NOT_ALLOWED);
- $fields['policyagreed'] = array('type' => PARAM_BOOL, 'null' => NULL_NOT_ALLOWED);
- $fields['deleted'] = array('type' => PARAM_BOOL, 'null' => NULL_NOT_ALLOWED);
- $fields['suspended'] = array('type' => PARAM_BOOL, 'null' => NULL_NOT_ALLOWED);
- $fields['mnethostid'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED);
- $fields['username'] = array('type' => PARAM_USERNAME, 'null' => NULL_NOT_ALLOWED);
- $fields['password'] = array('type' => PARAM_RAW, 'null' => NULL_NOT_ALLOWED);
- $fields['idnumber'] = array('type' => PARAM_RAW, 'null' => NULL_NOT_ALLOWED);
- $fields['firstname'] = array('type' => PARAM_NOTAGS, 'null' => NULL_NOT_ALLOWED);
- $fields['lastname'] = array('type' => PARAM_NOTAGS, 'null' => NULL_NOT_ALLOWED);
- $fields['surname'] = array('type' => PARAM_NOTAGS, 'null' => NULL_NOT_ALLOWED);
- $fields['email'] = array('type' => PARAM_RAW_TRIMMED, 'null' => NULL_NOT_ALLOWED);
- $fields['emailstop'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'default' => 0);
- $fields['phone1'] = array('type' => PARAM_NOTAGS, 'null' => NULL_NOT_ALLOWED);
- $fields['phone2'] = array('type' => PARAM_NOTAGS, 'null' => NULL_NOT_ALLOWED);
- $fields['institution'] = array('type' => PARAM_TEXT, 'null' => NULL_NOT_ALLOWED);
- $fields['department'] = array('type' => PARAM_TEXT, 'null' => NULL_NOT_ALLOWED);
- $fields['address'] = array('type' => PARAM_TEXT, 'null' => NULL_NOT_ALLOWED);
- $fields['city'] = array('type' => PARAM_TEXT, 'null' => NULL_NOT_ALLOWED, 'default' => $CFG->defaultcity);
- $fields['country'] = array('type' => PARAM_ALPHA, 'null' => NULL_NOT_ALLOWED, 'default' => $CFG->country,
- 'choices' => array_merge(array('' => ''), get_string_manager()->get_list_of_countries(true, true)));
- $fields['lang'] = array('type' => PARAM_LANG, 'null' => NULL_NOT_ALLOWED,
- 'default' => (!empty($CFG->autolangusercreation) && !empty($SESSION->lang)) ? $SESSION->lang : $CFG->lang,
- 'choices' => array_merge(array('' => ''), get_string_manager()->get_list_of_translations(false)));
- $fields['calendartype'] = array('type' => PARAM_PLUGIN, 'null' => NULL_NOT_ALLOWED, 'default' => $CFG->calendartype,
- 'choices' => array_merge(array('' => ''), \core_calendar\type_factory::get_list_of_calendar_types()));
- $fields['theme'] = array('type' => PARAM_THEME, 'null' => NULL_NOT_ALLOWED,
- 'default' => theme_config::DEFAULT_THEME, 'choices' => array_merge(array('' => ''), get_list_of_themes()));
- $fields['timezone'] = array('type' => PARAM_TIMEZONE, 'null' => NULL_NOT_ALLOWED,
- 'default' => core_date::get_server_timezone()); // Must not use choices here: timezones can come and go.
- $fields['firstaccess'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED);
- $fields['lastaccess'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED);
- $fields['lastlogin'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED);
- $fields['currentlogin'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED);
- $fields['lastip'] = array('type' => PARAM_NOTAGS, 'null' => NULL_NOT_ALLOWED);
- $fields['secret'] = array('type' => PARAM_ALPHANUM, 'null' => NULL_NOT_ALLOWED);
- $fields['picture'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED);
- $fields['description'] = array('type' => PARAM_RAW, 'null' => NULL_ALLOWED);
- $fields['descriptionformat'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED);
- $fields['mailformat'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED,
- 'default' => $CFG->defaultpreference_mailformat);
- $fields['maildigest'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED,
- 'default' => $CFG->defaultpreference_maildigest);
- $fields['maildisplay'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED,
- 'default' => $CFG->defaultpreference_maildisplay);
- $fields['autosubscribe'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED,
- 'default' => $CFG->defaultpreference_autosubscribe);
- $fields['trackforums'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED,
- 'default' => $CFG->defaultpreference_trackforums);
- $fields['timecreated'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED);
- $fields['timemodified'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED);
- $fields['trustbitmask'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED);
- $fields['imagealt'] = array('type' => PARAM_TEXT, 'null' => NULL_ALLOWED);
- $fields['lastnamephonetic'] = array('type' => PARAM_NOTAGS, 'null' => NULL_ALLOWED);
- $fields['firstnamephonetic'] = array('type' => PARAM_NOTAGS, 'null' => NULL_ALLOWED);
- $fields['middlename'] = array('type' => PARAM_NOTAGS, 'null' => NULL_ALLOWED);
- $fields['alternatename'] = array('type' => PARAM_NOTAGS, 'null' => NULL_ALLOWED);
+ $fields = [];
+ $fields['id'] = ['type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED];
+ $fields['auth'] = ['type' => PARAM_AUTH, 'null' => NULL_NOT_ALLOWED];
+ $fields['confirmed'] = ['type' => PARAM_BOOL, 'null' => NULL_NOT_ALLOWED];
+ $fields['policyagreed'] = ['type' => PARAM_BOOL, 'null' => NULL_NOT_ALLOWED];
+ $fields['deleted'] = ['type' => PARAM_BOOL, 'null' => NULL_NOT_ALLOWED];
+ $fields['suspended'] = ['type' => PARAM_BOOL, 'null' => NULL_NOT_ALLOWED];
+ $fields['mnethostid'] = ['type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED];
+ $fields['username'] = ['type' => PARAM_USERNAME, 'null' => NULL_NOT_ALLOWED];
+ $fields['password'] = ['type' => PARAM_RAW, 'null' => NULL_NOT_ALLOWED];
+ $fields['idnumber'] = ['type' => PARAM_RAW, 'null' => NULL_NOT_ALLOWED];
+ $fields['firstname'] = ['type' => PARAM_NOTAGS, 'null' => NULL_NOT_ALLOWED];
+ $fields['lastname'] = ['type' => PARAM_NOTAGS, 'null' => NULL_NOT_ALLOWED];
+ $fields['surname'] = ['type' => PARAM_NOTAGS, 'null' => NULL_NOT_ALLOWED];
+ $fields['email'] = ['type' => PARAM_RAW_TRIMMED, 'null' => NULL_NOT_ALLOWED];
+ $fields['emailstop'] = ['type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'default' => 0];
+ $fields['phone1'] = ['type' => PARAM_NOTAGS, 'null' => NULL_NOT_ALLOWED];
+ $fields['phone2'] = ['type' => PARAM_NOTAGS, 'null' => NULL_NOT_ALLOWED];
+ $fields['institution'] = ['type' => PARAM_TEXT, 'null' => NULL_NOT_ALLOWED];
+ $fields['department'] = ['type' => PARAM_TEXT, 'null' => NULL_NOT_ALLOWED];
+ $fields['address'] = ['type' => PARAM_TEXT, 'null' => NULL_NOT_ALLOWED];
+ $fields['city'] = ['type' => PARAM_TEXT, 'null' => NULL_NOT_ALLOWED, 'default' => $CFG->defaultcity];
+ $fields['country'] = [
+ 'type' => PARAM_ALPHA,
+ 'null' => NULL_NOT_ALLOWED,
+ 'default' => $CFG->country,
+ 'choices' => array_merge(
+ ['' => ''],
+ get_string_manager()->get_list_of_countries(true, true)
+ ),
+ ];
+ $fields['lang'] = [
+ 'type' => PARAM_LANG,
+ 'null' => NULL_NOT_ALLOWED,
+ 'default' => (!empty($CFG->autolangusercreation) && !empty($SESSION->lang)) ? $SESSION->lang : $CFG->lang,
+ 'choices' => array_merge(
+ ['' => ''],
+ get_string_manager()->get_list_of_translations(false)
+ ),
+ ];
+ $fields['calendartype'] = [
+ 'type' => PARAM_PLUGIN,
+ 'null' => NULL_NOT_ALLOWED,
+ 'default' => $CFG->calendartype,
+ 'choices' => array_merge(
+ ['' => ''],
+ \core_calendar\type_factory::get_list_of_calendar_types()
+ ),
+ ];
+ $fields['theme'] = [
+ 'type' => PARAM_THEME,
+ 'null' => NULL_NOT_ALLOWED,
+ 'default' => theme_config::DEFAULT_THEME,
+ 'choices' => array_merge(
+ ['' => ''],
+ get_list_of_themes()
+ ),
+ ];
+ $fields['timezone'] = [
+ // Must not use choices here: timezones can come and go.
+ 'type' => PARAM_TIMEZONE,
+ 'null' => NULL_NOT_ALLOWED,
+ 'default' => core_date::get_server_timezone(),
+ ];
+ $fields['firstaccess'] = ['type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED];
+ $fields['lastaccess'] = ['type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED];
+ $fields['lastlogin'] = ['type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED];
+ $fields['currentlogin'] = ['type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED];
+ $fields['lastip'] = ['type' => PARAM_NOTAGS, 'null' => NULL_NOT_ALLOWED];
+ $fields['secret'] = ['type' => PARAM_ALPHANUM, 'null' => NULL_NOT_ALLOWED];
+ $fields['picture'] = ['type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED];
+ $fields['description'] = ['type' => PARAM_RAW, 'null' => NULL_ALLOWED];
+ $fields['descriptionformat'] = ['type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED];
+ $fields['mailformat'] = [
+ 'type' => PARAM_INT,
+ 'null' => NULL_NOT_ALLOWED,
+ 'default' => $CFG->defaultpreference_mailformat,
+ ];
+ $fields['maildigest'] = [
+ 'type' => PARAM_INT,
+ 'null' => NULL_NOT_ALLOWED,
+ 'default' => $CFG->defaultpreference_maildigest,
+ ];
+ $fields['maildisplay'] = [
+ 'type' => PARAM_INT,
+ 'null' => NULL_NOT_ALLOWED,
+ 'default' => $CFG->defaultpreference_maildisplay,
+ ];
+ $fields['autosubscribe'] = [
+ 'type' => PARAM_INT,
+ 'null' => NULL_NOT_ALLOWED,
+ 'default' => $CFG->defaultpreference_autosubscribe,
+ ];
+ $fields['trackforums'] = [
+ 'type' => PARAM_INT,
+ 'null' => NULL_NOT_ALLOWED,
+ 'default' => $CFG->defaultpreference_trackforums,
+ ];
+ $fields['timecreated'] = ['type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED];
+ $fields['timemodified'] = ['type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED];
+ $fields['trustbitmask'] = ['type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED];
+ $fields['imagealt'] = ['type' => PARAM_TEXT, 'null' => NULL_ALLOWED];
+ $fields['lastnamephonetic'] = ['type' => PARAM_NOTAGS, 'null' => NULL_ALLOWED];
+ $fields['firstnamephonetic'] = ['type' => PARAM_NOTAGS, 'null' => NULL_ALLOWED];
+ $fields['middlename'] = ['type' => PARAM_NOTAGS, 'null' => NULL_ALLOWED];
+ $fields['alternatename'] = ['type' => PARAM_NOTAGS, 'null' => NULL_ALLOWED];
self::$propertiescache = $fields;
}
@@ -770,8 +864,10 @@ class core_user {
validate_param($value, self::$propertiescache[$property]['type'], self::$propertiescache[$property]['null']);
}
// Check that the value is part of a list of allowed values.
- if (!empty(self::$propertiescache[$property]['choices']) &&
- !isset(self::$propertiescache[$property]['choices'][$value])) {
+ if (
+ !empty(self::$propertiescache[$property]['choices']) &&
+ !isset(self::$propertiescache[$property]['choices'][$value])
+ ) {
throw new invalid_parameter_exception($value);
}
} catch (invalid_parameter_exception $e) {
@@ -806,7 +902,7 @@ class core_user {
foreach ($user as $field => $value) {
// Get the property parameter type and do the cleaning.
try {
- $user->$field = core_user::clean_field($value, $field);
+ $user->$field = self::clean_field($value, $field);
} catch (coding_exception $e) {
debugging("The property '$field' could not be cleaned.", DEBUG_DEVELOPER);
}
@@ -828,7 +924,7 @@ class core_user {
}
try {
- $type = core_user::get_property_type($field);
+ $type = self::get_property_type($field);
if (isset(self::$propertiescache[$field]['choices'])) {
if (!array_key_exists($data, self::$propertiescache[$field]['choices'])) {
@@ -900,9 +996,10 @@ class core_user {
self::fill_properties_cache();
- if (!array_key_exists($property, self::$propertiescache) && !array_key_exists('choices',
- self::$propertiescache[$property])) {
-
+ if (
+ !array_key_exists($property, self::$propertiescache) &&
+ !array_key_exists('choices', self::$propertiescache[$property])
+ ) {
throw new coding_exception('Invalid property requested, or the property does not has a list of choices.');
}
@@ -939,11 +1036,12 @@ class core_user {
* 'choices' => array(1, 2..) // An array of accepted values of the user field - optional
* 'default' => $CFG->setting // An default value for the field - optional
* 'isregex' => false/true // Whether the name of the preference is a regular expression (default false).
- * 'permissioncallback' => callable // Function accepting arguments ($user, $preferencename) that checks if current user
+ * 'permissioncallback' => callable // Function accepting arguments ($user, $preferencename) that checks if current
+ * // user
* // is allowed to modify this preference for given user.
- * // If not specified core_user::default_preference_permission_check() will be assumed.
- * 'cleancallback' => callable // Custom callback for cleaning value if something more difficult than just type/choices is needed
- * // accepts arguments ($value, $preferencename)
+ * // If not specified \core\user::default_preference_permission_check() will be assumed.
+ * 'cleancallback' => callable // Custom callback for cleaning value if something more difficult than just type/choices
+ * // is needed accepts arguments ($value, $preferencename)
* )
* )
*
@@ -958,32 +1056,44 @@ class core_user {
// Array of user preferences and expected types/values.
// Every preference that can be updated directly by user should be added here.
- $preferences = array();
- $preferences['auth_forcepasswordchange'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'choices' => array(0, 1),
- 'permissioncallback' => function($user, $preferencename) {
+ $preferences = [];
+ $preferences['auth_forcepasswordchange'] = [
+ 'type' => PARAM_INT,
+ 'null' => NULL_NOT_ALLOWED,
+ 'choices' => [0, 1],
+ 'permissioncallback' => function ($user, $preferencename) {
global $USER;
$systemcontext = context_system::instance();
return ($USER->id != $user->id && (has_capability('moodle/user:update', $systemcontext) ||
($user->timecreated > time() - 10 && has_capability('moodle/user:create', $systemcontext))));
- });
- $preferences['forum_markasreadonnotification'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'default' => 1,
- 'choices' => array(0, 1));
- $preferences['htmleditor'] = array('type' => PARAM_NOTAGS, 'null' => NULL_ALLOWED,
- 'cleancallback' => function($value, $preferencename) {
- if (empty($value) || !array_key_exists($value, core_component::get_plugin_list('editor'))) {
+ },
+ ];
+ $preferences['forum_markasreadonnotification'] = [
+ 'type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'default' => 1,
+ 'choices' => [0, 1],
+ ];
+ $preferences['htmleditor'] = [
+ 'type' => PARAM_NOTAGS, 'null' => NULL_ALLOWED,
+ 'cleancallback' => function ($value, $preferencename) {
+ if (empty($value) || !array_key_exists($value, component::get_plugin_list('editor'))) {
return null;
}
return $value;
- });
- $preferences['badgeprivacysetting'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'default' => 1,
- 'choices' => array(0, 1), 'permissioncallback' => function($user, $preferencename) {
+ },
+ ];
+ $preferences['badgeprivacysetting'] = [
+ 'type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'default' => 1,
+ 'choices' => [0, 1], 'permissioncallback' => function ($user, $preferencename) {
global $CFG;
return !empty($CFG->enablebadges) && self::is_current_user($user);
- });
- $preferences['blogpagesize'] = array('type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'default' => 10,
- 'permissioncallback' => function($user, $preferencename) {
+ },
+ ];
+ $preferences['blogpagesize'] = [
+ 'type' => PARAM_INT, 'null' => NULL_NOT_ALLOWED, 'default' => 10,
+ 'permissioncallback' => function ($user, $preferencename) {
return self::is_current_user($user) && has_capability('moodle/blog:view', context_system::instance());
- });
+ },
+ ];
$preferences['filemanager_recentviewmode'] = [
'type' => PARAM_INT,
'null' => NULL_NOT_ALLOWED,
@@ -1058,7 +1168,7 @@ class core_user {
global $CFG;
return self::is_current_user($user) &&
(!empty($CFG->defaulthomepage) && ($CFG->defaulthomepage == HOMEPAGE_USER));
- }
+ },
];
// Core components that may want to define their preferences.
@@ -1091,7 +1201,7 @@ class core_user {
* @param string $preferencename
* @return array
*/
- protected static function get_preference_definition($preferencename) {
+ public static function get_preference_definition($preferencename) {
self::fill_preferences_cache();
foreach (self::$preferencescache as $key => $preference) {
@@ -1127,13 +1237,13 @@ class core_user {
// Editing own profile.
$systemcontext = context_system::instance();
return has_capability('moodle/user:editownprofile', $systemcontext);
- } else {
+ } else {
// Teachers, parents, etc.
$personalcontext = context_user::instance($user->id);
if (!has_capability('moodle/user:editprofile', $personalcontext)) {
return false;
}
- if (is_siteadmin($user->id) and !is_siteadmin($USER)) {
+ if (is_siteadmin($user->id) && !is_siteadmin($USER)) {
// Only admins may edit other admins.
return false;
}
@@ -1361,7 +1471,7 @@ class core_user {
}
}
- $requirednames = array();
+ $requirednames = [];
// With each name, see if it is in the display name template, and add it to the required names array if it is.
foreach ($allnames as $allname) {
if (strpos($template, $allname) !== false) {
@@ -1386,7 +1496,7 @@ class core_user {
// Tidy up any misc. characters (Not perfect, but gets most characters).
// Don't remove the "u" at the end of the first expression unless you want garbled characters when combining hiragana or
// katakana and parenthesis.
- $patterns = array();
+ $patterns = [];
// This regular expression replacement is to fix problems such as 'James () Kirk' Where 'Tiberius' (middlename) has not been
// filled in by a user.
// The special characters are Japanese brackets that are common enough to make allowances for them (not covered by :punct:).
@@ -1412,9 +1522,9 @@ class core_user {
*
* @param stdClass $user the person to get details of.
* @param context|null $context The context will be used to determine the visibility of the user's profile url.
- * @return moodle_url Profile url of the user
+ * @return url Profile url of the user
*/
- public static function get_profile_url(stdClass $user, context $context = null): moodle_url {
+ public static function get_profile_url(stdClass $user, ?context $context = null): url {
if (empty($user->id)) {
throw new coding_exception('User id is required when displaying profile url.');
}
@@ -1429,9 +1539,9 @@ class core_user {
// If courseid is not set or is set to site id, then return profile page, otherwise return view page.
if (!isset($params['courseid']) || $params['courseid'] == SITEID) {
- return new moodle_url('/user/profile.php', $params);
+ return new url('/user/profile.php', $params);
} else {
- return new moodle_url('/user/view.php', $params);
+ return new url('/user/view.php', $params);
}
}
@@ -1606,3 +1716,8 @@ class core_user {
return $namefields;
}
}
+
+// Alias this class to the old name.
+// This file will be autoloaded by the legacyclasses autoload system.
+// In future all uses of this class will be corrected and the legacy references will be removed.
+class_alias(user::class, \core_user::class);
diff --git a/lib/db/caches.php b/lib/db/caches.php
index 09b36ffc4f3..3c939101479 100644
--- a/lib/db/caches.php
+++ b/lib/db/caches.php
@@ -618,4 +618,11 @@ $definitions = array(
'simpledata' => true,
'staticacceleration' => true,
],
+
+ 'routes' => [
+ 'mode' => cache_store::MODE_APPLICATION,
+ 'simplekeys' => true,
+ 'simpledata' => true,
+ 'canuselocalstore' => true,
+ ],
);
diff --git a/lib/db/hooks.php b/lib/db/hooks.php
index 11a8aa56e81..b7f8e7bf31b 100644
--- a/lib/db/hooks.php
+++ b/lib/db/hooks.php
@@ -106,4 +106,8 @@ $callbacks = [
'hook' => \core\hook\task\after_failed_task_max_delay::class,
'callback' => core\task\failed_task_callbacks::class . '::send_failed_task_max_delay_message',
],
+ [
+ 'hook' => \core\hook\di_configuration::class,
+ 'callback' => [\core\router\hook_callbacks::class, 'provide_di_configuration'],
+ ],
];
diff --git a/lib/dmllib.php b/lib/dmllib.php
index 497a00f8d2e..f9efbed1e65 100644
--- a/lib/dmllib.php
+++ b/lib/dmllib.php
@@ -15,6 +15,8 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see .
+use core\exception\response_aware_exception;
+use core\router\response\not_found_response;
/**
* This library contains all the Data Manipulation Language (DML) functions
@@ -176,7 +178,7 @@ class dml_multiple_records_exception extends dml_exception {
* @copyright 2008 Petr Skoda (http://skodak.org)
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class dml_missing_record_exception extends dml_exception {
+class dml_missing_record_exception extends dml_exception implements response_aware_exception {
/** @var string A table's name.*/
public $tablename;
/** @var string An SQL query.*/
@@ -218,6 +220,11 @@ class dml_missing_record_exception extends dml_exception {
$errorinfo = $sql."\n[".var_export($params, true).']';
parent::__construct($errcode, $tablename, $errorinfo);
}
+
+ #[\Override]
+ public function get_response_classname(): string {
+ return not_found_response::class;
+ }
}
/**
diff --git a/lib/excellib.class.php b/lib/excellib.class.php
index 62bda64bffe..3899b0aeb27 100644
--- a/lib/excellib.class.php
+++ b/lib/excellib.class.php
@@ -24,20 +24,18 @@
defined('MOODLE_INTERNAL') || die();
-require_once("$CFG->libdir/phpspreadsheet/vendor/autoload.php");
-
-use \PhpOffice\PhpSpreadsheet\Spreadsheet;
-use \PhpOffice\PhpSpreadsheet\IOFactory;
-use \PhpOffice\PhpSpreadsheet\Cell\Coordinate;
-use \PhpOffice\PhpSpreadsheet\Cell\DataType;
-use \PhpOffice\PhpSpreadsheet\Shared\Date;
-use \PhpOffice\PhpSpreadsheet\Style\Alignment;
-use \PhpOffice\PhpSpreadsheet\Style\Border;
-use \PhpOffice\PhpSpreadsheet\Style\Fill;
-use \PhpOffice\PhpSpreadsheet\Style\Font;
-use \PhpOffice\PhpSpreadsheet\Style\NumberFormat;
-use \PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
-use \PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
+use PhpOffice\PhpSpreadsheet\Cell\DataType;
+use PhpOffice\PhpSpreadsheet\Shared\Date;
+use PhpOffice\PhpSpreadsheet\Style\Alignment;
+use PhpOffice\PhpSpreadsheet\Style\Border;
+use PhpOffice\PhpSpreadsheet\Style\Fill;
+use PhpOffice\PhpSpreadsheet\Style\Font;
+use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
+use PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
+use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* Define and operate over one Moodle Workbook.
diff --git a/lib/nikic/fast-route/LICENSE b/lib/nikic/fast-route/LICENSE
new file mode 100644
index 00000000000..478e7641e93
--- /dev/null
+++ b/lib/nikic/fast-route/LICENSE
@@ -0,0 +1,31 @@
+Copyright (c) 2013 by Nikita Popov.
+
+Some rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following
+ disclaimer in the documentation and/or other materials provided
+ with the distribution.
+
+ * The names of the contributors may not be used to endorse or
+ promote products derived from this software without specific
+ prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/lib/nikic/fast-route/README.md b/lib/nikic/fast-route/README.md
new file mode 100644
index 00000000000..91bd4664e03
--- /dev/null
+++ b/lib/nikic/fast-route/README.md
@@ -0,0 +1,313 @@
+FastRoute - Fast request router for PHP
+=======================================
+
+This library provides a fast implementation of a regular expression based router. [Blog post explaining how the
+implementation works and why it is fast.][blog_post]
+
+Install
+-------
+
+To install with composer:
+
+```sh
+composer require nikic/fast-route
+```
+
+Requires PHP 5.4 or newer.
+
+Usage
+-----
+
+Here's a basic usage example:
+
+```php
+addRoute('GET', '/users', 'get_all_users_handler');
+ // {id} must be a number (\d+)
+ $r->addRoute('GET', '/user/{id:\d+}', 'get_user_handler');
+ // The /{title} suffix is optional
+ $r->addRoute('GET', '/articles/{id:\d+}[/{title}]', 'get_article_handler');
+});
+
+// Fetch method and URI from somewhere
+$httpMethod = $_SERVER['REQUEST_METHOD'];
+$uri = $_SERVER['REQUEST_URI'];
+
+// Strip query string (?foo=bar) and decode URI
+if (false !== $pos = strpos($uri, '?')) {
+ $uri = substr($uri, 0, $pos);
+}
+$uri = rawurldecode($uri);
+
+$routeInfo = $dispatcher->dispatch($httpMethod, $uri);
+switch ($routeInfo[0]) {
+ case FastRoute\Dispatcher::NOT_FOUND:
+ // ... 404 Not Found
+ break;
+ case FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
+ $allowedMethods = $routeInfo[1];
+ // ... 405 Method Not Allowed
+ break;
+ case FastRoute\Dispatcher::FOUND:
+ $handler = $routeInfo[1];
+ $vars = $routeInfo[2];
+ // ... call $handler with $vars
+ break;
+}
+```
+
+### Defining routes
+
+The routes are defined by calling the `FastRoute\simpleDispatcher()` function, which accepts
+a callable taking a `FastRoute\RouteCollector` instance. The routes are added by calling
+`addRoute()` on the collector instance:
+
+```php
+$r->addRoute($method, $routePattern, $handler);
+```
+
+The `$method` is an uppercase HTTP method string for which a certain route should match. It
+is possible to specify multiple valid methods using an array:
+
+```php
+// These two calls
+$r->addRoute('GET', '/test', 'handler');
+$r->addRoute('POST', '/test', 'handler');
+// Are equivalent to this one call
+$r->addRoute(['GET', 'POST'], '/test', 'handler');
+```
+
+By default the `$routePattern` uses a syntax where `{foo}` specifies a placeholder with name `foo`
+and matching the regex `[^/]+`. To adjust the pattern the placeholder matches, you can specify
+a custom pattern by writing `{bar:[0-9]+}`. Some examples:
+
+```php
+// Matches /user/42, but not /user/xyz
+$r->addRoute('GET', '/user/{id:\d+}', 'handler');
+
+// Matches /user/foobar, but not /user/foo/bar
+$r->addRoute('GET', '/user/{name}', 'handler');
+
+// Matches /user/foo/bar as well
+$r->addRoute('GET', '/user/{name:.+}', 'handler');
+```
+
+Custom patterns for route placeholders cannot use capturing groups. For example `{lang:(en|de)}`
+is not a valid placeholder, because `()` is a capturing group. Instead you can use either
+`{lang:en|de}` or `{lang:(?:en|de)}`.
+
+Furthermore parts of the route enclosed in `[...]` are considered optional, so that `/foo[bar]`
+will match both `/foo` and `/foobar`. Optional parts are only supported in a trailing position,
+not in the middle of a route.
+
+```php
+// This route
+$r->addRoute('GET', '/user/{id:\d+}[/{name}]', 'handler');
+// Is equivalent to these two routes
+$r->addRoute('GET', '/user/{id:\d+}', 'handler');
+$r->addRoute('GET', '/user/{id:\d+}/{name}', 'handler');
+
+// Multiple nested optional parts are possible as well
+$r->addRoute('GET', '/user[/{id:\d+}[/{name}]]', 'handler');
+
+// This route is NOT valid, because optional parts can only occur at the end
+$r->addRoute('GET', '/user[/{id:\d+}]/{name}', 'handler');
+```
+
+The `$handler` parameter does not necessarily have to be a callback, it could also be a controller
+class name or any other kind of data you wish to associate with the route. FastRoute only tells you
+which handler corresponds to your URI, how you interpret it is up to you.
+
+#### Shorcut methods for common request methods
+
+For the `GET`, `POST`, `PUT`, `PATCH`, `DELETE` and `HEAD` request methods shortcut methods are available. For example:
+
+```php
+$r->get('/get-route', 'get_handler');
+$r->post('/post-route', 'post_handler');
+```
+
+Is equivalent to:
+
+```php
+$r->addRoute('GET', '/get-route', 'get_handler');
+$r->addRoute('POST', '/post-route', 'post_handler');
+```
+
+#### Route Groups
+
+Additionally, you can specify routes inside of a group. All routes defined inside a group will have a common prefix.
+
+For example, defining your routes as:
+
+```php
+$r->addGroup('/admin', function (RouteCollector $r) {
+ $r->addRoute('GET', '/do-something', 'handler');
+ $r->addRoute('GET', '/do-another-thing', 'handler');
+ $r->addRoute('GET', '/do-something-else', 'handler');
+});
+```
+
+Will have the same result as:
+
+ ```php
+$r->addRoute('GET', '/admin/do-something', 'handler');
+$r->addRoute('GET', '/admin/do-another-thing', 'handler');
+$r->addRoute('GET', '/admin/do-something-else', 'handler');
+ ```
+
+Nested groups are also supported, in which case the prefixes of all the nested groups are combined.
+
+### Caching
+
+The reason `simpleDispatcher` accepts a callback for defining the routes is to allow seamless
+caching. By using `cachedDispatcher` instead of `simpleDispatcher` you can cache the generated
+routing data and construct the dispatcher from the cached information:
+
+```php
+addRoute('GET', '/user/{name}/{id:[0-9]+}', 'handler0');
+ $r->addRoute('GET', '/user/{id:[0-9]+}', 'handler1');
+ $r->addRoute('GET', '/user/{name}', 'handler2');
+}, [
+ 'cacheFile' => __DIR__ . '/route.cache', /* required */
+ 'cacheDisabled' => IS_DEBUG_ENABLED, /* optional, enabled by default */
+]);
+```
+
+The second parameter to the function is an options array, which can be used to specify the cache
+file location, among other things.
+
+### Dispatching a URI
+
+A URI is dispatched by calling the `dispatch()` method of the created dispatcher. This method
+accepts the HTTP method and a URI. Getting those two bits of information (and normalizing them
+appropriately) is your job - this library is not bound to the PHP web SAPIs.
+
+The `dispatch()` method returns an array whose first element contains a status code. It is one
+of `Dispatcher::NOT_FOUND`, `Dispatcher::METHOD_NOT_ALLOWED` and `Dispatcher::FOUND`. For the
+method not allowed status the second array element contains a list of HTTP methods allowed for
+the supplied URI. For example:
+
+ [FastRoute\Dispatcher::METHOD_NOT_ALLOWED, ['GET', 'POST']]
+
+> **NOTE:** The HTTP specification requires that a `405 Method Not Allowed` response include the
+`Allow:` header to detail available methods for the requested resource. Applications using FastRoute
+should use the second array element to add this header when relaying a 405 response.
+
+For the found status the second array element is the handler that was associated with the route
+and the third array element is a dictionary of placeholder names to their values. For example:
+
+ /* Routing against GET /user/nikic/42 */
+
+ [FastRoute\Dispatcher::FOUND, 'handler0', ['name' => 'nikic', 'id' => '42']]
+
+### Overriding the route parser and dispatcher
+
+The routing process makes use of three components: A route parser, a data generator and a
+dispatcher. The three components adhere to the following interfaces:
+
+```php
+ 'FastRoute\\RouteParser\\Std',
+ 'dataGenerator' => 'FastRoute\\DataGenerator\\GroupCountBased',
+ 'dispatcher' => 'FastRoute\\Dispatcher\\GroupCountBased',
+]);
+```
+
+The above options array corresponds to the defaults. By replacing `GroupCountBased` by
+`GroupPosBased` you could switch to a different dispatching strategy.
+
+### A Note on HEAD Requests
+
+The HTTP spec requires servers to [support both GET and HEAD methods][2616-511]:
+
+> The methods GET and HEAD MUST be supported by all general-purpose servers
+
+To avoid forcing users to manually register HEAD routes for each resource we fallback to matching an
+available GET route for a given resource. The PHP web SAPI transparently removes the entity body
+from HEAD responses so this behavior has no effect on the vast majority of users.
+
+However, implementers using FastRoute outside the web SAPI environment (e.g. a custom server) MUST
+NOT send entity bodies generated in response to HEAD requests. If you are a non-SAPI user this is
+*your responsibility*; FastRoute has no purview to prevent you from breaking HTTP in such cases.
+
+Finally, note that applications MAY always specify their own HEAD method route for a given
+resource to bypass this behavior entirely.
+
+### Credits
+
+This library is based on a router that [Levi Morrison][levi] implemented for the Aerys server.
+
+A large number of tests, as well as HTTP compliance considerations, were provided by [Daniel Lowrey][rdlowrey].
+
+
+[2616-511]: http://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html#sec5.1.1 "RFC 2616 Section 5.1.1"
+[blog_post]: http://nikic.github.io/2014/02/18/Fast-request-routing-using-regular-expressions.html
+[levi]: https://github.com/morrisonlevi
+[rdlowrey]: https://github.com/rdlowrey
diff --git a/lib/nikic/fast-route/composer.json b/lib/nikic/fast-route/composer.json
new file mode 100644
index 00000000000..fb446a2abfb
--- /dev/null
+++ b/lib/nikic/fast-route/composer.json
@@ -0,0 +1,24 @@
+{
+ "name": "nikic/fast-route",
+ "description": "Fast request router for PHP",
+ "keywords": ["routing", "router"],
+ "license": "BSD-3-Clause",
+ "authors": [
+ {
+ "name": "Nikita Popov",
+ "email": "nikic@php.net"
+ }
+ ],
+ "autoload": {
+ "psr-4": {
+ "FastRoute\\": "src/"
+ },
+ "files": ["src/functions.php"]
+ },
+ "require": {
+ "php": ">=5.4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8.35|~5.7"
+ }
+}
diff --git a/lib/nikic/fast-route/src/BadRouteException.php b/lib/nikic/fast-route/src/BadRouteException.php
new file mode 100644
index 00000000000..62262ec66f6
--- /dev/null
+++ b/lib/nikic/fast-route/src/BadRouteException.php
@@ -0,0 +1,7 @@
+ $route) {
+ $suffixLen++;
+ $suffix .= "\t";
+
+ $regexes[] = '(?:' . $regex . '/(\t{' . $suffixLen . '})\t{' . ($count - $suffixLen) . '})';
+ $routeMap[$suffix] = [$route->handler, $route->variables];
+ }
+
+ $regex = '~^(?|' . implode('|', $regexes) . ')$~';
+ return ['regex' => $regex, 'suffix' => '/' . $suffix, 'routeMap' => $routeMap];
+ }
+}
diff --git a/lib/nikic/fast-route/src/DataGenerator/GroupCountBased.php b/lib/nikic/fast-route/src/DataGenerator/GroupCountBased.php
new file mode 100644
index 00000000000..54d9a05e20b
--- /dev/null
+++ b/lib/nikic/fast-route/src/DataGenerator/GroupCountBased.php
@@ -0,0 +1,30 @@
+ $route) {
+ $numVariables = count($route->variables);
+ $numGroups = max($numGroups, $numVariables);
+
+ $regexes[] = $regex . str_repeat('()', $numGroups - $numVariables);
+ $routeMap[$numGroups + 1] = [$route->handler, $route->variables];
+
+ ++$numGroups;
+ }
+
+ $regex = '~^(?|' . implode('|', $regexes) . ')$~';
+ return ['regex' => $regex, 'routeMap' => $routeMap];
+ }
+}
diff --git a/lib/nikic/fast-route/src/DataGenerator/GroupPosBased.php b/lib/nikic/fast-route/src/DataGenerator/GroupPosBased.php
new file mode 100644
index 00000000000..fc4dc0aff63
--- /dev/null
+++ b/lib/nikic/fast-route/src/DataGenerator/GroupPosBased.php
@@ -0,0 +1,27 @@
+ $route) {
+ $regexes[] = $regex;
+ $routeMap[$offset] = [$route->handler, $route->variables];
+
+ $offset += count($route->variables);
+ }
+
+ $regex = '~^(?:' . implode('|', $regexes) . ')$~';
+ return ['regex' => $regex, 'routeMap' => $routeMap];
+ }
+}
diff --git a/lib/nikic/fast-route/src/DataGenerator/MarkBased.php b/lib/nikic/fast-route/src/DataGenerator/MarkBased.php
new file mode 100644
index 00000000000..0aebed9a161
--- /dev/null
+++ b/lib/nikic/fast-route/src/DataGenerator/MarkBased.php
@@ -0,0 +1,27 @@
+ $route) {
+ $regexes[] = $regex . '(*MARK:' . $markName . ')';
+ $routeMap[$markName] = [$route->handler, $route->variables];
+
+ ++$markName;
+ }
+
+ $regex = '~^(?|' . implode('|', $regexes) . ')$~';
+ return ['regex' => $regex, 'routeMap' => $routeMap];
+ }
+}
diff --git a/lib/nikic/fast-route/src/DataGenerator/RegexBasedAbstract.php b/lib/nikic/fast-route/src/DataGenerator/RegexBasedAbstract.php
new file mode 100644
index 00000000000..64572905992
--- /dev/null
+++ b/lib/nikic/fast-route/src/DataGenerator/RegexBasedAbstract.php
@@ -0,0 +1,186 @@
+isStaticRoute($routeData)) {
+ $this->addStaticRoute($httpMethod, $routeData, $handler);
+ } else {
+ $this->addVariableRoute($httpMethod, $routeData, $handler);
+ }
+ }
+
+ /**
+ * @return mixed[]
+ */
+ public function getData()
+ {
+ if (empty($this->methodToRegexToRoutesMap)) {
+ return [$this->staticRoutes, []];
+ }
+
+ return [$this->staticRoutes, $this->generateVariableRouteData()];
+ }
+
+ /**
+ * @return mixed[]
+ */
+ private function generateVariableRouteData()
+ {
+ $data = [];
+ foreach ($this->methodToRegexToRoutesMap as $method => $regexToRoutesMap) {
+ $chunkSize = $this->computeChunkSize(count($regexToRoutesMap));
+ $chunks = array_chunk($regexToRoutesMap, $chunkSize, true);
+ $data[$method] = array_map([$this, 'processChunk'], $chunks);
+ }
+ return $data;
+ }
+
+ /**
+ * @param int
+ * @return int
+ */
+ private function computeChunkSize($count)
+ {
+ $numParts = max(1, round($count / $this->getApproxChunkSize()));
+ return (int) ceil($count / $numParts);
+ }
+
+ /**
+ * @param mixed[]
+ * @return bool
+ */
+ private function isStaticRoute($routeData)
+ {
+ return count($routeData) === 1 && is_string($routeData[0]);
+ }
+
+ private function addStaticRoute($httpMethod, $routeData, $handler)
+ {
+ $routeStr = $routeData[0];
+
+ if (isset($this->staticRoutes[$httpMethod][$routeStr])) {
+ throw new BadRouteException(sprintf(
+ 'Cannot register two routes matching "%s" for method "%s"',
+ $routeStr, $httpMethod
+ ));
+ }
+
+ if (isset($this->methodToRegexToRoutesMap[$httpMethod])) {
+ foreach ($this->methodToRegexToRoutesMap[$httpMethod] as $route) {
+ if ($route->matches($routeStr)) {
+ throw new BadRouteException(sprintf(
+ 'Static route "%s" is shadowed by previously defined variable route "%s" for method "%s"',
+ $routeStr, $route->regex, $httpMethod
+ ));
+ }
+ }
+ }
+
+ $this->staticRoutes[$httpMethod][$routeStr] = $handler;
+ }
+
+ private function addVariableRoute($httpMethod, $routeData, $handler)
+ {
+ list($regex, $variables) = $this->buildRegexForRoute($routeData);
+
+ if (isset($this->methodToRegexToRoutesMap[$httpMethod][$regex])) {
+ throw new BadRouteException(sprintf(
+ 'Cannot register two routes matching "%s" for method "%s"',
+ $regex, $httpMethod
+ ));
+ }
+
+ $this->methodToRegexToRoutesMap[$httpMethod][$regex] = new Route(
+ $httpMethod, $handler, $regex, $variables
+ );
+ }
+
+ /**
+ * @param mixed[]
+ * @return mixed[]
+ */
+ private function buildRegexForRoute($routeData)
+ {
+ $regex = '';
+ $variables = [];
+ foreach ($routeData as $part) {
+ if (is_string($part)) {
+ $regex .= preg_quote($part, '~');
+ continue;
+ }
+
+ list($varName, $regexPart) = $part;
+
+ if (isset($variables[$varName])) {
+ throw new BadRouteException(sprintf(
+ 'Cannot use the same placeholder "%s" twice', $varName
+ ));
+ }
+
+ if ($this->regexHasCapturingGroups($regexPart)) {
+ throw new BadRouteException(sprintf(
+ 'Regex "%s" for parameter "%s" contains a capturing group',
+ $regexPart, $varName
+ ));
+ }
+
+ $variables[$varName] = $varName;
+ $regex .= '(' . $regexPart . ')';
+ }
+
+ return [$regex, $variables];
+ }
+
+ /**
+ * @param string
+ * @return bool
+ */
+ private function regexHasCapturingGroups($regex)
+ {
+ if (false === strpos($regex, '(')) {
+ // Needs to have at least a ( to contain a capturing group
+ return false;
+ }
+
+ // Semi-accurate detection for capturing groups
+ return (bool) preg_match(
+ '~
+ (?:
+ \(\?\(
+ | \[ [^\]\\\\]* (?: \\\\ . [^\]\\\\]* )* \]
+ | \\\\ .
+ ) (*SKIP)(*FAIL) |
+ \(
+ (?!
+ \? (?! <(?![!=]) | P< | \' )
+ | \*
+ )
+ ~x',
+ $regex
+ );
+ }
+}
diff --git a/lib/nikic/fast-route/src/Dispatcher.php b/lib/nikic/fast-route/src/Dispatcher.php
new file mode 100644
index 00000000000..4ae72a356b6
--- /dev/null
+++ b/lib/nikic/fast-route/src/Dispatcher.php
@@ -0,0 +1,26 @@
+ 'value', ...]]
+ *
+ * @param string $httpMethod
+ * @param string $uri
+ *
+ * @return array
+ */
+ public function dispatch($httpMethod, $uri);
+}
diff --git a/lib/nikic/fast-route/src/Dispatcher/CharCountBased.php b/lib/nikic/fast-route/src/Dispatcher/CharCountBased.php
new file mode 100644
index 00000000000..ef1eec1345e
--- /dev/null
+++ b/lib/nikic/fast-route/src/Dispatcher/CharCountBased.php
@@ -0,0 +1,31 @@
+staticRouteMap, $this->variableRouteData) = $data;
+ }
+
+ protected function dispatchVariableRoute($routeData, $uri)
+ {
+ foreach ($routeData as $data) {
+ if (!preg_match($data['regex'], $uri . $data['suffix'], $matches)) {
+ continue;
+ }
+
+ list($handler, $varNames) = $data['routeMap'][end($matches)];
+
+ $vars = [];
+ $i = 0;
+ foreach ($varNames as $varName) {
+ $vars[$varName] = $matches[++$i];
+ }
+ return [self::FOUND, $handler, $vars];
+ }
+
+ return [self::NOT_FOUND];
+ }
+}
diff --git a/lib/nikic/fast-route/src/Dispatcher/GroupCountBased.php b/lib/nikic/fast-route/src/Dispatcher/GroupCountBased.php
new file mode 100644
index 00000000000..493e7a94f08
--- /dev/null
+++ b/lib/nikic/fast-route/src/Dispatcher/GroupCountBased.php
@@ -0,0 +1,31 @@
+staticRouteMap, $this->variableRouteData) = $data;
+ }
+
+ protected function dispatchVariableRoute($routeData, $uri)
+ {
+ foreach ($routeData as $data) {
+ if (!preg_match($data['regex'], $uri, $matches)) {
+ continue;
+ }
+
+ list($handler, $varNames) = $data['routeMap'][count($matches)];
+
+ $vars = [];
+ $i = 0;
+ foreach ($varNames as $varName) {
+ $vars[$varName] = $matches[++$i];
+ }
+ return [self::FOUND, $handler, $vars];
+ }
+
+ return [self::NOT_FOUND];
+ }
+}
diff --git a/lib/nikic/fast-route/src/Dispatcher/GroupPosBased.php b/lib/nikic/fast-route/src/Dispatcher/GroupPosBased.php
new file mode 100644
index 00000000000..498220ed6f7
--- /dev/null
+++ b/lib/nikic/fast-route/src/Dispatcher/GroupPosBased.php
@@ -0,0 +1,33 @@
+staticRouteMap, $this->variableRouteData) = $data;
+ }
+
+ protected function dispatchVariableRoute($routeData, $uri)
+ {
+ foreach ($routeData as $data) {
+ if (!preg_match($data['regex'], $uri, $matches)) {
+ continue;
+ }
+
+ // find first non-empty match
+ for ($i = 1; '' === $matches[$i]; ++$i);
+
+ list($handler, $varNames) = $data['routeMap'][$i];
+
+ $vars = [];
+ foreach ($varNames as $varName) {
+ $vars[$varName] = $matches[$i++];
+ }
+ return [self::FOUND, $handler, $vars];
+ }
+
+ return [self::NOT_FOUND];
+ }
+}
diff --git a/lib/nikic/fast-route/src/Dispatcher/MarkBased.php b/lib/nikic/fast-route/src/Dispatcher/MarkBased.php
new file mode 100644
index 00000000000..22eb09ba575
--- /dev/null
+++ b/lib/nikic/fast-route/src/Dispatcher/MarkBased.php
@@ -0,0 +1,31 @@
+staticRouteMap, $this->variableRouteData) = $data;
+ }
+
+ protected function dispatchVariableRoute($routeData, $uri)
+ {
+ foreach ($routeData as $data) {
+ if (!preg_match($data['regex'], $uri, $matches)) {
+ continue;
+ }
+
+ list($handler, $varNames) = $data['routeMap'][$matches['MARK']];
+
+ $vars = [];
+ $i = 0;
+ foreach ($varNames as $varName) {
+ $vars[$varName] = $matches[++$i];
+ }
+ return [self::FOUND, $handler, $vars];
+ }
+
+ return [self::NOT_FOUND];
+ }
+}
diff --git a/lib/nikic/fast-route/src/Dispatcher/RegexBasedAbstract.php b/lib/nikic/fast-route/src/Dispatcher/RegexBasedAbstract.php
new file mode 100644
index 00000000000..206e879f7b2
--- /dev/null
+++ b/lib/nikic/fast-route/src/Dispatcher/RegexBasedAbstract.php
@@ -0,0 +1,88 @@
+staticRouteMap[$httpMethod][$uri])) {
+ $handler = $this->staticRouteMap[$httpMethod][$uri];
+ return [self::FOUND, $handler, []];
+ }
+
+ $varRouteData = $this->variableRouteData;
+ if (isset($varRouteData[$httpMethod])) {
+ $result = $this->dispatchVariableRoute($varRouteData[$httpMethod], $uri);
+ if ($result[0] === self::FOUND) {
+ return $result;
+ }
+ }
+
+ // For HEAD requests, attempt fallback to GET
+ if ($httpMethod === 'HEAD') {
+ if (isset($this->staticRouteMap['GET'][$uri])) {
+ $handler = $this->staticRouteMap['GET'][$uri];
+ return [self::FOUND, $handler, []];
+ }
+ if (isset($varRouteData['GET'])) {
+ $result = $this->dispatchVariableRoute($varRouteData['GET'], $uri);
+ if ($result[0] === self::FOUND) {
+ return $result;
+ }
+ }
+ }
+
+ // If nothing else matches, try fallback routes
+ if (isset($this->staticRouteMap['*'][$uri])) {
+ $handler = $this->staticRouteMap['*'][$uri];
+ return [self::FOUND, $handler, []];
+ }
+ if (isset($varRouteData['*'])) {
+ $result = $this->dispatchVariableRoute($varRouteData['*'], $uri);
+ if ($result[0] === self::FOUND) {
+ return $result;
+ }
+ }
+
+ // Find allowed methods for this URI by matching against all other HTTP methods as well
+ $allowedMethods = [];
+
+ foreach ($this->staticRouteMap as $method => $uriMap) {
+ if ($method !== $httpMethod && isset($uriMap[$uri])) {
+ $allowedMethods[] = $method;
+ }
+ }
+
+ foreach ($varRouteData as $method => $routeData) {
+ if ($method === $httpMethod) {
+ continue;
+ }
+
+ $result = $this->dispatchVariableRoute($routeData, $uri);
+ if ($result[0] === self::FOUND) {
+ $allowedMethods[] = $method;
+ }
+ }
+
+ // If there are no allowed methods the route simply does not exist
+ if ($allowedMethods) {
+ return [self::METHOD_NOT_ALLOWED, $allowedMethods];
+ }
+
+ return [self::NOT_FOUND];
+ }
+}
diff --git a/lib/nikic/fast-route/src/Route.php b/lib/nikic/fast-route/src/Route.php
new file mode 100644
index 00000000000..e1bf7dd9722
--- /dev/null
+++ b/lib/nikic/fast-route/src/Route.php
@@ -0,0 +1,47 @@
+httpMethod = $httpMethod;
+ $this->handler = $handler;
+ $this->regex = $regex;
+ $this->variables = $variables;
+ }
+
+ /**
+ * Tests whether this route matches the given string.
+ *
+ * @param string $str
+ *
+ * @return bool
+ */
+ public function matches($str)
+ {
+ $regex = '~^' . $this->regex . '$~';
+ return (bool) preg_match($regex, $str);
+ }
+}
diff --git a/lib/nikic/fast-route/src/RouteCollector.php b/lib/nikic/fast-route/src/RouteCollector.php
new file mode 100644
index 00000000000..c1c1762d8d3
--- /dev/null
+++ b/lib/nikic/fast-route/src/RouteCollector.php
@@ -0,0 +1,152 @@
+routeParser = $routeParser;
+ $this->dataGenerator = $dataGenerator;
+ $this->currentGroupPrefix = '';
+ }
+
+ /**
+ * Adds a route to the collection.
+ *
+ * The syntax used in the $route string depends on the used route parser.
+ *
+ * @param string|string[] $httpMethod
+ * @param string $route
+ * @param mixed $handler
+ */
+ public function addRoute($httpMethod, $route, $handler)
+ {
+ $route = $this->currentGroupPrefix . $route;
+ $routeDatas = $this->routeParser->parse($route);
+ foreach ((array) $httpMethod as $method) {
+ foreach ($routeDatas as $routeData) {
+ $this->dataGenerator->addRoute($method, $routeData, $handler);
+ }
+ }
+ }
+
+ /**
+ * Create a route group with a common prefix.
+ *
+ * All routes created in the passed callback will have the given group prefix prepended.
+ *
+ * @param string $prefix
+ * @param callable $callback
+ */
+ public function addGroup($prefix, callable $callback)
+ {
+ $previousGroupPrefix = $this->currentGroupPrefix;
+ $this->currentGroupPrefix = $previousGroupPrefix . $prefix;
+ $callback($this);
+ $this->currentGroupPrefix = $previousGroupPrefix;
+ }
+
+ /**
+ * Adds a GET route to the collection
+ *
+ * This is simply an alias of $this->addRoute('GET', $route, $handler)
+ *
+ * @param string $route
+ * @param mixed $handler
+ */
+ public function get($route, $handler)
+ {
+ $this->addRoute('GET', $route, $handler);
+ }
+
+ /**
+ * Adds a POST route to the collection
+ *
+ * This is simply an alias of $this->addRoute('POST', $route, $handler)
+ *
+ * @param string $route
+ * @param mixed $handler
+ */
+ public function post($route, $handler)
+ {
+ $this->addRoute('POST', $route, $handler);
+ }
+
+ /**
+ * Adds a PUT route to the collection
+ *
+ * This is simply an alias of $this->addRoute('PUT', $route, $handler)
+ *
+ * @param string $route
+ * @param mixed $handler
+ */
+ public function put($route, $handler)
+ {
+ $this->addRoute('PUT', $route, $handler);
+ }
+
+ /**
+ * Adds a DELETE route to the collection
+ *
+ * This is simply an alias of $this->addRoute('DELETE', $route, $handler)
+ *
+ * @param string $route
+ * @param mixed $handler
+ */
+ public function delete($route, $handler)
+ {
+ $this->addRoute('DELETE', $route, $handler);
+ }
+
+ /**
+ * Adds a PATCH route to the collection
+ *
+ * This is simply an alias of $this->addRoute('PATCH', $route, $handler)
+ *
+ * @param string $route
+ * @param mixed $handler
+ */
+ public function patch($route, $handler)
+ {
+ $this->addRoute('PATCH', $route, $handler);
+ }
+
+ /**
+ * Adds a HEAD route to the collection
+ *
+ * This is simply an alias of $this->addRoute('HEAD', $route, $handler)
+ *
+ * @param string $route
+ * @param mixed $handler
+ */
+ public function head($route, $handler)
+ {
+ $this->addRoute('HEAD', $route, $handler);
+ }
+
+ /**
+ * Returns the collected route data, as provided by the data generator.
+ *
+ * @return array
+ */
+ public function getData()
+ {
+ return $this->dataGenerator->getData();
+ }
+}
diff --git a/lib/nikic/fast-route/src/RouteParser.php b/lib/nikic/fast-route/src/RouteParser.php
new file mode 100644
index 00000000000..6a7685cfed6
--- /dev/null
+++ b/lib/nikic/fast-route/src/RouteParser.php
@@ -0,0 +1,37 @@
+ $segment) {
+ if ($segment === '' && $n !== 0) {
+ throw new BadRouteException('Empty optional part');
+ }
+
+ $currentRoute .= $segment;
+ $routeDatas[] = $this->parsePlaceholders($currentRoute);
+ }
+ return $routeDatas;
+ }
+
+ /**
+ * Parses a route string that does not contain optional segments.
+ *
+ * @param string
+ * @return mixed[]
+ */
+ private function parsePlaceholders($route)
+ {
+ if (!preg_match_all(
+ '~' . self::VARIABLE_REGEX . '~x', $route, $matches,
+ PREG_OFFSET_CAPTURE | PREG_SET_ORDER
+ )) {
+ return [$route];
+ }
+
+ $offset = 0;
+ $routeData = [];
+ foreach ($matches as $set) {
+ if ($set[0][1] > $offset) {
+ $routeData[] = substr($route, $offset, $set[0][1] - $offset);
+ }
+ $routeData[] = [
+ $set[1][0],
+ isset($set[2]) ? trim($set[2][0]) : self::DEFAULT_DISPATCH_REGEX
+ ];
+ $offset = $set[0][1] + strlen($set[0][0]);
+ }
+
+ if ($offset !== strlen($route)) {
+ $routeData[] = substr($route, $offset);
+ }
+
+ return $routeData;
+ }
+}
diff --git a/lib/nikic/fast-route/src/bootstrap.php b/lib/nikic/fast-route/src/bootstrap.php
new file mode 100644
index 00000000000..0bce3a42071
--- /dev/null
+++ b/lib/nikic/fast-route/src/bootstrap.php
@@ -0,0 +1,12 @@
+ 'FastRoute\\RouteParser\\Std',
+ 'dataGenerator' => 'FastRoute\\DataGenerator\\GroupCountBased',
+ 'dispatcher' => 'FastRoute\\Dispatcher\\GroupCountBased',
+ 'routeCollector' => 'FastRoute\\RouteCollector',
+ ];
+
+ /** @var RouteCollector $routeCollector */
+ $routeCollector = new $options['routeCollector'](
+ new $options['routeParser'], new $options['dataGenerator']
+ );
+ $routeDefinitionCallback($routeCollector);
+
+ return new $options['dispatcher']($routeCollector->getData());
+ }
+
+ /**
+ * @param callable $routeDefinitionCallback
+ * @param array $options
+ *
+ * @return Dispatcher
+ */
+ function cachedDispatcher(callable $routeDefinitionCallback, array $options = [])
+ {
+ $options += [
+ 'routeParser' => 'FastRoute\\RouteParser\\Std',
+ 'dataGenerator' => 'FastRoute\\DataGenerator\\GroupCountBased',
+ 'dispatcher' => 'FastRoute\\Dispatcher\\GroupCountBased',
+ 'routeCollector' => 'FastRoute\\RouteCollector',
+ 'cacheDisabled' => false,
+ ];
+
+ if (!isset($options['cacheFile'])) {
+ throw new \LogicException('Must specify "cacheFile" option');
+ }
+
+ if (!$options['cacheDisabled'] && file_exists($options['cacheFile'])) {
+ $dispatchData = require $options['cacheFile'];
+ if (!is_array($dispatchData)) {
+ throw new \RuntimeException('Invalid cache file "' . $options['cacheFile'] . '"');
+ }
+ return new $options['dispatcher']($dispatchData);
+ }
+
+ $routeCollector = new $options['routeCollector'](
+ new $options['routeParser'], new $options['dataGenerator']
+ );
+ $routeDefinitionCallback($routeCollector);
+
+ /** @var RouteCollector $routeCollector */
+ $dispatchData = $routeCollector->getData();
+ if (!$options['cacheDisabled']) {
+ file_put_contents(
+ $options['cacheFile'],
+ '.
-// autoload.php @generated by Composer
+/**
+ * Legacy autoloader for phpspreadsheet.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
-if (PHP_VERSION_ID < 50600) {
- if (!headers_sent()) {
- header('HTTP/1.1 500 Internal Server Error');
- }
- $err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
- if (!ini_get('display_errors')) {
- if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
- fwrite(STDERR, $err);
- } elseif (!headers_sent()) {
- echo $err;
- }
- }
- trigger_error(
- $err,
- E_USER_ERROR
- );
-}
-
-require_once __DIR__ . '/composer/autoload_real.php';
-
-return ComposerAutoloaderInitf14832faa9ea8f0ad137e596f5daa06a::getLoader();
+// TODO MDL-82653 This file is deprecated. A message to this effect will be added in Moodle 5.0.
diff --git a/lib/phpspreadsheet/vendor/composer/ClassLoader.php b/lib/phpspreadsheet/vendor/composer/ClassLoader.php
deleted file mode 100644
index a72151c77c8..00000000000
--- a/lib/phpspreadsheet/vendor/composer/ClassLoader.php
+++ /dev/null
@@ -1,585 +0,0 @@
-
- * Jordi Boggiano
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace Composer\Autoload;
-
-/**
- * ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
- *
- * $loader = new \Composer\Autoload\ClassLoader();
- *
- * // register classes with namespaces
- * $loader->add('Symfony\Component', __DIR__.'/component');
- * $loader->add('Symfony', __DIR__.'/framework');
- *
- * // activate the autoloader
- * $loader->register();
- *
- * // to enable searching the include path (eg. for PEAR packages)
- * $loader->setUseIncludePath(true);
- *
- * In this example, if you try to use a class in the Symfony\Component
- * namespace or one of its children (Symfony\Component\Console for instance),
- * the autoloader will first look for the class under the component/
- * directory, and it will then fallback to the framework/ directory if not
- * found before giving up.
- *
- * This class is loosely based on the Symfony UniversalClassLoader.
- *
- * @author Fabien Potencier
- * @author Jordi Boggiano
- * @see https://www.php-fig.org/psr/psr-0/
- * @see https://www.php-fig.org/psr/psr-4/
- */
-class ClassLoader
-{
- /** @var \Closure(string):void */
- private static $includeFile;
-
- /** @var ?string */
- private $vendorDir;
-
- // PSR-4
- /**
- * @var array[]
- * @psalm-var array>
- */
- private $prefixLengthsPsr4 = array();
- /**
- * @var array[]
- * @psalm-var array>
- */
- private $prefixDirsPsr4 = array();
- /**
- * @var array[]
- * @psalm-var array
- */
- private $fallbackDirsPsr4 = array();
-
- // PSR-0
- /**
- * @var array[]
- * @psalm-var array>
- */
- private $prefixesPsr0 = array();
- /**
- * @var array[]
- * @psalm-var array
- */
- private $fallbackDirsPsr0 = array();
-
- /** @var bool */
- private $useIncludePath = false;
-
- /**
- * @var string[]
- * @psalm-var array
- */
- private $classMap = array();
-
- /** @var bool */
- private $classMapAuthoritative = false;
-
- /**
- * @var bool[]
- * @psalm-var array
- */
- private $missingClasses = array();
-
- /** @var ?string */
- private $apcuPrefix;
-
- /**
- * @var self[]
- */
- private static $registeredLoaders = array();
-
- /**
- * @param ?string $vendorDir
- */
- public function __construct($vendorDir = null)
- {
- $this->vendorDir = $vendorDir;
- self::initializeIncludeClosure();
- }
-
- /**
- * @return string[]
- */
- public function getPrefixes()
- {
- if (!empty($this->prefixesPsr0)) {
- return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
- }
-
- return array();
- }
-
- /**
- * @return array[]
- * @psalm-return array>
- */
- public function getPrefixesPsr4()
- {
- return $this->prefixDirsPsr4;
- }
-
- /**
- * @return array[]
- * @psalm-return array
- */
- public function getFallbackDirs()
- {
- return $this->fallbackDirsPsr0;
- }
-
- /**
- * @return array[]
- * @psalm-return array
- */
- public function getFallbackDirsPsr4()
- {
- return $this->fallbackDirsPsr4;
- }
-
- /**
- * @return string[] Array of classname => path
- * @psalm-return array
- */
- public function getClassMap()
- {
- return $this->classMap;
- }
-
- /**
- * @param string[] $classMap Class to filename map
- * @psalm-param array $classMap
- *
- * @return void
- */
- public function addClassMap(array $classMap)
- {
- if ($this->classMap) {
- $this->classMap = array_merge($this->classMap, $classMap);
- } else {
- $this->classMap = $classMap;
- }
- }
-
- /**
- * Registers a set of PSR-0 directories for a given prefix, either
- * appending or prepending to the ones previously set for this prefix.
- *
- * @param string $prefix The prefix
- * @param string[]|string $paths The PSR-0 root directories
- * @param bool $prepend Whether to prepend the directories
- *
- * @return void
- */
- public function add($prefix, $paths, $prepend = false)
- {
- if (!$prefix) {
- if ($prepend) {
- $this->fallbackDirsPsr0 = array_merge(
- (array) $paths,
- $this->fallbackDirsPsr0
- );
- } else {
- $this->fallbackDirsPsr0 = array_merge(
- $this->fallbackDirsPsr0,
- (array) $paths
- );
- }
-
- return;
- }
-
- $first = $prefix[0];
- if (!isset($this->prefixesPsr0[$first][$prefix])) {
- $this->prefixesPsr0[$first][$prefix] = (array) $paths;
-
- return;
- }
- if ($prepend) {
- $this->prefixesPsr0[$first][$prefix] = array_merge(
- (array) $paths,
- $this->prefixesPsr0[$first][$prefix]
- );
- } else {
- $this->prefixesPsr0[$first][$prefix] = array_merge(
- $this->prefixesPsr0[$first][$prefix],
- (array) $paths
- );
- }
- }
-
- /**
- * Registers a set of PSR-4 directories for a given namespace, either
- * appending or prepending to the ones previously set for this namespace.
- *
- * @param string $prefix The prefix/namespace, with trailing '\\'
- * @param string[]|string $paths The PSR-4 base directories
- * @param bool $prepend Whether to prepend the directories
- *
- * @throws \InvalidArgumentException
- *
- * @return void
- */
- public function addPsr4($prefix, $paths, $prepend = false)
- {
- if (!$prefix) {
- // Register directories for the root namespace.
- if ($prepend) {
- $this->fallbackDirsPsr4 = array_merge(
- (array) $paths,
- $this->fallbackDirsPsr4
- );
- } else {
- $this->fallbackDirsPsr4 = array_merge(
- $this->fallbackDirsPsr4,
- (array) $paths
- );
- }
- } elseif (!isset($this->prefixDirsPsr4[$prefix])) {
- // Register directories for a new namespace.
- $length = strlen($prefix);
- if ('\\' !== $prefix[$length - 1]) {
- throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
- }
- $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
- $this->prefixDirsPsr4[$prefix] = (array) $paths;
- } elseif ($prepend) {
- // Prepend directories for an already registered namespace.
- $this->prefixDirsPsr4[$prefix] = array_merge(
- (array) $paths,
- $this->prefixDirsPsr4[$prefix]
- );
- } else {
- // Append directories for an already registered namespace.
- $this->prefixDirsPsr4[$prefix] = array_merge(
- $this->prefixDirsPsr4[$prefix],
- (array) $paths
- );
- }
- }
-
- /**
- * Registers a set of PSR-0 directories for a given prefix,
- * replacing any others previously set for this prefix.
- *
- * @param string $prefix The prefix
- * @param string[]|string $paths The PSR-0 base directories
- *
- * @return void
- */
- public function set($prefix, $paths)
- {
- if (!$prefix) {
- $this->fallbackDirsPsr0 = (array) $paths;
- } else {
- $this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
- }
- }
-
- /**
- * Registers a set of PSR-4 directories for a given namespace,
- * replacing any others previously set for this namespace.
- *
- * @param string $prefix The prefix/namespace, with trailing '\\'
- * @param string[]|string $paths The PSR-4 base directories
- *
- * @throws \InvalidArgumentException
- *
- * @return void
- */
- public function setPsr4($prefix, $paths)
- {
- if (!$prefix) {
- $this->fallbackDirsPsr4 = (array) $paths;
- } else {
- $length = strlen($prefix);
- if ('\\' !== $prefix[$length - 1]) {
- throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
- }
- $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
- $this->prefixDirsPsr4[$prefix] = (array) $paths;
- }
- }
-
- /**
- * Turns on searching the include path for class files.
- *
- * @param bool $useIncludePath
- *
- * @return void
- */
- public function setUseIncludePath($useIncludePath)
- {
- $this->useIncludePath = $useIncludePath;
- }
-
- /**
- * Can be used to check if the autoloader uses the include path to check
- * for classes.
- *
- * @return bool
- */
- public function getUseIncludePath()
- {
- return $this->useIncludePath;
- }
-
- /**
- * Turns off searching the prefix and fallback directories for classes
- * that have not been registered with the class map.
- *
- * @param bool $classMapAuthoritative
- *
- * @return void
- */
- public function setClassMapAuthoritative($classMapAuthoritative)
- {
- $this->classMapAuthoritative = $classMapAuthoritative;
- }
-
- /**
- * Should class lookup fail if not found in the current class map?
- *
- * @return bool
- */
- public function isClassMapAuthoritative()
- {
- return $this->classMapAuthoritative;
- }
-
- /**
- * APCu prefix to use to cache found/not-found classes, if the extension is enabled.
- *
- * @param string|null $apcuPrefix
- *
- * @return void
- */
- public function setApcuPrefix($apcuPrefix)
- {
- $this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
- }
-
- /**
- * The APCu prefix in use, or null if APCu caching is not enabled.
- *
- * @return string|null
- */
- public function getApcuPrefix()
- {
- return $this->apcuPrefix;
- }
-
- /**
- * Registers this instance as an autoloader.
- *
- * @param bool $prepend Whether to prepend the autoloader or not
- *
- * @return void
- */
- public function register($prepend = false)
- {
- spl_autoload_register(array($this, 'loadClass'), true, $prepend);
-
- if (null === $this->vendorDir) {
- return;
- }
-
- if ($prepend) {
- self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
- } else {
- unset(self::$registeredLoaders[$this->vendorDir]);
- self::$registeredLoaders[$this->vendorDir] = $this;
- }
- }
-
- /**
- * Unregisters this instance as an autoloader.
- *
- * @return void
- */
- public function unregister()
- {
- spl_autoload_unregister(array($this, 'loadClass'));
-
- if (null !== $this->vendorDir) {
- unset(self::$registeredLoaders[$this->vendorDir]);
- }
- }
-
- /**
- * Loads the given class or interface.
- *
- * @param string $class The name of the class
- * @return true|null True if loaded, null otherwise
- */
- public function loadClass($class)
- {
- if ($file = $this->findFile($class)) {
- $includeFile = self::$includeFile;
- $includeFile($file);
-
- return true;
- }
-
- return null;
- }
-
- /**
- * Finds the path to the file where the class is defined.
- *
- * @param string $class The name of the class
- *
- * @return string|false The path if found, false otherwise
- */
- public function findFile($class)
- {
- // class map lookup
- if (isset($this->classMap[$class])) {
- return $this->classMap[$class];
- }
- if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
- return false;
- }
- if (null !== $this->apcuPrefix) {
- $file = apcu_fetch($this->apcuPrefix.$class, $hit);
- if ($hit) {
- return $file;
- }
- }
-
- $file = $this->findFileWithExtension($class, '.php');
-
- // Search for Hack files if we are running on HHVM
- if (false === $file && defined('HHVM_VERSION')) {
- $file = $this->findFileWithExtension($class, '.hh');
- }
-
- if (null !== $this->apcuPrefix) {
- apcu_add($this->apcuPrefix.$class, $file);
- }
-
- if (false === $file) {
- // Remember that this class does not exist.
- $this->missingClasses[$class] = true;
- }
-
- return $file;
- }
-
- /**
- * Returns the currently registered loaders indexed by their corresponding vendor directories.
- *
- * @return self[]
- */
- public static function getRegisteredLoaders()
- {
- return self::$registeredLoaders;
- }
-
- /**
- * @param string $class
- * @param string $ext
- * @return string|false
- */
- private function findFileWithExtension($class, $ext)
- {
- // PSR-4 lookup
- $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
-
- $first = $class[0];
- if (isset($this->prefixLengthsPsr4[$first])) {
- $subPath = $class;
- while (false !== $lastPos = strrpos($subPath, '\\')) {
- $subPath = substr($subPath, 0, $lastPos);
- $search = $subPath . '\\';
- if (isset($this->prefixDirsPsr4[$search])) {
- $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
- foreach ($this->prefixDirsPsr4[$search] as $dir) {
- if (file_exists($file = $dir . $pathEnd)) {
- return $file;
- }
- }
- }
- }
- }
-
- // PSR-4 fallback dirs
- foreach ($this->fallbackDirsPsr4 as $dir) {
- if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
- return $file;
- }
- }
-
- // PSR-0 lookup
- if (false !== $pos = strrpos($class, '\\')) {
- // namespaced class name
- $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
- . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
- } else {
- // PEAR-like class name
- $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
- }
-
- if (isset($this->prefixesPsr0[$first])) {
- foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
- if (0 === strpos($class, $prefix)) {
- foreach ($dirs as $dir) {
- if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
- return $file;
- }
- }
- }
- }
- }
-
- // PSR-0 fallback dirs
- foreach ($this->fallbackDirsPsr0 as $dir) {
- if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
- return $file;
- }
- }
-
- // PSR-0 include paths.
- if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
- return $file;
- }
-
- return false;
- }
-
- /**
- * @return void
- */
- private static function initializeIncludeClosure()
- {
- if (self::$includeFile !== null) {
- return;
- }
-
- /**
- * Scope isolated include.
- *
- * Prevents access to $this/self from included files.
- *
- * @param string $file
- * @return void
- */
- self::$includeFile = \Closure::bind(static function($file) {
- include $file;
- }, null, null);
- }
-}
diff --git a/lib/phpspreadsheet/vendor/composer/InstalledVersions.php b/lib/phpspreadsheet/vendor/composer/InstalledVersions.php
deleted file mode 100644
index 51e734a774b..00000000000
--- a/lib/phpspreadsheet/vendor/composer/InstalledVersions.php
+++ /dev/null
@@ -1,359 +0,0 @@
-
- * Jordi Boggiano
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace Composer;
-
-use Composer\Autoload\ClassLoader;
-use Composer\Semver\VersionParser;
-
-/**
- * This class is copied in every Composer installed project and available to all
- *
- * See also https://getcomposer.org/doc/07-runtime.md#installed-versions
- *
- * To require its presence, you can require `composer-runtime-api ^2.0`
- *
- * @final
- */
-class InstalledVersions
-{
- /**
- * @var mixed[]|null
- * @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array}|array{}|null
- */
- private static $installed;
-
- /**
- * @var bool|null
- */
- private static $canGetVendors;
-
- /**
- * @var array[]
- * @psalm-var array}>
- */
- private static $installedByVendor = array();
-
- /**
- * Returns a list of all package names which are present, either by being installed, replaced or provided
- *
- * @return string[]
- * @psalm-return list
- */
- public static function getInstalledPackages()
- {
- $packages = array();
- foreach (self::getInstalled() as $installed) {
- $packages[] = array_keys($installed['versions']);
- }
-
- if (1 === \count($packages)) {
- return $packages[0];
- }
-
- return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
- }
-
- /**
- * Returns a list of all package names with a specific type e.g. 'library'
- *
- * @param string $type
- * @return string[]
- * @psalm-return list
- */
- public static function getInstalledPackagesByType($type)
- {
- $packagesByType = array();
-
- foreach (self::getInstalled() as $installed) {
- foreach ($installed['versions'] as $name => $package) {
- if (isset($package['type']) && $package['type'] === $type) {
- $packagesByType[] = $name;
- }
- }
- }
-
- return $packagesByType;
- }
-
- /**
- * Checks whether the given package is installed
- *
- * This also returns true if the package name is provided or replaced by another package
- *
- * @param string $packageName
- * @param bool $includeDevRequirements
- * @return bool
- */
- public static function isInstalled($packageName, $includeDevRequirements = true)
- {
- foreach (self::getInstalled() as $installed) {
- if (isset($installed['versions'][$packageName])) {
- return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
- }
- }
-
- return false;
- }
-
- /**
- * Checks whether the given package satisfies a version constraint
- *
- * e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
- *
- * Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
- *
- * @param VersionParser $parser Install composer/semver to have access to this class and functionality
- * @param string $packageName
- * @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
- * @return bool
- */
- public static function satisfies(VersionParser $parser, $packageName, $constraint)
- {
- $constraint = $parser->parseConstraints((string) $constraint);
- $provided = $parser->parseConstraints(self::getVersionRanges($packageName));
-
- return $provided->matches($constraint);
- }
-
- /**
- * Returns a version constraint representing all the range(s) which are installed for a given package
- *
- * It is easier to use this via isInstalled() with the $constraint argument if you need to check
- * whether a given version of a package is installed, and not just whether it exists
- *
- * @param string $packageName
- * @return string Version constraint usable with composer/semver
- */
- public static function getVersionRanges($packageName)
- {
- foreach (self::getInstalled() as $installed) {
- if (!isset($installed['versions'][$packageName])) {
- continue;
- }
-
- $ranges = array();
- if (isset($installed['versions'][$packageName]['pretty_version'])) {
- $ranges[] = $installed['versions'][$packageName]['pretty_version'];
- }
- if (array_key_exists('aliases', $installed['versions'][$packageName])) {
- $ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
- }
- if (array_key_exists('replaced', $installed['versions'][$packageName])) {
- $ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
- }
- if (array_key_exists('provided', $installed['versions'][$packageName])) {
- $ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
- }
-
- return implode(' || ', $ranges);
- }
-
- throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
- }
-
- /**
- * @param string $packageName
- * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
- */
- public static function getVersion($packageName)
- {
- foreach (self::getInstalled() as $installed) {
- if (!isset($installed['versions'][$packageName])) {
- continue;
- }
-
- if (!isset($installed['versions'][$packageName]['version'])) {
- return null;
- }
-
- return $installed['versions'][$packageName]['version'];
- }
-
- throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
- }
-
- /**
- * @param string $packageName
- * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
- */
- public static function getPrettyVersion($packageName)
- {
- foreach (self::getInstalled() as $installed) {
- if (!isset($installed['versions'][$packageName])) {
- continue;
- }
-
- if (!isset($installed['versions'][$packageName]['pretty_version'])) {
- return null;
- }
-
- return $installed['versions'][$packageName]['pretty_version'];
- }
-
- throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
- }
-
- /**
- * @param string $packageName
- * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
- */
- public static function getReference($packageName)
- {
- foreach (self::getInstalled() as $installed) {
- if (!isset($installed['versions'][$packageName])) {
- continue;
- }
-
- if (!isset($installed['versions'][$packageName]['reference'])) {
- return null;
- }
-
- return $installed['versions'][$packageName]['reference'];
- }
-
- throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
- }
-
- /**
- * @param string $packageName
- * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
- */
- public static function getInstallPath($packageName)
- {
- foreach (self::getInstalled() as $installed) {
- if (!isset($installed['versions'][$packageName])) {
- continue;
- }
-
- return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
- }
-
- throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
- }
-
- /**
- * @return array
- * @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
- */
- public static function getRootPackage()
- {
- $installed = self::getInstalled();
-
- return $installed[0]['root'];
- }
-
- /**
- * Returns the raw installed.php data for custom implementations
- *
- * @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
- * @return array[]
- * @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array}
- */
- public static function getRawData()
- {
- @trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
-
- if (null === self::$installed) {
- // only require the installed.php file if this file is loaded from its dumped location,
- // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
- if (substr(__DIR__, -8, 1) !== 'C') {
- self::$installed = include __DIR__ . '/installed.php';
- } else {
- self::$installed = array();
- }
- }
-
- return self::$installed;
- }
-
- /**
- * Returns the raw data of all installed.php which are currently loaded for custom implementations
- *
- * @return array[]
- * @psalm-return list}>
- */
- public static function getAllRawData()
- {
- return self::getInstalled();
- }
-
- /**
- * Lets you reload the static array from another file
- *
- * This is only useful for complex integrations in which a project needs to use
- * this class but then also needs to execute another project's autoloader in process,
- * and wants to ensure both projects have access to their version of installed.php.
- *
- * A typical case would be PHPUnit, where it would need to make sure it reads all
- * the data it needs from this class, then call reload() with
- * `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
- * the project in which it runs can then also use this class safely, without
- * interference between PHPUnit's dependencies and the project's dependencies.
- *
- * @param array[] $data A vendor/composer/installed.php data set
- * @return void
- *
- * @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $data
- */
- public static function reload($data)
- {
- self::$installed = $data;
- self::$installedByVendor = array();
- }
-
- /**
- * @return array[]
- * @psalm-return list}>
- */
- private static function getInstalled()
- {
- if (null === self::$canGetVendors) {
- self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
- }
-
- $installed = array();
-
- if (self::$canGetVendors) {
- foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
- if (isset(self::$installedByVendor[$vendorDir])) {
- $installed[] = self::$installedByVendor[$vendorDir];
- } elseif (is_file($vendorDir.'/composer/installed.php')) {
- /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */
- $required = require $vendorDir.'/composer/installed.php';
- $installed[] = self::$installedByVendor[$vendorDir] = $required;
- if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) {
- self::$installed = $installed[count($installed) - 1];
- }
- }
- }
- }
-
- if (null === self::$installed) {
- // only require the installed.php file if this file is loaded from its dumped location,
- // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
- if (substr(__DIR__, -8, 1) !== 'C') {
- /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */
- $required = require __DIR__ . '/installed.php';
- self::$installed = $required;
- } else {
- self::$installed = array();
- }
- }
-
- if (self::$installed !== array()) {
- $installed[] = self::$installed;
- }
-
- return $installed;
- }
-}
diff --git a/lib/phpspreadsheet/vendor/composer/autoload_classmap.php b/lib/phpspreadsheet/vendor/composer/autoload_classmap.php
deleted file mode 100644
index 0fb0a2c194b..00000000000
--- a/lib/phpspreadsheet/vendor/composer/autoload_classmap.php
+++ /dev/null
@@ -1,10 +0,0 @@
- $vendorDir . '/composer/InstalledVersions.php',
-);
diff --git a/lib/phpspreadsheet/vendor/composer/autoload_namespaces.php b/lib/phpspreadsheet/vendor/composer/autoload_namespaces.php
deleted file mode 100644
index 15a2ff3ad6d..00000000000
--- a/lib/phpspreadsheet/vendor/composer/autoload_namespaces.php
+++ /dev/null
@@ -1,9 +0,0 @@
- array($vendorDir . '/psr/simple-cache/src'),
- 'Psr\\Http\\Message\\' => array($vendorDir . '/psr/http-message/src', $vendorDir . '/psr/http-factory/src'),
- 'Psr\\Http\\Client\\' => array($vendorDir . '/psr/http-client/src'),
- 'PhpOffice\\PhpSpreadsheet\\' => array($vendorDir . '/phpoffice/phpspreadsheet/src/PhpSpreadsheet'),
- 'Matrix\\' => array($vendorDir . '/markbaker/matrix/classes/src'),
- 'Complex\\' => array($vendorDir . '/markbaker/complex/classes/src'),
-);
diff --git a/lib/phpspreadsheet/vendor/composer/autoload_real.php b/lib/phpspreadsheet/vendor/composer/autoload_real.php
deleted file mode 100644
index d51084dc0ec..00000000000
--- a/lib/phpspreadsheet/vendor/composer/autoload_real.php
+++ /dev/null
@@ -1,38 +0,0 @@
-register(true);
-
- return $loader;
- }
-}
diff --git a/lib/phpspreadsheet/vendor/composer/autoload_static.php b/lib/phpspreadsheet/vendor/composer/autoload_static.php
deleted file mode 100644
index e90be9b6b69..00000000000
--- a/lib/phpspreadsheet/vendor/composer/autoload_static.php
+++ /dev/null
@@ -1,68 +0,0 @@
-
- array (
- 'Psr\\SimpleCache\\' => 16,
- 'Psr\\Http\\Message\\' => 17,
- 'Psr\\Http\\Client\\' => 16,
- 'PhpOffice\\PhpSpreadsheet\\' => 25,
- ),
- 'M' =>
- array (
- 'Matrix\\' => 7,
- ),
- 'C' =>
- array (
- 'Complex\\' => 8,
- ),
- );
-
- public static $prefixDirsPsr4 = array (
- 'Psr\\SimpleCache\\' =>
- array (
- 0 => __DIR__ . '/..' . '/psr/simple-cache/src',
- ),
- 'Psr\\Http\\Message\\' =>
- array (
- 0 => __DIR__ . '/..' . '/psr/http-message/src',
- 1 => __DIR__ . '/..' . '/psr/http-factory/src',
- ),
- 'Psr\\Http\\Client\\' =>
- array (
- 0 => __DIR__ . '/..' . '/psr/http-client/src',
- ),
- 'PhpOffice\\PhpSpreadsheet\\' =>
- array (
- 0 => __DIR__ . '/..' . '/phpoffice/phpspreadsheet/src/PhpSpreadsheet',
- ),
- 'Matrix\\' =>
- array (
- 0 => __DIR__ . '/..' . '/markbaker/matrix/classes/src',
- ),
- 'Complex\\' =>
- array (
- 0 => __DIR__ . '/..' . '/markbaker/complex/classes/src',
- ),
- );
-
- public static $classMap = array (
- 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
- );
-
- public static function getInitializer(ClassLoader $loader)
- {
- return \Closure::bind(function () use ($loader) {
- $loader->prefixLengthsPsr4 = ComposerStaticInitf14832faa9ea8f0ad137e596f5daa06a::$prefixLengthsPsr4;
- $loader->prefixDirsPsr4 = ComposerStaticInitf14832faa9ea8f0ad137e596f5daa06a::$prefixDirsPsr4;
- $loader->classMap = ComposerStaticInitf14832faa9ea8f0ad137e596f5daa06a::$classMap;
-
- }, null, ClassLoader::class);
- }
-}
diff --git a/lib/phpspreadsheet/vendor/composer/installed.json b/lib/phpspreadsheet/vendor/composer/installed.json
deleted file mode 100644
index f37a4542a31..00000000000
--- a/lib/phpspreadsheet/vendor/composer/installed.json
+++ /dev/null
@@ -1,450 +0,0 @@
-{
- "packages": [
- {
- "name": "markbaker/complex",
- "version": "3.0.2",
- "version_normalized": "3.0.2.0",
- "source": {
- "type": "git",
- "url": "https://github.com/MarkBaker/PHPComplex.git",
- "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
- "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
- "shasum": ""
- },
- "require": {
- "php": "^7.2 || ^8.0"
- },
- "require-dev": {
- "dealerdirect/phpcodesniffer-composer-installer": "dev-master",
- "phpcompatibility/php-compatibility": "^9.3",
- "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
- "squizlabs/php_codesniffer": "^3.7"
- },
- "time": "2022-12-06T16:21:08+00:00",
- "type": "library",
- "installation-source": "dist",
- "autoload": {
- "psr-4": {
- "Complex\\": "classes/src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Mark Baker",
- "email": "mark@lange.demon.co.uk"
- }
- ],
- "description": "PHP Class for working with complex numbers",
- "homepage": "https://github.com/MarkBaker/PHPComplex",
- "keywords": [
- "complex",
- "mathematics"
- ],
- "support": {
- "issues": "https://github.com/MarkBaker/PHPComplex/issues",
- "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
- },
- "install-path": "../markbaker/complex"
- },
- {
- "name": "markbaker/matrix",
- "version": "3.0.1",
- "version_normalized": "3.0.1.0",
- "source": {
- "type": "git",
- "url": "https://github.com/MarkBaker/PHPMatrix.git",
- "reference": "728434227fe21be27ff6d86621a1b13107a2562c"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
- "reference": "728434227fe21be27ff6d86621a1b13107a2562c",
- "shasum": ""
- },
- "require": {
- "php": "^7.1 || ^8.0"
- },
- "require-dev": {
- "dealerdirect/phpcodesniffer-composer-installer": "dev-master",
- "phpcompatibility/php-compatibility": "^9.3",
- "phpdocumentor/phpdocumentor": "2.*",
- "phploc/phploc": "^4.0",
- "phpmd/phpmd": "2.*",
- "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
- "sebastian/phpcpd": "^4.0",
- "squizlabs/php_codesniffer": "^3.7"
- },
- "time": "2022-12-02T22:17:43+00:00",
- "type": "library",
- "installation-source": "dist",
- "autoload": {
- "psr-4": {
- "Matrix\\": "classes/src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Mark Baker",
- "email": "mark@demon-angel.eu"
- }
- ],
- "description": "PHP Class for working with matrices",
- "homepage": "https://github.com/MarkBaker/PHPMatrix",
- "keywords": [
- "mathematics",
- "matrix",
- "vector"
- ],
- "support": {
- "issues": "https://github.com/MarkBaker/PHPMatrix/issues",
- "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
- },
- "install-path": "../markbaker/matrix"
- },
- {
- "name": "phpoffice/phpspreadsheet",
- "version": "1.29.0",
- "version_normalized": "1.29.0.0",
- "source": {
- "type": "git",
- "url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
- "reference": "fde2ccf55eaef7e86021ff1acce26479160a0fa0"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/fde2ccf55eaef7e86021ff1acce26479160a0fa0",
- "reference": "fde2ccf55eaef7e86021ff1acce26479160a0fa0",
- "shasum": ""
- },
- "require": {
- "ext-ctype": "*",
- "ext-dom": "*",
- "ext-fileinfo": "*",
- "ext-gd": "*",
- "ext-iconv": "*",
- "ext-libxml": "*",
- "ext-mbstring": "*",
- "ext-simplexml": "*",
- "ext-xml": "*",
- "ext-xmlreader": "*",
- "ext-xmlwriter": "*",
- "ext-zip": "*",
- "ext-zlib": "*",
- "ezyang/htmlpurifier": "^4.15",
- "maennchen/zipstream-php": "^2.1 || ^3.0",
- "markbaker/complex": "^3.0",
- "markbaker/matrix": "^3.0",
- "php": "^7.4 || ^8.0",
- "psr/http-client": "^1.0",
- "psr/http-factory": "^1.0",
- "psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
- },
- "require-dev": {
- "dealerdirect/phpcodesniffer-composer-installer": "dev-main",
- "dompdf/dompdf": "^1.0 || ^2.0",
- "friendsofphp/php-cs-fixer": "^3.2",
- "mitoteam/jpgraph": "^10.3",
- "mpdf/mpdf": "^8.1.1",
- "phpcompatibility/php-compatibility": "^9.3",
- "phpstan/phpstan": "^1.1",
- "phpstan/phpstan-phpunit": "^1.0",
- "phpunit/phpunit": "^8.5 || ^9.0 || ^10.0",
- "squizlabs/php_codesniffer": "^3.7",
- "tecnickcom/tcpdf": "^6.5"
- },
- "suggest": {
- "dompdf/dompdf": "Option for rendering PDF with PDF Writer",
- "ext-intl": "PHP Internationalization Functions",
- "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
- "mpdf/mpdf": "Option for rendering PDF with PDF Writer",
- "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
- },
- "time": "2023-06-14T22:48:31+00:00",
- "type": "library",
- "installation-source": "dist",
- "autoload": {
- "psr-4": {
- "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Maarten Balliauw",
- "homepage": "https://blog.maartenballiauw.be"
- },
- {
- "name": "Mark Baker",
- "homepage": "https://markbakeruk.net"
- },
- {
- "name": "Franck Lefevre",
- "homepage": "https://rootslabs.net"
- },
- {
- "name": "Erik Tilt"
- },
- {
- "name": "Adrien Crivelli"
- }
- ],
- "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
- "homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
- "keywords": [
- "OpenXML",
- "excel",
- "gnumeric",
- "ods",
- "php",
- "spreadsheet",
- "xls",
- "xlsx"
- ],
- "support": {
- "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
- "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.29.0"
- },
- "install-path": "../phpoffice/phpspreadsheet"
- },
- {
- "name": "psr/http-client",
- "version": "1.0.2",
- "version_normalized": "1.0.2.0",
- "source": {
- "type": "git",
- "url": "https://github.com/php-fig/http-client.git",
- "reference": "0955afe48220520692d2d09f7ab7e0f93ffd6a31"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/php-fig/http-client/zipball/0955afe48220520692d2d09f7ab7e0f93ffd6a31",
- "reference": "0955afe48220520692d2d09f7ab7e0f93ffd6a31",
- "shasum": ""
- },
- "require": {
- "php": "^7.0 || ^8.0",
- "psr/http-message": "^1.0 || ^2.0"
- },
- "time": "2023-04-10T20:12:12+00:00",
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.0.x-dev"
- }
- },
- "installation-source": "dist",
- "autoload": {
- "psr-4": {
- "Psr\\Http\\Client\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
- }
- ],
- "description": "Common interface for HTTP clients",
- "homepage": "https://github.com/php-fig/http-client",
- "keywords": [
- "http",
- "http-client",
- "psr",
- "psr-18"
- ],
- "support": {
- "source": "https://github.com/php-fig/http-client/tree/1.0.2"
- },
- "install-path": "../psr/http-client"
- },
- {
- "name": "psr/http-factory",
- "version": "1.0.2",
- "version_normalized": "1.0.2.0",
- "source": {
- "type": "git",
- "url": "https://github.com/php-fig/http-factory.git",
- "reference": "e616d01114759c4c489f93b099585439f795fe35"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/php-fig/http-factory/zipball/e616d01114759c4c489f93b099585439f795fe35",
- "reference": "e616d01114759c4c489f93b099585439f795fe35",
- "shasum": ""
- },
- "require": {
- "php": ">=7.0.0",
- "psr/http-message": "^1.0 || ^2.0"
- },
- "time": "2023-04-10T20:10:41+00:00",
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.0.x-dev"
- }
- },
- "installation-source": "dist",
- "autoload": {
- "psr-4": {
- "Psr\\Http\\Message\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
- }
- ],
- "description": "Common interfaces for PSR-7 HTTP message factories",
- "keywords": [
- "factory",
- "http",
- "message",
- "psr",
- "psr-17",
- "psr-7",
- "request",
- "response"
- ],
- "support": {
- "source": "https://github.com/php-fig/http-factory/tree/1.0.2"
- },
- "install-path": "../psr/http-factory"
- },
- {
- "name": "psr/http-message",
- "version": "2.0",
- "version_normalized": "2.0.0.0",
- "source": {
- "type": "git",
- "url": "https://github.com/php-fig/http-message.git",
- "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
- "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
- "shasum": ""
- },
- "require": {
- "php": "^7.2 || ^8.0"
- },
- "time": "2023-04-04T09:54:51+00:00",
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "2.0.x-dev"
- }
- },
- "installation-source": "dist",
- "autoload": {
- "psr-4": {
- "Psr\\Http\\Message\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
- }
- ],
- "description": "Common interface for HTTP messages",
- "homepage": "https://github.com/php-fig/http-message",
- "keywords": [
- "http",
- "http-message",
- "psr",
- "psr-7",
- "request",
- "response"
- ],
- "support": {
- "source": "https://github.com/php-fig/http-message/tree/2.0"
- },
- "install-path": "../psr/http-message"
- },
- {
- "name": "psr/simple-cache",
- "version": "3.0.0",
- "version_normalized": "3.0.0.0",
- "source": {
- "type": "git",
- "url": "https://github.com/php-fig/simple-cache.git",
- "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
- "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
- "shasum": ""
- },
- "require": {
- "php": ">=8.0.0"
- },
- "time": "2021-10-29T13:26:27+00:00",
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "3.0.x-dev"
- }
- },
- "installation-source": "dist",
- "autoload": {
- "psr-4": {
- "Psr\\SimpleCache\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
- }
- ],
- "description": "Common interfaces for simple caching",
- "keywords": [
- "cache",
- "caching",
- "psr",
- "psr-16",
- "simple-cache"
- ],
- "support": {
- "source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
- },
- "install-path": "../psr/simple-cache"
- }
- ],
- "dev": true,
- "dev-package-names": []
-}
diff --git a/lib/phpspreadsheet/vendor/composer/installed.php b/lib/phpspreadsheet/vendor/composer/installed.php
deleted file mode 100644
index e8b22835b1d..00000000000
--- a/lib/phpspreadsheet/vendor/composer/installed.php
+++ /dev/null
@@ -1,110 +0,0 @@
- array(
- 'name' => '__root__',
- 'pretty_version' => '1.0.0+no-version-set',
- 'version' => '1.0.0.0',
- 'reference' => NULL,
- 'type' => 'library',
- 'install_path' => __DIR__ . '/../../',
- 'aliases' => array(),
- 'dev' => true,
- ),
- 'versions' => array(
- '__root__' => array(
- 'pretty_version' => '1.0.0+no-version-set',
- 'version' => '1.0.0.0',
- 'reference' => NULL,
- 'type' => 'library',
- 'install_path' => __DIR__ . '/../../',
- 'aliases' => array(),
- 'dev_requirement' => false,
- ),
- 'ezyang/htmlpurifier' => array(
- 'dev_requirement' => false,
- 'replaced' => array(
- 0 => '*',
- ),
- ),
- 'maennchen/zipstream-php' => array(
- 'dev_requirement' => false,
- 'replaced' => array(
- 0 => '*',
- ),
- ),
- 'markbaker/complex' => array(
- 'pretty_version' => '3.0.2',
- 'version' => '3.0.2.0',
- 'reference' => '95c56caa1cf5c766ad6d65b6344b807c1e8405b9',
- 'type' => 'library',
- 'install_path' => __DIR__ . '/../markbaker/complex',
- 'aliases' => array(),
- 'dev_requirement' => false,
- ),
- 'markbaker/matrix' => array(
- 'pretty_version' => '3.0.1',
- 'version' => '3.0.1.0',
- 'reference' => '728434227fe21be27ff6d86621a1b13107a2562c',
- 'type' => 'library',
- 'install_path' => __DIR__ . '/../markbaker/matrix',
- 'aliases' => array(),
- 'dev_requirement' => false,
- ),
- 'myclabs/php-enum' => array(
- 'dev_requirement' => false,
- 'replaced' => array(
- 0 => '*',
- ),
- ),
- 'phpoffice/phpspreadsheet' => array(
- 'pretty_version' => '1.29.0',
- 'version' => '1.29.0.0',
- 'reference' => 'fde2ccf55eaef7e86021ff1acce26479160a0fa0',
- 'type' => 'library',
- 'install_path' => __DIR__ . '/../phpoffice/phpspreadsheet',
- 'aliases' => array(),
- 'dev_requirement' => false,
- ),
- 'psr/http-client' => array(
- 'pretty_version' => '1.0.2',
- 'version' => '1.0.2.0',
- 'reference' => '0955afe48220520692d2d09f7ab7e0f93ffd6a31',
- 'type' => 'library',
- 'install_path' => __DIR__ . '/../psr/http-client',
- 'aliases' => array(),
- 'dev_requirement' => false,
- ),
- 'psr/http-factory' => array(
- 'pretty_version' => '1.0.2',
- 'version' => '1.0.2.0',
- 'reference' => 'e616d01114759c4c489f93b099585439f795fe35',
- 'type' => 'library',
- 'install_path' => __DIR__ . '/../psr/http-factory',
- 'aliases' => array(),
- 'dev_requirement' => false,
- ),
- 'psr/http-message' => array(
- 'pretty_version' => '2.0',
- 'version' => '2.0.0.0',
- 'reference' => '402d35bcb92c70c026d1a6a9883f06b2ead23d71',
- 'type' => 'library',
- 'install_path' => __DIR__ . '/../psr/http-message',
- 'aliases' => array(),
- 'dev_requirement' => false,
- ),
- 'psr/simple-cache' => array(
- 'pretty_version' => '3.0.0',
- 'version' => '3.0.0.0',
- 'reference' => '764e0b3939f5ca87cb904f570ef9be2d78a07865',
- 'type' => 'library',
- 'install_path' => __DIR__ . '/../psr/simple-cache',
- 'aliases' => array(),
- 'dev_requirement' => false,
- ),
- 'symfony/polyfill-mbstring' => array(
- 'dev_requirement' => false,
- 'replaced' => array(
- 0 => '*',
- ),
- ),
- ),
-);
diff --git a/lib/phpspreadsheet/vendor/composer/platform_check.php b/lib/phpspreadsheet/vendor/composer/platform_check.php
deleted file mode 100644
index adfb472fbdd..00000000000
--- a/lib/phpspreadsheet/vendor/composer/platform_check.php
+++ /dev/null
@@ -1,26 +0,0 @@
-= 80000)) {
- $issues[] = 'Your Composer dependencies require a PHP version ">= 8.0.0". You are running ' . PHP_VERSION . '.';
-}
-
-if ($issues) {
- if (!headers_sent()) {
- header('HTTP/1.1 500 Internal Server Error');
- }
- if (!ini_get('display_errors')) {
- if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
- fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL);
- } elseif (!headers_sent()) {
- echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
- }
- }
- trigger_error(
- 'Composer detected issues in your platform: ' . implode(' ', $issues),
- E_USER_ERROR
- );
-}
diff --git a/lib/phpspreadsheet/vendor/markbaker/matrix/examples/test.php b/lib/phpspreadsheet/vendor/markbaker/matrix/examples/test.php
deleted file mode 100644
index 071dae910c0..00000000000
--- a/lib/phpspreadsheet/vendor/markbaker/matrix/examples/test.php
+++ /dev/null
@@ -1,33 +0,0 @@
-solve($target);
-
-echo 'X', PHP_EOL;
-var_export($X->toArray());
-echo PHP_EOL;
-
-$resolve = $matrix->multiply($X);
-
-echo 'Resolve', PHP_EOL;
-var_export($resolve->toArray());
-echo PHP_EOL;
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/CodePage.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/CodePage.php
deleted file mode 100644
index 8718a6135ed..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/CodePage.php
+++ /dev/null
@@ -1,114 +0,0 @@
- 'CP1252', // CodePage is not always correctly set when the xls file was saved by Apple's Numbers program
- 367 => 'ASCII', // ASCII
- 437 => 'CP437', // OEM US
- //720 => 'notsupported', // OEM Arabic
- 737 => 'CP737', // OEM Greek
- 775 => 'CP775', // OEM Baltic
- 850 => 'CP850', // OEM Latin I
- 852 => 'CP852', // OEM Latin II (Central European)
- 855 => 'CP855', // OEM Cyrillic
- 857 => 'CP857', // OEM Turkish
- 858 => 'CP858', // OEM Multilingual Latin I with Euro
- 860 => 'CP860', // OEM Portugese
- 861 => 'CP861', // OEM Icelandic
- 862 => 'CP862', // OEM Hebrew
- 863 => 'CP863', // OEM Canadian (French)
- 864 => 'CP864', // OEM Arabic
- 865 => 'CP865', // OEM Nordic
- 866 => 'CP866', // OEM Cyrillic (Russian)
- 869 => 'CP869', // OEM Greek (Modern)
- 874 => 'CP874', // ANSI Thai
- 932 => 'CP932', // ANSI Japanese Shift-JIS
- 936 => 'CP936', // ANSI Chinese Simplified GBK
- 949 => 'CP949', // ANSI Korean (Wansung)
- 950 => 'CP950', // ANSI Chinese Traditional BIG5
- 1200 => 'UTF-16LE', // UTF-16 (BIFF8)
- 1250 => 'CP1250', // ANSI Latin II (Central European)
- 1251 => 'CP1251', // ANSI Cyrillic
- 1252 => 'CP1252', // ANSI Latin I (BIFF4-BIFF7)
- 1253 => 'CP1253', // ANSI Greek
- 1254 => 'CP1254', // ANSI Turkish
- 1255 => 'CP1255', // ANSI Hebrew
- 1256 => 'CP1256', // ANSI Arabic
- 1257 => 'CP1257', // ANSI Baltic
- 1258 => 'CP1258', // ANSI Vietnamese
- 1361 => 'CP1361', // ANSI Korean (Johab)
- 10000 => 'MAC', // Apple Roman
- 10001 => 'CP932', // Macintosh Japanese
- 10002 => 'CP950', // Macintosh Chinese Traditional
- 10003 => 'CP1361', // Macintosh Korean
- 10004 => 'MACARABIC', // Apple Arabic
- 10005 => 'MACHEBREW', // Apple Hebrew
- 10006 => 'MACGREEK', // Macintosh Greek
- 10007 => 'MACCYRILLIC', // Macintosh Cyrillic
- 10008 => 'CP936', // Macintosh - Simplified Chinese (GB 2312)
- 10010 => 'MACROMANIA', // Macintosh Romania
- 10017 => 'MACUKRAINE', // Macintosh Ukraine
- 10021 => 'MACTHAI', // Macintosh Thai
- 10029 => ['MACCENTRALEUROPE', 'MAC-CENTRALEUROPE'], // Macintosh Central Europe
- 10079 => 'MACICELAND', // Macintosh Icelandic
- 10081 => 'MACTURKISH', // Macintosh Turkish
- 10082 => 'MACCROATIAN', // Macintosh Croatian
- 21010 => 'UTF-16LE', // UTF-16 (BIFF8) This isn't correct, but some Excel writer libraries erroneously use Codepage 21010 for UTF-16LE
- 32768 => 'MAC', // Apple Roman
- //32769 => 'unsupported', // ANSI Latin I (BIFF2-BIFF3)
- 65000 => 'UTF-7', // Unicode (UTF-7)
- 65001 => 'UTF-8', // Unicode (UTF-8)
- 99999 => ['unsupported'], // Unicode (UTF-8)
- ];
-
- public static function validate(string $codePage): bool
- {
- return in_array($codePage, self::$pageArray, true);
- }
-
- /**
- * Convert Microsoft Code Page Identifier to Code Page Name which iconv
- * and mbstring understands.
- *
- * @param int $codePage Microsoft Code Page Indentifier
- *
- * @return string Code Page Name
- */
- public static function numberToName(int $codePage): string
- {
- if (array_key_exists($codePage, self::$pageArray)) {
- $value = self::$pageArray[$codePage];
- if (is_array($value)) {
- foreach ($value as $encoding) {
- if (@iconv('UTF-8', $encoding, ' ') !== false) {
- self::$pageArray[$codePage] = $encoding;
-
- return $encoding;
- }
- }
-
- throw new PhpSpreadsheetException("Code page $codePage not implemented on this system.");
- } else {
- return $value;
- }
- }
- if ($codePage == 720 || $codePage == 32769) {
- throw new PhpSpreadsheetException("Code page $codePage not supported."); // OEM Arabic
- }
-
- throw new PhpSpreadsheetException('Unknown codepage: ' . $codePage);
- }
-
- public static function getEncodings(): array
- {
- return self::$pageArray;
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Date.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Date.php
deleted file mode 100644
index 4f196731131..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Date.php
+++ /dev/null
@@ -1,556 +0,0 @@
- 'January',
- 'Feb' => 'February',
- 'Mar' => 'March',
- 'Apr' => 'April',
- 'May' => 'May',
- 'Jun' => 'June',
- 'Jul' => 'July',
- 'Aug' => 'August',
- 'Sep' => 'September',
- 'Oct' => 'October',
- 'Nov' => 'November',
- 'Dec' => 'December',
- ];
-
- /**
- * @var string[]
- */
- public static $numberSuffixes = [
- 'st',
- 'nd',
- 'rd',
- 'th',
- ];
-
- /**
- * Base calendar year to use for calculations
- * Value is either CALENDAR_WINDOWS_1900 (1900) or CALENDAR_MAC_1904 (1904).
- *
- * @var int
- */
- protected static $excelCalendar = self::CALENDAR_WINDOWS_1900;
-
- /**
- * Default timezone to use for DateTime objects.
- *
- * @var null|DateTimeZone
- */
- protected static $defaultTimeZone;
-
- /**
- * Set the Excel calendar (Windows 1900 or Mac 1904).
- *
- * @param int $baseYear Excel base date (1900 or 1904)
- *
- * @return bool Success or failure
- */
- public static function setExcelCalendar($baseYear)
- {
- if (
- ($baseYear == self::CALENDAR_WINDOWS_1900) ||
- ($baseYear == self::CALENDAR_MAC_1904)
- ) {
- self::$excelCalendar = $baseYear;
-
- return true;
- }
-
- return false;
- }
-
- /**
- * Return the Excel calendar (Windows 1900 or Mac 1904).
- *
- * @return int Excel base date (1900 or 1904)
- */
- public static function getExcelCalendar()
- {
- return self::$excelCalendar;
- }
-
- /**
- * Set the Default timezone to use for dates.
- *
- * @param null|DateTimeZone|string $timeZone The timezone to set for all Excel datetimestamp to PHP DateTime Object conversions
- *
- * @return bool Success or failure
- */
- public static function setDefaultTimezone($timeZone)
- {
- try {
- $timeZone = self::validateTimeZone($timeZone);
- self::$defaultTimeZone = $timeZone;
- $retval = true;
- } catch (PhpSpreadsheetException $e) {
- $retval = false;
- }
-
- return $retval;
- }
-
- /**
- * Return the Default timezone, or UTC if default not set.
- */
- public static function getDefaultTimezone(): DateTimeZone
- {
- return self::$defaultTimeZone ?? new DateTimeZone('UTC');
- }
-
- /**
- * Return the Default timezone, or local timezone if default is not set.
- */
- public static function getDefaultOrLocalTimezone(): DateTimeZone
- {
- return self::$defaultTimeZone ?? new DateTimeZone(date_default_timezone_get());
- }
-
- /**
- * Return the Default timezone even if null.
- */
- public static function getDefaultTimezoneOrNull(): ?DateTimeZone
- {
- return self::$defaultTimeZone;
- }
-
- /**
- * Validate a timezone.
- *
- * @param null|DateTimeZone|string $timeZone The timezone to validate, either as a timezone string or object
- *
- * @return ?DateTimeZone The timezone as a timezone object
- */
- private static function validateTimeZone($timeZone)
- {
- if ($timeZone instanceof DateTimeZone || $timeZone === null) {
- return $timeZone;
- }
- if (in_array($timeZone, DateTimeZone::listIdentifiers(DateTimeZone::ALL_WITH_BC))) {
- return new DateTimeZone($timeZone);
- }
-
- throw new PhpSpreadsheetException('Invalid timezone');
- }
-
- /**
- * @param mixed $value Converts a date/time in ISO-8601 standard format date string to an Excel
- * serialized timestamp.
- * See https://en.wikipedia.org/wiki/ISO_8601 for details of the ISO-8601 standard format.
- *
- * @return float|int
- */
- public static function convertIsoDate($value)
- {
- if (!is_string($value)) {
- throw new Exception('Non-string value supplied for Iso Date conversion');
- }
-
- $date = new DateTime($value);
- $dateErrors = DateTime::getLastErrors();
-
- if (is_array($dateErrors) && ($dateErrors['warning_count'] > 0 || $dateErrors['error_count'] > 0)) {
- throw new Exception("Invalid string $value supplied for datatype Date");
- }
-
- $newValue = SharedDate::PHPToExcel($date);
- if ($newValue === false) {
- throw new Exception("Invalid string $value supplied for datatype Date");
- }
-
- if (preg_match('/^\\s*\\d?\\d:\\d\\d(:\\d\\d([.]\\d+)?)?\\s*(am|pm)?\\s*$/i', $value) == 1) {
- $newValue = fmod($newValue, 1.0);
- }
-
- return $newValue;
- }
-
- /**
- * Convert a MS serialized datetime value from Excel to a PHP Date/Time object.
- *
- * @param float|int $excelTimestamp MS Excel serialized date/time value
- * @param null|DateTimeZone|string $timeZone The timezone to assume for the Excel timestamp,
- * if you don't want to treat it as a UTC value
- * Use the default (UTC) unless you absolutely need a conversion
- *
- * @return DateTime PHP date/time object
- */
- public static function excelToDateTimeObject($excelTimestamp, $timeZone = null)
- {
- $timeZone = ($timeZone === null) ? self::getDefaultTimezone() : self::validateTimeZone($timeZone);
- if (Functions::getCompatibilityMode() == Functions::COMPATIBILITY_EXCEL) {
- if ($excelTimestamp < 1 && self::$excelCalendar === self::CALENDAR_WINDOWS_1900) {
- // Unix timestamp base date
- $baseDate = new DateTime('1970-01-01', $timeZone);
- } else {
- // MS Excel calendar base dates
- if (self::$excelCalendar == self::CALENDAR_WINDOWS_1900) {
- // Allow adjustment for 1900 Leap Year in MS Excel
- $baseDate = ($excelTimestamp < 60) ? new DateTime('1899-12-31', $timeZone) : new DateTime('1899-12-30', $timeZone);
- } else {
- $baseDate = new DateTime('1904-01-01', $timeZone);
- }
- }
- } else {
- $baseDate = new DateTime('1899-12-30', $timeZone);
- }
-
- $days = floor($excelTimestamp);
- $partDay = $excelTimestamp - $days;
- $hours = floor($partDay * 24);
- $partDay = $partDay * 24 - $hours;
- $minutes = floor($partDay * 60);
- $partDay = $partDay * 60 - $minutes;
- $seconds = round($partDay * 60);
-
- if ($days >= 0) {
- $days = '+' . $days;
- }
- $interval = $days . ' days';
-
- return $baseDate->modify($interval)
- ->setTime((int) $hours, (int) $minutes, (int) $seconds);
- }
-
- /**
- * Convert a MS serialized datetime value from Excel to a unix timestamp.
- * The use of Unix timestamps, and therefore this function, is discouraged.
- * They are not Y2038-safe on a 32-bit system, and have no timezone info.
- *
- * @param float|int $excelTimestamp MS Excel serialized date/time value
- * @param null|DateTimeZone|string $timeZone The timezone to assume for the Excel timestamp,
- * if you don't want to treat it as a UTC value
- * Use the default (UTC) unless you absolutely need a conversion
- *
- * @return int Unix timetamp for this date/time
- */
- public static function excelToTimestamp($excelTimestamp, $timeZone = null)
- {
- return (int) self::excelToDateTimeObject($excelTimestamp, $timeZone)
- ->format('U');
- }
-
- /**
- * Convert a date from PHP to an MS Excel serialized date/time value.
- *
- * @param mixed $dateValue PHP DateTime object or a string - Unix timestamp is also permitted, but discouraged;
- * not Y2038-safe on a 32-bit system, and no timezone info
- *
- * @return false|float Excel date/time value
- * or boolean FALSE on failure
- */
- public static function PHPToExcel($dateValue)
- {
- if ((is_object($dateValue)) && ($dateValue instanceof DateTimeInterface)) {
- return self::dateTimeToExcel($dateValue);
- } elseif (is_numeric($dateValue)) {
- return self::timestampToExcel($dateValue);
- } elseif (is_string($dateValue)) {
- return self::stringToExcel($dateValue);
- }
-
- return false;
- }
-
- /**
- * Convert a PHP DateTime object to an MS Excel serialized date/time value.
- *
- * @param DateTimeInterface $dateValue PHP DateTime object
- *
- * @return float MS Excel serialized date/time value
- */
- public static function dateTimeToExcel(DateTimeInterface $dateValue)
- {
- return self::formattedPHPToExcel(
- (int) $dateValue->format('Y'),
- (int) $dateValue->format('m'),
- (int) $dateValue->format('d'),
- (int) $dateValue->format('H'),
- (int) $dateValue->format('i'),
- (int) $dateValue->format('s')
- );
- }
-
- /**
- * Convert a Unix timestamp to an MS Excel serialized date/time value.
- * The use of Unix timestamps, and therefore this function, is discouraged.
- * They are not Y2038-safe on a 32-bit system, and have no timezone info.
- *
- * @param float|int|string $unixTimestamp Unix Timestamp
- *
- * @return false|float MS Excel serialized date/time value
- */
- public static function timestampToExcel($unixTimestamp)
- {
- if (!is_numeric($unixTimestamp)) {
- return false;
- }
-
- return self::dateTimeToExcel(new DateTime('@' . $unixTimestamp));
- }
-
- /**
- * formattedPHPToExcel.
- *
- * @param int $year
- * @param int $month
- * @param int $day
- * @param int $hours
- * @param int $minutes
- * @param int $seconds
- *
- * @return float Excel date/time value
- */
- public static function formattedPHPToExcel($year, $month, $day, $hours = 0, $minutes = 0, $seconds = 0)
- {
- if (self::$excelCalendar == self::CALENDAR_WINDOWS_1900) {
- //
- // Fudge factor for the erroneous fact that the year 1900 is treated as a Leap Year in MS Excel
- // This affects every date following 28th February 1900
- //
- $excel1900isLeapYear = true;
- if (($year == 1900) && ($month <= 2)) {
- $excel1900isLeapYear = false;
- }
- $myexcelBaseDate = 2415020;
- } else {
- $myexcelBaseDate = 2416481;
- $excel1900isLeapYear = false;
- }
-
- // Julian base date Adjustment
- if ($month > 2) {
- $month -= 3;
- } else {
- $month += 9;
- --$year;
- }
-
- // Calculate the Julian Date, then subtract the Excel base date (JD 2415020 = 31-Dec-1899 Giving Excel Date of 0)
- $century = (int) substr((string) $year, 0, 2);
- $decade = (int) substr((string) $year, 2, 2);
- $excelDate = floor((146097 * $century) / 4) + floor((1461 * $decade) / 4) + floor((153 * $month + 2) / 5) + $day + 1721119 - $myexcelBaseDate + $excel1900isLeapYear;
-
- $excelTime = (($hours * 3600) + ($minutes * 60) + $seconds) / 86400;
-
- return (float) $excelDate + $excelTime;
- }
-
- /**
- * Is a given cell a date/time?
- *
- * @param mixed $value
- *
- * @return bool
- */
- public static function isDateTime(Cell $cell, $value = null, bool $dateWithoutTimeOkay = true)
- {
- $result = false;
- $worksheet = $cell->getWorksheetOrNull();
- $spreadsheet = ($worksheet === null) ? null : $worksheet->getParent();
- if ($worksheet !== null && $spreadsheet !== null) {
- $index = $spreadsheet->getActiveSheetIndex();
- $selected = $worksheet->getSelectedCells();
-
- try {
- $result = is_numeric($value ?? $cell->getCalculatedValue()) &&
- self::isDateTimeFormat(
- $worksheet->getStyle(
- $cell->getCoordinate()
- )->getNumberFormat(),
- $dateWithoutTimeOkay
- );
- } catch (Exception $e) {
- // Result is already false, so no need to actually do anything here
- }
- $worksheet->setSelectedCells($selected);
- $spreadsheet->setActiveSheetIndex($index);
- }
-
- return $result;
- }
-
- /**
- * Is a given NumberFormat code a date/time format code?
- *
- * @return bool
- */
- public static function isDateTimeFormat(NumberFormat $excelFormatCode, bool $dateWithoutTimeOkay = true)
- {
- return self::isDateTimeFormatCode((string) $excelFormatCode->getFormatCode(), $dateWithoutTimeOkay);
- }
-
- private const POSSIBLE_DATETIME_FORMAT_CHARACTERS = 'eymdHs';
- private const POSSIBLE_TIME_FORMAT_CHARACTERS = 'Hs'; // note - no 'm' due to ambiguity
-
- /**
- * Is a given number format code a date/time?
- *
- * @param string $excelFormatCode
- *
- * @return bool
- */
- public static function isDateTimeFormatCode($excelFormatCode, bool $dateWithoutTimeOkay = true)
- {
- if (strtolower($excelFormatCode) === strtolower(NumberFormat::FORMAT_GENERAL)) {
- // "General" contains an epoch letter 'e', so we trap for it explicitly here (case-insensitive check)
- return false;
- }
- if (preg_match('/[0#]E[+-]0/i', $excelFormatCode)) {
- // Scientific format
- return false;
- }
-
- // Switch on formatcode
- if (in_array($excelFormatCode, NumberFormat::DATE_TIME_OR_DATETIME_ARRAY, true)) {
- return $dateWithoutTimeOkay || in_array($excelFormatCode, NumberFormat::TIME_OR_DATETIME_ARRAY);
- }
-
- // Typically number, currency or accounting (or occasionally fraction) formats
- if ((substr($excelFormatCode, 0, 1) == '_') || (substr($excelFormatCode, 0, 2) == '0 ')) {
- return false;
- }
- // Some "special formats" provided in German Excel versions were detected as date time value,
- // so filter them out here - "\C\H\-00000" (Switzerland) and "\D-00000" (Germany).
- if (\strpos($excelFormatCode, '-00000') !== false) {
- return false;
- }
- $possibleFormatCharacters = $dateWithoutTimeOkay ? self::POSSIBLE_DATETIME_FORMAT_CHARACTERS : self::POSSIBLE_TIME_FORMAT_CHARACTERS;
- // Try checking for any of the date formatting characters that don't appear within square braces
- if (preg_match('/(^|\])[^\[]*[' . $possibleFormatCharacters . ']/i', $excelFormatCode)) {
- // We might also have a format mask containing quoted strings...
- // we don't want to test for any of our characters within the quoted blocks
- if (strpos($excelFormatCode, '"') !== false) {
- $segMatcher = false;
- foreach (explode('"', $excelFormatCode) as $subVal) {
- // Only test in alternate array entries (the non-quoted blocks)
- $segMatcher = $segMatcher === false;
- if (
- $segMatcher &&
- (preg_match('/(^|\])[^\[]*[' . $possibleFormatCharacters . ']/i', $subVal))
- ) {
- return true;
- }
- }
-
- return false;
- }
-
- return true;
- }
-
- // No date...
- return false;
- }
-
- /**
- * Convert a date/time string to Excel time.
- *
- * @param string $dateValue Examples: '2009-12-31', '2009-12-31 15:59', '2009-12-31 15:59:10'
- *
- * @return false|float Excel date/time serial value
- */
- public static function stringToExcel($dateValue)
- {
- if (strlen($dateValue) < 2) {
- return false;
- }
- if (!preg_match('/^(\d{1,4}[ \.\/\-][A-Z]{3,9}([ \.\/\-]\d{1,4})?|[A-Z]{3,9}[ \.\/\-]\d{1,4}([ \.\/\-]\d{1,4})?|\d{1,4}[ \.\/\-]\d{1,4}([ \.\/\-]\d{1,4})?)( \d{1,2}:\d{1,2}(:\d{1,2})?)?$/iu', $dateValue)) {
- return false;
- }
-
- $dateValueNew = DateTimeExcel\DateValue::fromString($dateValue);
-
- if (!is_float($dateValueNew)) {
- return false;
- }
-
- if (strpos($dateValue, ':') !== false) {
- $timeValue = DateTimeExcel\TimeValue::fromString($dateValue);
- if (!is_float($timeValue)) {
- return false;
- }
- $dateValueNew += $timeValue;
- }
-
- return $dateValueNew;
- }
-
- /**
- * Converts a month name (either a long or a short name) to a month number.
- *
- * @param string $monthName Month name or abbreviation
- *
- * @return int|string Month number (1 - 12), or the original string argument if it isn't a valid month name
- */
- public static function monthStringToNumber($monthName)
- {
- $monthIndex = 1;
- foreach (self::$monthNames as $shortMonthName => $longMonthName) {
- if (($monthName === $longMonthName) || ($monthName === $shortMonthName)) {
- return $monthIndex;
- }
- ++$monthIndex;
- }
-
- return $monthName;
- }
-
- /**
- * Strips an ordinal from a numeric value.
- *
- * @param string $day Day number with an ordinal
- *
- * @return int|string The integer value with any ordinal stripped, or the original string argument if it isn't a valid numeric
- */
- public static function dayStringToNumber($day)
- {
- $strippedDayValue = (str_replace(self::$numberSuffixes, '', $day));
- if (is_numeric($strippedDayValue)) {
- return (int) $strippedDayValue;
- }
-
- return $day;
- }
-
- public static function dateTimeFromTimestamp(string $date, ?DateTimeZone $timeZone = null): DateTime
- {
- $dtobj = DateTime::createFromFormat('U', $date) ?: new DateTime();
- $dtobj->setTimeZone($timeZone ?? self::getDefaultOrLocalTimezone());
-
- return $dtobj;
- }
-
- public static function formattedDateTimeFromTimestamp(string $date, string $format, ?DateTimeZone $timeZone = null): string
- {
- $dtobj = self::dateTimeFromTimestamp($date, $timeZone);
-
- return $dtobj->format($format);
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Drawing.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Drawing.php
deleted file mode 100644
index f69310fc617..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Drawing.php
+++ /dev/null
@@ -1,177 +0,0 @@
-getName();
- $size = $defaultFont->getSize();
-
- if (isset(Font::$defaultColumnWidths[$name][$size])) {
- // Exact width can be determined
- return $pixelValue * Font::$defaultColumnWidths[$name][$size]['width']
- / Font::$defaultColumnWidths[$name][$size]['px'];
- }
-
- // We don't have data for this particular font and size, use approximation by
- // extrapolating from Calibri 11
- return $pixelValue * 11 * Font::$defaultColumnWidths['Calibri'][11]['width']
- / Font::$defaultColumnWidths['Calibri'][11]['px'] / $size;
- }
-
- /**
- * Convert column width from (intrinsic) Excel units to pixels.
- *
- * @param float $cellWidth Value in cell dimension
- * @param \PhpOffice\PhpSpreadsheet\Style\Font $defaultFont Default font of the workbook
- *
- * @return int Value in pixels
- */
- public static function cellDimensionToPixels($cellWidth, \PhpOffice\PhpSpreadsheet\Style\Font $defaultFont)
- {
- // Font name and size
- $name = $defaultFont->getName();
- $size = $defaultFont->getSize();
-
- if (isset(Font::$defaultColumnWidths[$name][$size])) {
- // Exact width can be determined
- $colWidth = $cellWidth * Font::$defaultColumnWidths[$name][$size]['px']
- / Font::$defaultColumnWidths[$name][$size]['width'];
- } else {
- // We don't have data for this particular font and size, use approximation by
- // extrapolating from Calibri 11
- $colWidth = $cellWidth * $size * Font::$defaultColumnWidths['Calibri'][11]['px']
- / Font::$defaultColumnWidths['Calibri'][11]['width'] / 11;
- }
-
- // Round pixels to closest integer
- $colWidth = (int) round($colWidth);
-
- return $colWidth;
- }
-
- /**
- * Convert pixels to points.
- *
- * @param int $pixelValue Value in pixels
- *
- * @return float Value in points
- */
- public static function pixelsToPoints($pixelValue)
- {
- return $pixelValue * 0.75;
- }
-
- /**
- * Convert points to pixels.
- *
- * @param int $pointValue Value in points
- *
- * @return int Value in pixels
- */
- public static function pointsToPixels($pointValue)
- {
- if ($pointValue != 0) {
- return (int) ceil($pointValue / 0.75);
- }
-
- return 0;
- }
-
- /**
- * Convert degrees to angle.
- *
- * @param int $degrees Degrees
- *
- * @return int Angle
- */
- public static function degreesToAngle($degrees)
- {
- return (int) round($degrees * 60000);
- }
-
- /**
- * Convert angle to degrees.
- *
- * @param int|SimpleXMLElement $angle Angle
- *
- * @return int Degrees
- */
- public static function angleToDegrees($angle)
- {
- $angle = (int) $angle;
- if ($angle != 0) {
- return (int) round($angle / 60000);
- }
-
- return 0;
- }
-
- /**
- * Create a new image from file. By alexander at alexauto dot nl.
- *
- * @see http://www.php.net/manual/en/function.imagecreatefromwbmp.php#86214
- *
- * @param string $bmpFilename Path to Windows DIB (BMP) image
- *
- * @return GdImage|resource
- *
- * @deprecated 1.26 use Php function imagecreatefrombmp instead
- *
- * @codeCoverageIgnore
- */
- public static function imagecreatefrombmp($bmpFilename)
- {
- $retVal = @imagecreatefrombmp($bmpFilename);
- if ($retVal === false) {
- throw new ReaderException("Unable to create image from $bmpFilename");
- }
-
- return $retVal;
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher.php
deleted file mode 100644
index 466e7e8254c..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher.php
+++ /dev/null
@@ -1,64 +0,0 @@
-dggContainer;
- }
-
- /**
- * Set Drawing Group Container.
- *
- * @param Escher\DggContainer $dggContainer
- *
- * @return Escher\DggContainer
- */
- public function setDggContainer($dggContainer)
- {
- return $this->dggContainer = $dggContainer;
- }
-
- /**
- * Get Drawing Container.
- *
- * @return ?Escher\DgContainer
- */
- public function getDgContainer()
- {
- return $this->dgContainer;
- }
-
- /**
- * Set Drawing Container.
- *
- * @param Escher\DgContainer $dgContainer
- *
- * @return Escher\DgContainer
- */
- public function setDgContainer($dgContainer)
- {
- return $this->dgContainer = $dgContainer;
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DgContainer.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DgContainer.php
deleted file mode 100644
index 51c6860cbe7..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DgContainer.php
+++ /dev/null
@@ -1,65 +0,0 @@
-dgId;
- }
-
- public function setDgId(int $value): void
- {
- $this->dgId = $value;
- }
-
- public function getLastSpId(): ?int
- {
- return $this->lastSpId;
- }
-
- public function setLastSpId(int $value): void
- {
- $this->lastSpId = $value;
- }
-
- public function getSpgrContainer(): ?DgContainer\SpgrContainer
- {
- return $this->spgrContainer;
- }
-
- public function getSpgrContainerOrThrow(): DgContainer\SpgrContainer
- {
- if ($this->spgrContainer !== null) {
- return $this->spgrContainer;
- }
-
- throw new SpreadsheetException('spgrContainer is unexpectedly null');
- }
-
- /** @param DgContainer\SpgrContainer $spgrContainer */
- public function setSpgrContainer($spgrContainer): DgContainer\SpgrContainer
- {
- return $this->spgrContainer = $spgrContainer;
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DgContainer/SpgrContainer.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DgContainer/SpgrContainer.php
deleted file mode 100644
index 260df9cd4c0..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DgContainer/SpgrContainer.php
+++ /dev/null
@@ -1,75 +0,0 @@
-parent = $parent;
- }
-
- /**
- * Get the parent Shape Group Container if any.
- */
- public function getParent(): ?self
- {
- return $this->parent;
- }
-
- /**
- * Add a child. This will be either spgrContainer or spContainer.
- *
- * @param mixed $child
- */
- public function addChild($child): void
- {
- $this->children[] = $child;
- $child->setParent($this);
- }
-
- /**
- * Get collection of Shape Containers.
- */
- public function getChildren(): array
- {
- return $this->children;
- }
-
- /**
- * Recursively get all spContainers within this spgrContainer.
- *
- * @return SpgrContainer\SpContainer[]
- */
- public function getAllSpContainers()
- {
- $allSpContainers = [];
-
- foreach ($this->children as $child) {
- if ($child instanceof self) {
- $allSpContainers = array_merge($allSpContainers, $child->getAllSpContainers());
- } else {
- $allSpContainers[] = $child;
- }
- }
-
- return $allSpContainers;
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DgContainer/SpgrContainer/SpContainer.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DgContainer/SpgrContainer/SpContainer.php
deleted file mode 100644
index 8a81ff57974..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DgContainer/SpgrContainer/SpContainer.php
+++ /dev/null
@@ -1,369 +0,0 @@
-parent = $parent;
- }
-
- /**
- * Get the parent Shape Group Container.
- *
- * @return SpgrContainer
- */
- public function getParent()
- {
- return $this->parent;
- }
-
- /**
- * Set whether this is a group shape.
- *
- * @param bool $value
- */
- public function setSpgr($value): void
- {
- $this->spgr = $value;
- }
-
- /**
- * Get whether this is a group shape.
- *
- * @return bool
- */
- public function getSpgr()
- {
- return $this->spgr;
- }
-
- /**
- * Set the shape type.
- *
- * @param int $value
- */
- public function setSpType($value): void
- {
- $this->spType = $value;
- }
-
- /**
- * Get the shape type.
- *
- * @return int
- */
- public function getSpType()
- {
- return $this->spType;
- }
-
- /**
- * Set the shape flag.
- *
- * @param int $value
- */
- public function setSpFlag($value): void
- {
- $this->spFlag = $value;
- }
-
- /**
- * Get the shape flag.
- *
- * @return int
- */
- public function getSpFlag()
- {
- return $this->spFlag;
- }
-
- /**
- * Set the shape index.
- *
- * @param int $value
- */
- public function setSpId($value): void
- {
- $this->spId = $value;
- }
-
- /**
- * Get the shape index.
- *
- * @return int
- */
- public function getSpId()
- {
- return $this->spId;
- }
-
- /**
- * Set an option for the Shape Group Container.
- *
- * @param int $property The number specifies the option
- * @param mixed $value
- */
- public function setOPT($property, $value): void
- {
- $this->OPT[$property] = $value;
- }
-
- /**
- * Get an option for the Shape Group Container.
- *
- * @param int $property The number specifies the option
- *
- * @return mixed
- */
- public function getOPT($property)
- {
- if (isset($this->OPT[$property])) {
- return $this->OPT[$property];
- }
-
- return null;
- }
-
- /**
- * Get the collection of options.
- *
- * @return array
- */
- public function getOPTCollection()
- {
- return $this->OPT;
- }
-
- /**
- * Set cell coordinates of upper-left corner of shape.
- *
- * @param string $value eg: 'A1'
- */
- public function setStartCoordinates($value): void
- {
- $this->startCoordinates = $value;
- }
-
- /**
- * Get cell coordinates of upper-left corner of shape.
- *
- * @return string
- */
- public function getStartCoordinates()
- {
- return $this->startCoordinates;
- }
-
- /**
- * Set offset in x-direction of upper-left corner of shape measured in 1/1024 of column width.
- *
- * @param int $startOffsetX
- */
- public function setStartOffsetX($startOffsetX): void
- {
- $this->startOffsetX = $startOffsetX;
- }
-
- /**
- * Get offset in x-direction of upper-left corner of shape measured in 1/1024 of column width.
- *
- * @return int
- */
- public function getStartOffsetX()
- {
- return $this->startOffsetX;
- }
-
- /**
- * Set offset in y-direction of upper-left corner of shape measured in 1/256 of row height.
- *
- * @param int $startOffsetY
- */
- public function setStartOffsetY($startOffsetY): void
- {
- $this->startOffsetY = $startOffsetY;
- }
-
- /**
- * Get offset in y-direction of upper-left corner of shape measured in 1/256 of row height.
- *
- * @return int
- */
- public function getStartOffsetY()
- {
- return $this->startOffsetY;
- }
-
- /**
- * Set cell coordinates of bottom-right corner of shape.
- *
- * @param string $value eg: 'A1'
- */
- public function setEndCoordinates($value): void
- {
- $this->endCoordinates = $value;
- }
-
- /**
- * Get cell coordinates of bottom-right corner of shape.
- *
- * @return string
- */
- public function getEndCoordinates()
- {
- return $this->endCoordinates;
- }
-
- /**
- * Set offset in x-direction of bottom-right corner of shape measured in 1/1024 of column width.
- *
- * @param int $endOffsetX
- */
- public function setEndOffsetX($endOffsetX): void
- {
- $this->endOffsetX = $endOffsetX;
- }
-
- /**
- * Get offset in x-direction of bottom-right corner of shape measured in 1/1024 of column width.
- *
- * @return int
- */
- public function getEndOffsetX()
- {
- return $this->endOffsetX;
- }
-
- /**
- * Set offset in y-direction of bottom-right corner of shape measured in 1/256 of row height.
- *
- * @param int $endOffsetY
- */
- public function setEndOffsetY($endOffsetY): void
- {
- $this->endOffsetY = $endOffsetY;
- }
-
- /**
- * Get offset in y-direction of bottom-right corner of shape measured in 1/256 of row height.
- *
- * @return int
- */
- public function getEndOffsetY()
- {
- return $this->endOffsetY;
- }
-
- /**
- * Get the nesting level of this spContainer. This is the number of spgrContainers between this spContainer and
- * the dgContainer. A value of 1 = immediately within first spgrContainer
- * Higher nesting level occurs if and only if spContainer is part of a shape group.
- *
- * @return int Nesting level
- */
- public function getNestingLevel()
- {
- $nestingLevel = 0;
-
- $parent = $this->getParent();
- while ($parent instanceof SpgrContainer) {
- ++$nestingLevel;
- $parent = $parent->getParent();
- }
-
- return $nestingLevel;
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DggContainer.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DggContainer.php
deleted file mode 100644
index ba5e7980b29..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DggContainer.php
+++ /dev/null
@@ -1,175 +0,0 @@
-spIdMax;
- }
-
- /**
- * Set maximum shape index of all shapes in all drawings (plus one).
- *
- * @param int $value
- */
- public function setSpIdMax($value): void
- {
- $this->spIdMax = $value;
- }
-
- /**
- * Get total number of drawings saved.
- *
- * @return int
- */
- public function getCDgSaved()
- {
- return $this->cDgSaved;
- }
-
- /**
- * Set total number of drawings saved.
- *
- * @param int $value
- */
- public function setCDgSaved($value): void
- {
- $this->cDgSaved = $value;
- }
-
- /**
- * Get total number of shapes saved (including group shapes).
- *
- * @return int
- */
- public function getCSpSaved()
- {
- return $this->cSpSaved;
- }
-
- /**
- * Set total number of shapes saved (including group shapes).
- *
- * @param int $value
- */
- public function setCSpSaved($value): void
- {
- $this->cSpSaved = $value;
- }
-
- /**
- * Get BLIP Store Container.
- *
- * @return ?DggContainer\BstoreContainer
- */
- public function getBstoreContainer()
- {
- return $this->bstoreContainer;
- }
-
- /**
- * Set BLIP Store Container.
- *
- * @param DggContainer\BstoreContainer $bstoreContainer
- */
- public function setBstoreContainer($bstoreContainer): void
- {
- $this->bstoreContainer = $bstoreContainer;
- }
-
- /**
- * Set an option for the drawing group.
- *
- * @param int $property The number specifies the option
- * @param mixed $value
- */
- public function setOPT($property, $value): void
- {
- $this->OPT[$property] = $value;
- }
-
- /**
- * Get an option for the drawing group.
- *
- * @param int $property The number specifies the option
- *
- * @return mixed
- */
- public function getOPT($property)
- {
- if (isset($this->OPT[$property])) {
- return $this->OPT[$property];
- }
-
- return null;
- }
-
- /**
- * Get identifier clusters.
- *
- * @return array
- */
- public function getIDCLs()
- {
- return $this->IDCLs;
- }
-
- /**
- * Set identifier clusters. [ => , ...].
- *
- * @param array $IDCLs
- */
- public function setIDCLs($IDCLs): void
- {
- $this->IDCLs = $IDCLs;
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DggContainer/BstoreContainer.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DggContainer/BstoreContainer.php
deleted file mode 100644
index 7203b66bea2..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DggContainer/BstoreContainer.php
+++ /dev/null
@@ -1,32 +0,0 @@
-BSECollection[] = $BSE;
- $BSE->setParent($this);
- }
-
- /**
- * Get the collection of BLIP Store Entries.
- *
- * @return BstoreContainer\BSE[]
- */
- public function getBSECollection()
- {
- return $this->BSECollection;
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DggContainer/BstoreContainer/BSE.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DggContainer/BstoreContainer/BSE.php
deleted file mode 100644
index 328ac6b6c24..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DggContainer/BstoreContainer/BSE.php
+++ /dev/null
@@ -1,88 +0,0 @@
-parent = $parent;
- }
-
- /**
- * Get the BLIP.
- *
- * @return ?BSE\Blip
- */
- public function getBlip()
- {
- return $this->blip;
- }
-
- /**
- * Set the BLIP.
- */
- public function setBlip(BSE\Blip $blip): void
- {
- $this->blip = $blip;
- $blip->setParent($this);
- }
-
- /**
- * Get the BLIP type.
- *
- * @return int
- */
- public function getBlipType()
- {
- return $this->blipType;
- }
-
- /**
- * Set the BLIP type.
- *
- * @param int $blipType
- */
- public function setBlipType($blipType): void
- {
- $this->blipType = $blipType;
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DggContainer/BstoreContainer/BSE/Blip.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DggContainer/BstoreContainer/BSE/Blip.php
deleted file mode 100644
index 03b261f8feb..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DggContainer/BstoreContainer/BSE/Blip.php
+++ /dev/null
@@ -1,58 +0,0 @@
-data;
- }
-
- /**
- * Set the raw image data.
- *
- * @param string $data
- */
- public function setData($data): void
- {
- $this->data = $data;
- }
-
- /**
- * Set parent BSE.
- */
- public function setParent(BSE $parent): void
- {
- $this->parent = $parent;
- }
-
- /**
- * Get parent BSE.
- */
- public function getParent(): BSE
- {
- return $this->parent;
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/File.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/File.php
deleted file mode 100644
index ca22e6ca2be..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/File.php
+++ /dev/null
@@ -1,203 +0,0 @@
-open($zipFile);
- if ($res === true) {
- $returnValue = ($zip->getFromName($archiveFile) !== false);
- $zip->close();
-
- return $returnValue;
- }
- }
-
- return false;
- }
-
- return file_exists($filename);
- }
-
- /**
- * Returns canonicalized absolute pathname, also for ZIP archives.
- */
- public static function realpath(string $filename): string
- {
- // Returnvalue
- $returnValue = '';
-
- // Try using realpath()
- if (file_exists($filename)) {
- $returnValue = realpath($filename) ?: '';
- }
-
- // Found something?
- if ($returnValue === '') {
- $pathArray = explode('/', $filename);
- while (in_array('..', $pathArray) && $pathArray[0] != '..') {
- $iMax = count($pathArray);
- for ($i = 0; $i < $iMax; ++$i) {
- if ($pathArray[$i] == '..' && $i > 0) {
- unset($pathArray[$i], $pathArray[$i - 1]);
-
- break;
- }
- }
- }
- $returnValue = implode('/', $pathArray);
- }
-
- // Return
- return $returnValue;
- }
-
- /**
- * Get the systems temporary directory.
- */
- public static function sysGetTempDir(): string
- {
- // Moodle hack!
- if (function_exists('make_temp_directory')) {
- $temp = make_temp_directory('phpspreadsheet');
- return realpath(dirname($temp));
- }
-
- $path = sys_get_temp_dir();
- if (self::$useUploadTempDirectory) {
- // use upload-directory when defined to allow running on environments having very restricted
- // open_basedir configs
- if (ini_get('upload_tmp_dir') !== false) {
- if ($temp = ini_get('upload_tmp_dir')) {
- if (file_exists($temp)) {
- $path = $temp;
- }
- }
- }
- }
-
- return realpath($path) ?: '';
- }
-
- public static function temporaryFilename(): string
- {
- $filename = tempnam(self::sysGetTempDir(), 'phpspreadsheet');
- if ($filename === false) {
- throw new Exception('Could not create temporary file');
- }
-
- return $filename;
- }
-
- /**
- * Assert that given path is an existing file and is readable, otherwise throw exception.
- */
- public static function assertFile(string $filename, string $zipMember = ''): void
- {
- if (!is_file($filename)) {
- throw new ReaderException('File "' . $filename . '" does not exist.');
- }
-
- if (!is_readable($filename)) {
- throw new ReaderException('Could not open "' . $filename . '" for reading.');
- }
-
- if ($zipMember !== '') {
- $zipfile = "zip://$filename#$zipMember";
- if (!self::fileExists($zipfile)) {
- // Has the file been saved with Windoze directory separators rather than unix?
- $zipfile = "zip://$filename#" . str_replace('/', '\\', $zipMember);
- if (!self::fileExists($zipfile)) {
- throw new ReaderException("Could not find zip member $zipfile");
- }
- }
- }
- }
-
- /**
- * Same as assertFile, except return true/false and don't throw Exception.
- */
- public static function testFileNoThrow(string $filename, ?string $zipMember = null): bool
- {
- if (!is_file($filename)) {
- return false;
- }
- if (!is_readable($filename)) {
- return false;
- }
- if ($zipMember === null) {
- return true;
- }
- // validate zip, but don't check specific member
- if ($zipMember === '') {
- return self::validateZipFirst4($filename);
- }
-
- $zipfile = "zip://$filename#$zipMember";
- if (self::fileExists($zipfile)) {
- return true;
- }
-
- // Has the file been saved with Windoze directory separators rather than unix?
- $zipfile = "zip://$filename#" . str_replace('/', '\\', $zipMember);
-
- return self::fileExists($zipfile);
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Font.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Font.php
deleted file mode 100644
index 90c1992a3ab..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Font.php
+++ /dev/null
@@ -1,675 +0,0 @@
- [
- 'x' => self::ARIAL,
- 'xb' => self::ARIAL_BOLD,
- 'xi' => self::ARIAL_ITALIC,
- 'xbi' => self::ARIAL_BOLD_ITALIC,
- ],
- 'Calibri' => [
- 'x' => self::CALIBRI,
- 'xb' => self::CALIBRI_BOLD,
- 'xi' => self::CALIBRI_ITALIC,
- 'xbi' => self::CALIBRI_BOLD_ITALIC,
- ],
- 'Comic Sans MS' => [
- 'x' => self::COMIC_SANS_MS,
- 'xb' => self::COMIC_SANS_MS_BOLD,
- 'xi' => self::COMIC_SANS_MS,
- 'xbi' => self::COMIC_SANS_MS_BOLD,
- ],
- 'Courier New' => [
- 'x' => self::COURIER_NEW,
- 'xb' => self::COURIER_NEW_BOLD,
- 'xi' => self::COURIER_NEW_ITALIC,
- 'xbi' => self::COURIER_NEW_BOLD_ITALIC,
- ],
- 'Georgia' => [
- 'x' => self::GEORGIA,
- 'xb' => self::GEORGIA_BOLD,
- 'xi' => self::GEORGIA_ITALIC,
- 'xbi' => self::GEORGIA_BOLD_ITALIC,
- ],
- 'Impact' => [
- 'x' => self::IMPACT,
- 'xb' => self::IMPACT,
- 'xi' => self::IMPACT,
- 'xbi' => self::IMPACT,
- ],
- 'Liberation Sans' => [
- 'x' => self::LIBERATION_SANS,
- 'xb' => self::LIBERATION_SANS_BOLD,
- 'xi' => self::LIBERATION_SANS_ITALIC,
- 'xbi' => self::LIBERATION_SANS_BOLD_ITALIC,
- ],
- 'Lucida Console' => [
- 'x' => self::LUCIDA_CONSOLE,
- 'xb' => self::LUCIDA_CONSOLE,
- 'xi' => self::LUCIDA_CONSOLE,
- 'xbi' => self::LUCIDA_CONSOLE,
- ],
- 'Lucida Sans Unicode' => [
- 'x' => self::LUCIDA_SANS_UNICODE,
- 'xb' => self::LUCIDA_SANS_UNICODE,
- 'xi' => self::LUCIDA_SANS_UNICODE,
- 'xbi' => self::LUCIDA_SANS_UNICODE,
- ],
- 'Microsoft Sans Serif' => [
- 'x' => self::MICROSOFT_SANS_SERIF,
- 'xb' => self::MICROSOFT_SANS_SERIF,
- 'xi' => self::MICROSOFT_SANS_SERIF,
- 'xbi' => self::MICROSOFT_SANS_SERIF,
- ],
- 'Palatino Linotype' => [
- 'x' => self::PALATINO_LINOTYPE,
- 'xb' => self::PALATINO_LINOTYPE_BOLD,
- 'xi' => self::PALATINO_LINOTYPE_ITALIC,
- 'xbi' => self::PALATINO_LINOTYPE_BOLD_ITALIC,
- ],
- 'Symbol' => [
- 'x' => self::SYMBOL,
- 'xb' => self::SYMBOL,
- 'xi' => self::SYMBOL,
- 'xbi' => self::SYMBOL,
- ],
- 'Tahoma' => [
- 'x' => self::TAHOMA,
- 'xb' => self::TAHOMA_BOLD,
- 'xi' => self::TAHOMA,
- 'xbi' => self::TAHOMA_BOLD,
- ],
- 'Times New Roman' => [
- 'x' => self::TIMES_NEW_ROMAN,
- 'xb' => self::TIMES_NEW_ROMAN_BOLD,
- 'xi' => self::TIMES_NEW_ROMAN_ITALIC,
- 'xbi' => self::TIMES_NEW_ROMAN_BOLD_ITALIC,
- ],
- 'Trebuchet MS' => [
- 'x' => self::TREBUCHET_MS,
- 'xb' => self::TREBUCHET_MS_BOLD,
- 'xi' => self::TREBUCHET_MS_ITALIC,
- 'xbi' => self::TREBUCHET_MS_BOLD_ITALIC,
- ],
- 'Verdana' => [
- 'x' => self::VERDANA,
- 'xb' => self::VERDANA_BOLD,
- 'xi' => self::VERDANA_ITALIC,
- 'xbi' => self::VERDANA_BOLD_ITALIC,
- ],
- ];
-
- /**
- * Array that can be used to supplement FONT_FILE_NAMES for calculating exact width.
- *
- * @var array
- */
- private static $extraFontArray = [];
-
- public static function setExtraFontArray(array $extraFontArray): void
- {
- self::$extraFontArray = $extraFontArray;
- }
-
- public static function getExtraFontArray(): array
- {
- return self::$extraFontArray;
- }
-
- /**
- * AutoSize method.
- *
- * @var string
- */
- private static $autoSizeMethod = self::AUTOSIZE_METHOD_APPROX;
-
- /**
- * Path to folder containing TrueType font .ttf files.
- *
- * @var string
- */
- private static $trueTypeFontPath = '';
-
- /**
- * How wide is a default column for a given default font and size?
- * Empirical data found by inspecting real Excel files and reading off the pixel width
- * in Microsoft Office Excel 2007.
- * Added height in points.
- */
- public const DEFAULT_COLUMN_WIDTHS = [
- 'Arial' => [
- 1 => ['px' => 24, 'width' => 12.00000000, 'height' => 5.25],
- 2 => ['px' => 24, 'width' => 12.00000000, 'height' => 5.25],
- 3 => ['px' => 32, 'width' => 10.66406250, 'height' => 6.0],
-
- 4 => ['px' => 32, 'width' => 10.66406250, 'height' => 6.75],
- 5 => ['px' => 40, 'width' => 10.00000000, 'height' => 8.25],
- 6 => ['px' => 48, 'width' => 9.59765625, 'height' => 8.25],
- 7 => ['px' => 48, 'width' => 9.59765625, 'height' => 9.0],
- 8 => ['px' => 56, 'width' => 9.33203125, 'height' => 11.25],
- 9 => ['px' => 64, 'width' => 9.14062500, 'height' => 12.0],
- 10 => ['px' => 64, 'width' => 9.14062500, 'height' => 12.75],
- ],
- 'Calibri' => [
- 1 => ['px' => 24, 'width' => 12.00000000, 'height' => 5.25],
- 2 => ['px' => 24, 'width' => 12.00000000, 'height' => 5.25],
- 3 => ['px' => 32, 'width' => 10.66406250, 'height' => 6.00],
- 4 => ['px' => 32, 'width' => 10.66406250, 'height' => 6.75],
- 5 => ['px' => 40, 'width' => 10.00000000, 'height' => 8.25],
- 6 => ['px' => 48, 'width' => 9.59765625, 'height' => 8.25],
- 7 => ['px' => 48, 'width' => 9.59765625, 'height' => 9.0],
- 8 => ['px' => 56, 'width' => 9.33203125, 'height' => 11.25],
- 9 => ['px' => 56, 'width' => 9.33203125, 'height' => 12.0],
- 10 => ['px' => 64, 'width' => 9.14062500, 'height' => 12.75],
- 11 => ['px' => 64, 'width' => 9.14062500, 'height' => 15.0],
- ],
- 'Verdana' => [
- 1 => ['px' => 24, 'width' => 12.00000000, 'height' => 5.25],
- 2 => ['px' => 24, 'width' => 12.00000000, 'height' => 5.25],
- 3 => ['px' => 32, 'width' => 10.66406250, 'height' => 6.0],
- 4 => ['px' => 32, 'width' => 10.66406250, 'height' => 6.75],
- 5 => ['px' => 40, 'width' => 10.00000000, 'height' => 8.25],
- 6 => ['px' => 48, 'width' => 9.59765625, 'height' => 8.25],
- 7 => ['px' => 48, 'width' => 9.59765625, 'height' => 9.0],
- 8 => ['px' => 64, 'width' => 9.14062500, 'height' => 10.5],
- 9 => ['px' => 72, 'width' => 9.00000000, 'height' => 11.25],
- 10 => ['px' => 72, 'width' => 9.00000000, 'height' => 12.75],
- ],
- ];
-
- /**
- * List of column widths. Replaced by constant;
- * previously it was public and updateable, allowing
- * user to make inappropriate alterations.
- *
- * @deprecated 1.25.0 Use DEFAULT_COLUMN_WIDTHS constant instead.
- *
- * @var array
- */
- public static $defaultColumnWidths = self::DEFAULT_COLUMN_WIDTHS;
-
- /**
- * Set autoSize method.
- *
- * @param string $method see self::AUTOSIZE_METHOD_*
- *
- * @return bool Success or failure
- */
- public static function setAutoSizeMethod($method)
- {
- if (!in_array($method, self::AUTOSIZE_METHODS)) {
- return false;
- }
- self::$autoSizeMethod = $method;
-
- return true;
- }
-
- /**
- * Get autoSize method.
- *
- * @return string
- */
- public static function getAutoSizeMethod()
- {
- return self::$autoSizeMethod;
- }
-
- /**
- * Set the path to the folder containing .ttf files. There should be a trailing slash.
- * Typical locations on variout some platforms:
- *
- * C:/Windows/Fonts/
- * /usr/share/fonts/truetype/
- * ~/.fonts/
- * .
- *
- * @param string $folderPath
- */
- public static function setTrueTypeFontPath($folderPath): void
- {
- self::$trueTypeFontPath = $folderPath;
- }
-
- /**
- * Get the path to the folder containing .ttf files.
- *
- * @return string
- */
- public static function getTrueTypeFontPath()
- {
- return self::$trueTypeFontPath;
- }
-
- /**
- * Calculate an (approximate) OpenXML column width, based on font size and text contained.
- *
- * @param FontStyle $font Font object
- * @param null|RichText|string $cellText Text to calculate width
- * @param int $rotation Rotation angle
- * @param null|FontStyle $defaultFont Font object
- * @param bool $filterAdjustment Add space for Autofilter or Table dropdown
- */
- public static function calculateColumnWidth(
- FontStyle $font,
- $cellText = '',
- $rotation = 0,
- ?FontStyle $defaultFont = null,
- bool $filterAdjustment = false,
- int $indentAdjustment = 0
- ): float {
- // If it is rich text, use plain text
- if ($cellText instanceof RichText) {
- $cellText = $cellText->getPlainText();
- }
-
- // Special case if there are one or more newline characters ("\n")
- $cellText = (string) $cellText;
- if (strpos($cellText, "\n") !== false) {
- $lineTexts = explode("\n", $cellText);
- $lineWidths = [];
- foreach ($lineTexts as $lineText) {
- $lineWidths[] = self::calculateColumnWidth($font, $lineText, $rotation = 0, $defaultFont, $filterAdjustment);
- }
-
- return max($lineWidths); // width of longest line in cell
- }
-
- // Try to get the exact text width in pixels
- $approximate = self::$autoSizeMethod === self::AUTOSIZE_METHOD_APPROX;
- $columnWidth = 0;
- if (!$approximate) {
- try {
- $columnWidthAdjust = ceil(
- self::getTextWidthPixelsExact(
- str_repeat('n', 1 * (($filterAdjustment ? 3 : 1) + ($indentAdjustment * 2))),
- $font,
- 0
- ) * 1.07
- );
-
- // Width of text in pixels excl. padding
- // and addition because Excel adds some padding, just use approx width of 'n' glyph
- $columnWidth = self::getTextWidthPixelsExact($cellText, $font, $rotation) + $columnWidthAdjust;
- } catch (PhpSpreadsheetException $e) {
- $approximate = true;
- }
- }
-
- if ($approximate) {
- $columnWidthAdjust = self::getTextWidthPixelsApprox(
- str_repeat('n', 1 * (($filterAdjustment ? 3 : 1) + ($indentAdjustment * 2))),
- $font,
- 0
- );
- // Width of text in pixels excl. padding, approximation
- // and addition because Excel adds some padding, just use approx width of 'n' glyph
- $columnWidth = self::getTextWidthPixelsApprox($cellText, $font, $rotation) + $columnWidthAdjust;
- }
-
- // Convert from pixel width to column width
- $columnWidth = Drawing::pixelsToCellDimension((int) $columnWidth, $defaultFont ?? new FontStyle());
-
- // Return
- return round($columnWidth, 4);
- }
-
- /**
- * Get GD text width in pixels for a string of text in a certain font at a certain rotation angle.
- */
- public static function getTextWidthPixelsExact(string $text, FontStyle $font, int $rotation = 0): float
- {
- // font size should really be supplied in pixels in GD2,
- // but since GD2 seems to assume 72dpi, pixels and points are the same
- $fontFile = self::getTrueTypeFontFileFromFont($font);
- $textBox = imagettfbbox($font->getSize() ?? 10.0, $rotation, $fontFile, $text);
- if ($textBox === false) {
- // @codeCoverageIgnoreStart
- throw new PhpSpreadsheetException('imagettfbbox failed');
- // @codeCoverageIgnoreEnd
- }
-
- // Get corners positions
- $lowerLeftCornerX = $textBox[0];
- $lowerRightCornerX = $textBox[2];
- $upperRightCornerX = $textBox[4];
- $upperLeftCornerX = $textBox[6];
-
- // Consider the rotation when calculating the width
- return round(max($lowerRightCornerX - $upperLeftCornerX, $upperRightCornerX - $lowerLeftCornerX), 4);
- }
-
- /**
- * Get approximate width in pixels for a string of text in a certain font at a certain rotation angle.
- *
- * @param string $columnText
- * @param int $rotation
- *
- * @return int Text width in pixels (no padding added)
- */
- public static function getTextWidthPixelsApprox($columnText, FontStyle $font, $rotation = 0)
- {
- $fontName = $font->getName();
- $fontSize = $font->getSize();
-
- // Calculate column width in pixels.
- // We assume fixed glyph width, but count double for "fullwidth" characters.
- // Result varies with font name and size.
- switch ($fontName) {
- case 'Arial':
- // value 8 was set because of experience in different exports at Arial 10 font.
- $columnWidth = (int) (8 * StringHelper::countCharactersDbcs($columnText));
- $columnWidth = $columnWidth * $fontSize / 10; // extrapolate from font size
-
- break;
- case 'Verdana':
- // value 8 was found via interpolation by inspecting real Excel files with Verdana 10 font.
- $columnWidth = (int) (8 * StringHelper::countCharactersDbcs($columnText));
- $columnWidth = $columnWidth * $fontSize / 10; // extrapolate from font size
-
- break;
- default:
- // just assume Calibri
- // value 8.26 was found via interpolation by inspecting real Excel files with Calibri 11 font.
- $columnWidth = (int) (8.26 * StringHelper::countCharactersDbcs($columnText));
- $columnWidth = $columnWidth * $fontSize / 11; // extrapolate from font size
-
- break;
- }
-
- // Calculate approximate rotated column width
- if ($rotation !== 0) {
- if ($rotation == Alignment::TEXTROTATION_STACK_PHPSPREADSHEET) {
- // stacked text
- $columnWidth = 4; // approximation
- } else {
- // rotated text
- $columnWidth = $columnWidth * cos(deg2rad($rotation))
- + $fontSize * abs(sin(deg2rad($rotation))) / 5; // approximation
- }
- }
-
- // pixel width is an integer
- return (int) $columnWidth;
- }
-
- /**
- * Calculate an (approximate) pixel size, based on a font points size.
- *
- * @param int $fontSizeInPoints Font size (in points)
- *
- * @return int Font size (in pixels)
- */
- public static function fontSizeToPixels($fontSizeInPoints)
- {
- return (int) ((4 / 3) * $fontSizeInPoints);
- }
-
- /**
- * Calculate an (approximate) pixel size, based on inch size.
- *
- * @param int $sizeInInch Font size (in inch)
- *
- * @return int Size (in pixels)
- */
- public static function inchSizeToPixels($sizeInInch)
- {
- return $sizeInInch * 96;
- }
-
- /**
- * Calculate an (approximate) pixel size, based on centimeter size.
- *
- * @param int $sizeInCm Font size (in centimeters)
- *
- * @return float Size (in pixels)
- */
- public static function centimeterSizeToPixels($sizeInCm)
- {
- return $sizeInCm * 37.795275591;
- }
-
- /**
- * Returns the font path given the font.
- *
- * @return string Path to TrueType font file
- */
- public static function getTrueTypeFontFileFromFont(FontStyle $font, bool $checkPath = true)
- {
- if ($checkPath && (!file_exists(self::$trueTypeFontPath) || !is_dir(self::$trueTypeFontPath))) {
- throw new PhpSpreadsheetException('Valid directory to TrueType Font files not specified');
- }
-
- $name = $font->getName();
- $fontArray = array_merge(self::FONT_FILE_NAMES, self::$extraFontArray);
- if (!isset($fontArray[$name])) {
- throw new PhpSpreadsheetException('Unknown font name "' . $name . '". Cannot map to TrueType font file');
- }
- $bold = $font->getBold();
- $italic = $font->getItalic();
- $index = 'x';
- if ($bold) {
- $index .= 'b';
- }
- if ($italic) {
- $index .= 'i';
- }
- $fontFile = $fontArray[$name][$index];
-
- $separator = '';
- if (mb_strlen(self::$trueTypeFontPath) > 1 && mb_substr(self::$trueTypeFontPath, -1) !== '/' && mb_substr(self::$trueTypeFontPath, -1) !== '\\') {
- $separator = DIRECTORY_SEPARATOR;
- }
- $fontFileAbsolute = preg_match('~^([A-Za-z]:)?[/\\\\]~', $fontFile) === 1;
- if (!$fontFileAbsolute) {
- $fontFile = self::$trueTypeFontPath . $separator . $fontFile;
- }
-
- // Check if file actually exists
- if ($checkPath && !file_exists($fontFile) && !$fontFileAbsolute) {
- $alternateName = $name;
- if ($index !== 'x' && $fontArray[$name][$index] !== $fontArray[$name]['x']) {
- // Bold but no italic:
- // Comic Sans
- // Tahoma
- // Neither bold nor italic:
- // Impact
- // Lucida Console
- // Lucida Sans Unicode
- // Microsoft Sans Serif
- // Symbol
- if ($index === 'xb') {
- $alternateName .= ' Bold';
- } elseif ($index === 'xi') {
- $alternateName .= ' Italic';
- } elseif ($fontArray[$name]['xb'] === $fontArray[$name]['xbi']) {
- $alternateName .= ' Bold';
- } else {
- $alternateName .= ' Bold Italic';
- }
- }
- $fontFile = self::$trueTypeFontPath . $separator . $alternateName . '.ttf';
- if (!file_exists($fontFile)) {
- throw new PhpSpreadsheetException('TrueType Font file not found');
- }
- }
-
- return $fontFile;
- }
-
- public const CHARSET_FROM_FONT_NAME = [
- 'EucrosiaUPC' => self::CHARSET_ANSI_THAI,
- 'Wingdings' => self::CHARSET_SYMBOL,
- 'Wingdings 2' => self::CHARSET_SYMBOL,
- 'Wingdings 3' => self::CHARSET_SYMBOL,
- ];
-
- /**
- * Returns the associated charset for the font name.
- *
- * @param string $fontName Font name
- *
- * @return int Character set code
- */
- public static function getCharsetFromFontName($fontName)
- {
- return self::CHARSET_FROM_FONT_NAME[$fontName] ?? self::CHARSET_ANSI_LATIN;
- }
-
- /**
- * Get the effective column width for columns without a column dimension or column with width -1
- * For example, for Calibri 11 this is 9.140625 (64 px).
- *
- * @param FontStyle $font The workbooks default font
- * @param bool $returnAsPixels true = return column width in pixels, false = return in OOXML units
- *
- * @return mixed Column width
- */
- public static function getDefaultColumnWidthByFont(FontStyle $font, $returnAsPixels = false)
- {
- if (isset(self::DEFAULT_COLUMN_WIDTHS[$font->getName()][$font->getSize()])) {
- // Exact width can be determined
- $columnWidth = $returnAsPixels ?
- self::DEFAULT_COLUMN_WIDTHS[$font->getName()][$font->getSize()]['px']
- : self::DEFAULT_COLUMN_WIDTHS[$font->getName()][$font->getSize()]['width'];
- } else {
- // We don't have data for this particular font and size, use approximation by
- // extrapolating from Calibri 11
- $columnWidth = $returnAsPixels ?
- self::DEFAULT_COLUMN_WIDTHS['Calibri'][11]['px']
- : self::DEFAULT_COLUMN_WIDTHS['Calibri'][11]['width'];
- $columnWidth = $columnWidth * $font->getSize() / 11;
-
- // Round pixels to closest integer
- if ($returnAsPixels) {
- $columnWidth = (int) round($columnWidth);
- }
- }
-
- return $columnWidth;
- }
-
- /**
- * Get the effective row height for rows without a row dimension or rows with height -1
- * For example, for Calibri 11 this is 15 points.
- *
- * @param FontStyle $font The workbooks default font
- *
- * @return float Row height in points
- */
- public static function getDefaultRowHeightByFont(FontStyle $font)
- {
- $name = $font->getName();
- $size = $font->getSize();
- if (isset(self::DEFAULT_COLUMN_WIDTHS[$name][$size])) {
- $rowHeight = self::DEFAULT_COLUMN_WIDTHS[$name][$size]['height'];
- } elseif ($name === 'Arial' || $name === 'Verdana') {
- $rowHeight = self::DEFAULT_COLUMN_WIDTHS[$name][10]['height'] * $size / 10.0;
- } else {
- $rowHeight = self::DEFAULT_COLUMN_WIDTHS['Calibri'][11]['height'] * $size / 11.0;
- }
-
- return $rowHeight;
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/IntOrFloat.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/IntOrFloat.php
deleted file mode 100644
index 060f09c8831..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/IntOrFloat.php
+++ /dev/null
@@ -1,21 +0,0 @@
- 'md2',
- Protection::ALGORITHM_MD4 => 'md4',
- Protection::ALGORITHM_MD5 => 'md5',
- Protection::ALGORITHM_SHA_1 => 'sha1',
- Protection::ALGORITHM_SHA_256 => 'sha256',
- Protection::ALGORITHM_SHA_384 => 'sha384',
- Protection::ALGORITHM_SHA_512 => 'sha512',
- Protection::ALGORITHM_RIPEMD_128 => 'ripemd128',
- Protection::ALGORITHM_RIPEMD_160 => 'ripemd160',
- Protection::ALGORITHM_WHIRLPOOL => 'whirlpool',
- ];
-
- if (array_key_exists($algorithmName, $mapping)) {
- return $mapping[$algorithmName];
- }
-
- throw new SpException('Unsupported password algorithm: ' . $algorithmName);
- }
-
- /**
- * Create a password hash from a given string.
- *
- * This method is based on the spec at:
- * https://interoperability.blob.core.windows.net/files/MS-OFFCRYPTO/[MS-OFFCRYPTO].pdf
- * 2.3.7.1 Binary Document Password Verifier Derivation Method 1
- *
- * It replaces a method based on the algorithm provided by
- * Daniel Rentz of OpenOffice and the PEAR package
- * Spreadsheet_Excel_Writer by Xavier Noguer .
- *
- * Scrutinizer will squawk at the use of bitwise operations here,
- * but it should ultimately pass.
- *
- * @param string $password Password to hash
- */
- private static function defaultHashPassword(string $password): string
- {
- $verifier = 0;
- $pwlen = strlen($password);
- $passwordArray = pack('c', $pwlen) . $password;
- for ($i = $pwlen; $i >= 0; --$i) {
- $intermediate1 = (($verifier & 0x4000) === 0) ? 0 : 1;
- $intermediate2 = 2 * $verifier;
- $intermediate2 = $intermediate2 & 0x7fff;
- $intermediate3 = $intermediate1 | $intermediate2;
- $verifier = $intermediate3 ^ ord($passwordArray[$i]);
- }
- $verifier ^= 0xCE4B;
-
- return strtoupper(dechex($verifier));
- }
-
- /**
- * Create a password hash from a given string by a specific algorithm.
- *
- * 2.4.2.4 ISO Write Protection Method
- *
- * @see https://docs.microsoft.com/en-us/openspecs/office_file_formats/ms-offcrypto/1357ea58-646e-4483-92ef-95d718079d6f
- *
- * @param string $password Password to hash
- * @param string $algorithm Hash algorithm used to compute the password hash value
- * @param string $salt Pseudorandom string
- * @param int $spinCount Number of times to iterate on a hash of a password
- *
- * @return string Hashed password
- */
- public static function hashPassword(string $password, string $algorithm = '', string $salt = '', int $spinCount = 10000): string
- {
- if (strlen($password) > self::MAX_PASSWORD_LENGTH) {
- throw new SpException('Password exceeds ' . self::MAX_PASSWORD_LENGTH . ' characters');
- }
- $phpAlgorithm = self::getAlgorithm($algorithm);
- if (!$phpAlgorithm) {
- return self::defaultHashPassword($password);
- }
-
- $saltValue = base64_decode($salt);
- $encodedPassword = mb_convert_encoding($password, 'UCS-2LE', 'UTF-8');
-
- $hashValue = hash($phpAlgorithm, $saltValue . /** @scrutinizer ignore-type */ $encodedPassword, true);
- for ($i = 0; $i < $spinCount; ++$i) {
- $hashValue = hash($phpAlgorithm, $hashValue . pack('L', $i), true);
- }
-
- return base64_encode($hashValue);
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/StringHelper.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/StringHelper.php
deleted file mode 100644
index c6c198e203c..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/StringHelper.php
+++ /dev/null
@@ -1,684 +0,0 @@
- chr(0),
- "\x1B 1" => chr(1),
- "\x1B 2" => chr(2),
- "\x1B 3" => chr(3),
- "\x1B 4" => chr(4),
- "\x1B 5" => chr(5),
- "\x1B 6" => chr(6),
- "\x1B 7" => chr(7),
- "\x1B 8" => chr(8),
- "\x1B 9" => chr(9),
- "\x1B :" => chr(10),
- "\x1B ;" => chr(11),
- "\x1B <" => chr(12),
- "\x1B =" => chr(13),
- "\x1B >" => chr(14),
- "\x1B ?" => chr(15),
- "\x1B!0" => chr(16),
- "\x1B!1" => chr(17),
- "\x1B!2" => chr(18),
- "\x1B!3" => chr(19),
- "\x1B!4" => chr(20),
- "\x1B!5" => chr(21),
- "\x1B!6" => chr(22),
- "\x1B!7" => chr(23),
- "\x1B!8" => chr(24),
- "\x1B!9" => chr(25),
- "\x1B!:" => chr(26),
- "\x1B!;" => chr(27),
- "\x1B!<" => chr(28),
- "\x1B!=" => chr(29),
- "\x1B!>" => chr(30),
- "\x1B!?" => chr(31),
- "\x1B'?" => chr(127),
- "\x1B(0" => '€', // 128 in CP1252
- "\x1B(2" => '‚', // 130 in CP1252
- "\x1B(3" => 'ƒ', // 131 in CP1252
- "\x1B(4" => '„', // 132 in CP1252
- "\x1B(5" => '…', // 133 in CP1252
- "\x1B(6" => '†', // 134 in CP1252
- "\x1B(7" => '‡', // 135 in CP1252
- "\x1B(8" => 'ˆ', // 136 in CP1252
- "\x1B(9" => '‰', // 137 in CP1252
- "\x1B(:" => 'Š', // 138 in CP1252
- "\x1B(;" => '‹', // 139 in CP1252
- "\x1BNj" => 'Œ', // 140 in CP1252
- "\x1B(>" => 'Ž', // 142 in CP1252
- "\x1B)1" => '‘', // 145 in CP1252
- "\x1B)2" => '’', // 146 in CP1252
- "\x1B)3" => '“', // 147 in CP1252
- "\x1B)4" => '”', // 148 in CP1252
- "\x1B)5" => '•', // 149 in CP1252
- "\x1B)6" => '–', // 150 in CP1252
- "\x1B)7" => '—', // 151 in CP1252
- "\x1B)8" => '˜', // 152 in CP1252
- "\x1B)9" => '™', // 153 in CP1252
- "\x1B):" => 'š', // 154 in CP1252
- "\x1B);" => '›', // 155 in CP1252
- "\x1BNz" => 'œ', // 156 in CP1252
- "\x1B)>" => 'ž', // 158 in CP1252
- "\x1B)?" => 'Ÿ', // 159 in CP1252
- "\x1B*0" => ' ', // 160 in CP1252
- "\x1BN!" => '¡', // 161 in CP1252
- "\x1BN\"" => '¢', // 162 in CP1252
- "\x1BN#" => '£', // 163 in CP1252
- "\x1BN(" => '¤', // 164 in CP1252
- "\x1BN%" => '¥', // 165 in CP1252
- "\x1B*6" => '¦', // 166 in CP1252
- "\x1BN'" => '§', // 167 in CP1252
- "\x1BNH " => '¨', // 168 in CP1252
- "\x1BNS" => '©', // 169 in CP1252
- "\x1BNc" => 'ª', // 170 in CP1252
- "\x1BN+" => '«', // 171 in CP1252
- "\x1B*<" => '¬', // 172 in CP1252
- "\x1B*=" => '', // 173 in CP1252
- "\x1BNR" => '®', // 174 in CP1252
- "\x1B*?" => '¯', // 175 in CP1252
- "\x1BN0" => '°', // 176 in CP1252
- "\x1BN1" => '±', // 177 in CP1252
- "\x1BN2" => '²', // 178 in CP1252
- "\x1BN3" => '³', // 179 in CP1252
- "\x1BNB " => '´', // 180 in CP1252
- "\x1BN5" => 'µ', // 181 in CP1252
- "\x1BN6" => '¶', // 182 in CP1252
- "\x1BN7" => '·', // 183 in CP1252
- "\x1B+8" => '¸', // 184 in CP1252
- "\x1BNQ" => '¹', // 185 in CP1252
- "\x1BNk" => 'º', // 186 in CP1252
- "\x1BN;" => '»', // 187 in CP1252
- "\x1BN<" => '¼', // 188 in CP1252
- "\x1BN=" => '½', // 189 in CP1252
- "\x1BN>" => '¾', // 190 in CP1252
- "\x1BN?" => '¿', // 191 in CP1252
- "\x1BNAA" => 'À', // 192 in CP1252
- "\x1BNBA" => 'Á', // 193 in CP1252
- "\x1BNCA" => 'Â', // 194 in CP1252
- "\x1BNDA" => 'Ã', // 195 in CP1252
- "\x1BNHA" => 'Ä', // 196 in CP1252
- "\x1BNJA" => 'Å', // 197 in CP1252
- "\x1BNa" => 'Æ', // 198 in CP1252
- "\x1BNKC" => 'Ç', // 199 in CP1252
- "\x1BNAE" => 'È', // 200 in CP1252
- "\x1BNBE" => 'É', // 201 in CP1252
- "\x1BNCE" => 'Ê', // 202 in CP1252
- "\x1BNHE" => 'Ë', // 203 in CP1252
- "\x1BNAI" => 'Ì', // 204 in CP1252
- "\x1BNBI" => 'Í', // 205 in CP1252
- "\x1BNCI" => 'Î', // 206 in CP1252
- "\x1BNHI" => 'Ï', // 207 in CP1252
- "\x1BNb" => 'Ð', // 208 in CP1252
- "\x1BNDN" => 'Ñ', // 209 in CP1252
- "\x1BNAO" => 'Ò', // 210 in CP1252
- "\x1BNBO" => 'Ó', // 211 in CP1252
- "\x1BNCO" => 'Ô', // 212 in CP1252
- "\x1BNDO" => 'Õ', // 213 in CP1252
- "\x1BNHO" => 'Ö', // 214 in CP1252
- "\x1B-7" => '×', // 215 in CP1252
- "\x1BNi" => 'Ø', // 216 in CP1252
- "\x1BNAU" => 'Ù', // 217 in CP1252
- "\x1BNBU" => 'Ú', // 218 in CP1252
- "\x1BNCU" => 'Û', // 219 in CP1252
- "\x1BNHU" => 'Ü', // 220 in CP1252
- "\x1B-=" => 'Ý', // 221 in CP1252
- "\x1BNl" => 'Þ', // 222 in CP1252
- "\x1BN{" => 'ß', // 223 in CP1252
- "\x1BNAa" => 'à', // 224 in CP1252
- "\x1BNBa" => 'á', // 225 in CP1252
- "\x1BNCa" => 'â', // 226 in CP1252
- "\x1BNDa" => 'ã', // 227 in CP1252
- "\x1BNHa" => 'ä', // 228 in CP1252
- "\x1BNJa" => 'å', // 229 in CP1252
- "\x1BNq" => 'æ', // 230 in CP1252
- "\x1BNKc" => 'ç', // 231 in CP1252
- "\x1BNAe" => 'è', // 232 in CP1252
- "\x1BNBe" => 'é', // 233 in CP1252
- "\x1BNCe" => 'ê', // 234 in CP1252
- "\x1BNHe" => 'ë', // 235 in CP1252
- "\x1BNAi" => 'ì', // 236 in CP1252
- "\x1BNBi" => 'í', // 237 in CP1252
- "\x1BNCi" => 'î', // 238 in CP1252
- "\x1BNHi" => 'ï', // 239 in CP1252
- "\x1BNs" => 'ð', // 240 in CP1252
- "\x1BNDn" => 'ñ', // 241 in CP1252
- "\x1BNAo" => 'ò', // 242 in CP1252
- "\x1BNBo" => 'ó', // 243 in CP1252
- "\x1BNCo" => 'ô', // 244 in CP1252
- "\x1BNDo" => 'õ', // 245 in CP1252
- "\x1BNHo" => 'ö', // 246 in CP1252
- "\x1B/7" => '÷', // 247 in CP1252
- "\x1BNy" => 'ø', // 248 in CP1252
- "\x1BNAu" => 'ù', // 249 in CP1252
- "\x1BNBu" => 'ú', // 250 in CP1252
- "\x1BNCu" => 'û', // 251 in CP1252
- "\x1BNHu" => 'ü', // 252 in CP1252
- "\x1B/=" => 'ý', // 253 in CP1252
- "\x1BN|" => 'þ', // 254 in CP1252
- "\x1BNHy" => 'ÿ', // 255 in CP1252
- ];
- }
-
- /**
- * Get whether iconv extension is available.
- *
- * @return bool
- */
- public static function getIsIconvEnabled()
- {
- if (isset(self::$isIconvEnabled)) {
- return self::$isIconvEnabled;
- }
-
- // Assume no problems with iconv
- self::$isIconvEnabled = true;
-
- // Fail if iconv doesn't exist
- if (!function_exists('iconv')) {
- self::$isIconvEnabled = false;
- } elseif (!@iconv('UTF-8', 'UTF-16LE', 'x')) {
- // Sometimes iconv is not working, and e.g. iconv('UTF-8', 'UTF-16LE', 'x') just returns false,
- self::$isIconvEnabled = false;
- } elseif (defined('PHP_OS') && @stristr(PHP_OS, 'AIX') && defined('ICONV_IMPL') && (@strcasecmp(ICONV_IMPL, 'unknown') == 0) && defined('ICONV_VERSION') && (@strcasecmp(ICONV_VERSION, 'unknown') == 0)) {
- // CUSTOM: IBM AIX iconv() does not work
- self::$isIconvEnabled = false;
- }
-
- // Deactivate iconv default options if they fail (as seen on IMB i)
- if (self::$isIconvEnabled && !@iconv('UTF-8', 'UTF-16LE' . self::$iconvOptions, 'x')) {
- self::$iconvOptions = '';
- }
-
- return self::$isIconvEnabled;
- }
-
- private static function buildCharacterSets(): void
- {
- if (empty(self::$controlCharacters)) {
- self::buildControlCharacters();
- }
-
- if (empty(self::$SYLKCharacters)) {
- self::buildSYLKCharacters();
- }
- }
-
- /**
- * Convert from OpenXML escaped control character to PHP control character.
- *
- * Excel 2007 team:
- * ----------------
- * That's correct, control characters are stored directly in the shared-strings table.
- * We do encode characters that cannot be represented in XML using the following escape sequence:
- * _xHHHH_ where H represents a hexadecimal character in the character's value...
- * So you could end up with something like _x0008_ in a string (either in a cell value ()
- * element or in the shared string element.
- *
- * @param string $textValue Value to unescape
- *
- * @return string
- */
- public static function controlCharacterOOXML2PHP($textValue)
- {
- self::buildCharacterSets();
-
- return str_replace(array_keys(self::$controlCharacters), array_values(self::$controlCharacters), $textValue);
- }
-
- /**
- * Convert from PHP control character to OpenXML escaped control character.
- *
- * Excel 2007 team:
- * ----------------
- * That's correct, control characters are stored directly in the shared-strings table.
- * We do encode characters that cannot be represented in XML using the following escape sequence:
- * _xHHHH_ where H represents a hexadecimal character in the character's value...
- * So you could end up with something like _x0008_ in a string (either in a cell value ()
- * element or in the shared string element.
- *
- * @param string $textValue Value to escape
- *
- * @return string
- */
- public static function controlCharacterPHP2OOXML($textValue)
- {
- self::buildCharacterSets();
-
- return str_replace(array_values(self::$controlCharacters), array_keys(self::$controlCharacters), $textValue);
- }
-
- /**
- * Try to sanitize UTF8, replacing invalid sequences with Unicode substitution characters.
- */
- public static function sanitizeUTF8(string $textValue): string
- {
- $textValue = str_replace(["\xef\xbf\xbe", "\xef\xbf\xbf"], "\xef\xbf\xbd", $textValue);
- $subst = mb_substitute_character(); // default is question mark
- mb_substitute_character(65533); // Unicode substitution character
- // Phpstan does not think this can return false.
- $returnValue = mb_convert_encoding($textValue, 'UTF-8', 'UTF-8');
- mb_substitute_character(/** @scrutinizer ignore-type */ $subst);
-
- return self::returnString($returnValue);
- }
-
- /**
- * Strictly to satisfy Scrutinizer.
- *
- * @param mixed $value
- */
- private static function returnString($value): string
- {
- return is_string($value) ? $value : '';
- }
-
- /**
- * Check if a string contains UTF8 data.
- */
- public static function isUTF8(string $textValue): bool
- {
- return $textValue === self::sanitizeUTF8($textValue);
- }
-
- /**
- * Formats a numeric value as a string for output in various output writers forcing
- * point as decimal separator in case locale is other than English.
- *
- * @param float|int|string $numericValue
- */
- public static function formatNumber($numericValue): string
- {
- if (is_float($numericValue)) {
- return str_replace(',', '.', (string) $numericValue);
- }
-
- return (string) $numericValue;
- }
-
- /**
- * Converts a UTF-8 string into BIFF8 Unicode string data (8-bit string length)
- * Writes the string using uncompressed notation, no rich text, no Asian phonetics
- * If mbstring extension is not available, ASCII is assumed, and compressed notation is used
- * although this will give wrong results for non-ASCII strings
- * see OpenOffice.org's Documentation of the Microsoft Excel File Format, sect. 2.5.3.
- *
- * @param string $textValue UTF-8 encoded string
- * @param mixed[] $arrcRuns Details of rich text runs in $value
- */
- public static function UTF8toBIFF8UnicodeShort(string $textValue, array $arrcRuns = []): string
- {
- // character count
- $ln = self::countCharacters($textValue, 'UTF-8');
- // option flags
- if (empty($arrcRuns)) {
- $data = pack('CC', $ln, 0x0001);
- // characters
- $data .= self::convertEncoding($textValue, 'UTF-16LE', 'UTF-8');
- } else {
- $data = pack('vC', $ln, 0x09);
- $data .= pack('v', count($arrcRuns));
- // characters
- $data .= self::convertEncoding($textValue, 'UTF-16LE', 'UTF-8');
- foreach ($arrcRuns as $cRun) {
- $data .= pack('v', $cRun['strlen']);
- $data .= pack('v', $cRun['fontidx']);
- }
- }
-
- return $data;
- }
-
- /**
- * Converts a UTF-8 string into BIFF8 Unicode string data (16-bit string length)
- * Writes the string using uncompressed notation, no rich text, no Asian phonetics
- * If mbstring extension is not available, ASCII is assumed, and compressed notation is used
- * although this will give wrong results for non-ASCII strings
- * see OpenOffice.org's Documentation of the Microsoft Excel File Format, sect. 2.5.3.
- *
- * @param string $textValue UTF-8 encoded string
- */
- public static function UTF8toBIFF8UnicodeLong(string $textValue): string
- {
- // character count
- $ln = self::countCharacters($textValue, 'UTF-8');
-
- // characters
- $chars = self::convertEncoding($textValue, 'UTF-16LE', 'UTF-8');
-
- return pack('vC', $ln, 0x0001) . $chars;
- }
-
- /**
- * Convert string from one encoding to another.
- *
- * @param string $to Encoding to convert to, e.g. 'UTF-8'
- * @param string $from Encoding to convert from, e.g. 'UTF-16LE'
- */
- public static function convertEncoding(string $textValue, string $to, string $from): string
- {
- if (self::getIsIconvEnabled()) {
- $result = iconv($from, $to . self::$iconvOptions, $textValue);
- if (false !== $result) {
- return $result;
- }
- }
-
- return self::returnString(mb_convert_encoding($textValue, $to, $from));
- }
-
- /**
- * Get character count.
- *
- * @param string $encoding Encoding
- *
- * @return int Character count
- */
- public static function countCharacters(string $textValue, string $encoding = 'UTF-8'): int
- {
- return mb_strlen($textValue, $encoding);
- }
-
- /**
- * Get character count using mb_strwidth rather than mb_strlen.
- *
- * @param string $encoding Encoding
- *
- * @return int Character count
- */
- public static function countCharactersDbcs(string $textValue, string $encoding = 'UTF-8'): int
- {
- return mb_strwidth($textValue, $encoding);
- }
-
- /**
- * Get a substring of a UTF-8 encoded string.
- *
- * @param string $textValue UTF-8 encoded string
- * @param int $offset Start offset
- * @param ?int $length Maximum number of characters in substring
- */
- public static function substring(string $textValue, int $offset, ?int $length = 0): string
- {
- return mb_substr($textValue, $offset, $length, 'UTF-8');
- }
-
- /**
- * Convert a UTF-8 encoded string to upper case.
- *
- * @param string $textValue UTF-8 encoded string
- */
- public static function strToUpper(string $textValue): string
- {
- return mb_convert_case($textValue, MB_CASE_UPPER, 'UTF-8');
- }
-
- /**
- * Convert a UTF-8 encoded string to lower case.
- *
- * @param string $textValue UTF-8 encoded string
- */
- public static function strToLower(string $textValue): string
- {
- return mb_convert_case($textValue, MB_CASE_LOWER, 'UTF-8');
- }
-
- /**
- * Convert a UTF-8 encoded string to title/proper case
- * (uppercase every first character in each word, lower case all other characters).
- *
- * @param string $textValue UTF-8 encoded string
- */
- public static function strToTitle(string $textValue): string
- {
- return mb_convert_case($textValue, MB_CASE_TITLE, 'UTF-8');
- }
-
- public static function mbIsUpper(string $character): bool
- {
- return mb_strtolower($character, 'UTF-8') !== $character;
- }
-
- /**
- * Splits a UTF-8 string into an array of individual characters.
- */
- public static function mbStrSplit(string $string): array
- {
- // Split at all position not after the start: ^
- // and not before the end: $
- $split = preg_split('/(? $v) {
- $textValue = str_replace($k, $v, $textValue);
- }
-
- return $textValue;
- }
-
- /**
- * Retrieve any leading numeric part of a string, or return the full string if no leading numeric
- * (handles basic integer or float, but not exponent or non decimal).
- *
- * @param string $textValue
- *
- * @return mixed string or only the leading numeric part of the string
- */
- public static function testStringAsNumeric($textValue)
- {
- if (is_numeric($textValue)) {
- return $textValue;
- }
- $v = (float) $textValue;
-
- return (is_numeric(substr($textValue, 0, strlen((string) $v)))) ? $v : $textValue;
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/TimeZone.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/TimeZone.php
deleted file mode 100644
index 324e3424dd2..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/TimeZone.php
+++ /dev/null
@@ -1,77 +0,0 @@
-setTimeZone(new DateTimeZone($timezoneName));
-
- return $dtobj->getOffset();
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/BestFit.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/BestFit.php
deleted file mode 100644
index a8d7c93b7d6..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/BestFit.php
+++ /dev/null
@@ -1,501 +0,0 @@
-error;
- }
-
- /** @return string */
- public function getBestFitType()
- {
- return $this->bestFitType;
- }
-
- /**
- * Return the Y-Value for a specified value of X.
- *
- * @param float $xValue X-Value
- *
- * @return float Y-Value
- */
- abstract public function getValueOfYForX($xValue);
-
- /**
- * Return the X-Value for a specified value of Y.
- *
- * @param float $yValue Y-Value
- *
- * @return float X-Value
- */
- abstract public function getValueOfXForY($yValue);
-
- /**
- * Return the original set of X-Values.
- *
- * @return float[] X-Values
- */
- public function getXValues()
- {
- return $this->xValues;
- }
-
- /**
- * Return the Equation of the best-fit line.
- *
- * @param int $dp Number of places of decimal precision to display
- *
- * @return string
- */
- abstract public function getEquation($dp = 0);
-
- /**
- * Return the Slope of the line.
- *
- * @param int $dp Number of places of decimal precision to display
- *
- * @return float
- */
- public function getSlope($dp = 0)
- {
- if ($dp != 0) {
- return round($this->slope, $dp);
- }
-
- return $this->slope;
- }
-
- /**
- * Return the standard error of the Slope.
- *
- * @param int $dp Number of places of decimal precision to display
- *
- * @return float
- */
- public function getSlopeSE($dp = 0)
- {
- if ($dp != 0) {
- return round($this->slopeSE, $dp);
- }
-
- return $this->slopeSE;
- }
-
- /**
- * Return the Value of X where it intersects Y = 0.
- *
- * @param int $dp Number of places of decimal precision to display
- *
- * @return float
- */
- public function getIntersect($dp = 0)
- {
- if ($dp != 0) {
- return round($this->intersect, $dp);
- }
-
- return $this->intersect;
- }
-
- /**
- * Return the standard error of the Intersect.
- *
- * @param int $dp Number of places of decimal precision to display
- *
- * @return float
- */
- public function getIntersectSE($dp = 0)
- {
- if ($dp != 0) {
- return round($this->intersectSE, $dp);
- }
-
- return $this->intersectSE;
- }
-
- /**
- * Return the goodness of fit for this regression.
- *
- * @param int $dp Number of places of decimal precision to return
- *
- * @return float
- */
- public function getGoodnessOfFit($dp = 0)
- {
- if ($dp != 0) {
- return round($this->goodnessOfFit, $dp);
- }
-
- return $this->goodnessOfFit;
- }
-
- /**
- * Return the goodness of fit for this regression.
- *
- * @param int $dp Number of places of decimal precision to return
- *
- * @return float
- */
- public function getGoodnessOfFitPercent($dp = 0)
- {
- if ($dp != 0) {
- return round($this->goodnessOfFit * 100, $dp);
- }
-
- return $this->goodnessOfFit * 100;
- }
-
- /**
- * Return the standard deviation of the residuals for this regression.
- *
- * @param int $dp Number of places of decimal precision to return
- *
- * @return float
- */
- public function getStdevOfResiduals($dp = 0)
- {
- if ($dp != 0) {
- return round($this->stdevOfResiduals, $dp);
- }
-
- return $this->stdevOfResiduals;
- }
-
- /**
- * @param int $dp Number of places of decimal precision to return
- *
- * @return float
- */
- public function getSSRegression($dp = 0)
- {
- if ($dp != 0) {
- return round($this->SSRegression, $dp);
- }
-
- return $this->SSRegression;
- }
-
- /**
- * @param int $dp Number of places of decimal precision to return
- *
- * @return float
- */
- public function getSSResiduals($dp = 0)
- {
- if ($dp != 0) {
- return round($this->SSResiduals, $dp);
- }
-
- return $this->SSResiduals;
- }
-
- /**
- * @param int $dp Number of places of decimal precision to return
- *
- * @return float
- */
- public function getDFResiduals($dp = 0)
- {
- if ($dp != 0) {
- return round($this->DFResiduals, $dp);
- }
-
- return $this->DFResiduals;
- }
-
- /**
- * @param int $dp Number of places of decimal precision to return
- *
- * @return float
- */
- public function getF($dp = 0)
- {
- if ($dp != 0) {
- return round($this->f, $dp);
- }
-
- return $this->f;
- }
-
- /**
- * @param int $dp Number of places of decimal precision to return
- *
- * @return float
- */
- public function getCovariance($dp = 0)
- {
- if ($dp != 0) {
- return round($this->covariance, $dp);
- }
-
- return $this->covariance;
- }
-
- /**
- * @param int $dp Number of places of decimal precision to return
- *
- * @return float
- */
- public function getCorrelation($dp = 0)
- {
- if ($dp != 0) {
- return round($this->correlation, $dp);
- }
-
- return $this->correlation;
- }
-
- /**
- * @return float[]
- */
- public function getYBestFitValues()
- {
- return $this->yBestFitValues;
- }
-
- /** @var mixed */
- private static $scrutinizerZeroPointZero = 0.0;
-
- /**
- * @param mixed $x
- * @param mixed $y
- */
- private static function scrutinizerLooseCompare($x, $y): bool
- {
- return $x == $y;
- }
-
- /**
- * @param float $sumX
- * @param float $sumY
- * @param float $sumX2
- * @param float $sumY2
- * @param float $sumXY
- * @param float $meanX
- * @param float $meanY
- * @param bool|int $const
- */
- protected function calculateGoodnessOfFit($sumX, $sumY, $sumX2, $sumY2, $sumXY, $meanX, $meanY, $const): void
- {
- $SSres = $SScov = $SStot = $SSsex = 0.0;
- foreach ($this->xValues as $xKey => $xValue) {
- $bestFitY = $this->yBestFitValues[$xKey] = $this->getValueOfYForX($xValue);
-
- $SSres += ($this->yValues[$xKey] - $bestFitY) * ($this->yValues[$xKey] - $bestFitY);
- if ($const === true) {
- $SStot += ($this->yValues[$xKey] - $meanY) * ($this->yValues[$xKey] - $meanY);
- } else {
- $SStot += $this->yValues[$xKey] * $this->yValues[$xKey];
- }
- $SScov += ($this->xValues[$xKey] - $meanX) * ($this->yValues[$xKey] - $meanY);
- if ($const === true) {
- $SSsex += ($this->xValues[$xKey] - $meanX) * ($this->xValues[$xKey] - $meanX);
- } else {
- $SSsex += $this->xValues[$xKey] * $this->xValues[$xKey];
- }
- }
-
- $this->SSResiduals = $SSres;
- $this->DFResiduals = $this->valueCount - 1 - ($const === true ? 1 : 0);
-
- if ($this->DFResiduals == 0.0) {
- $this->stdevOfResiduals = 0.0;
- } else {
- $this->stdevOfResiduals = sqrt($SSres / $this->DFResiduals);
- }
- // Scrutinizer thinks $SSres == $SStot is always true. It is wrong.
- if ($SStot == self::$scrutinizerZeroPointZero || self::scrutinizerLooseCompare($SSres, $SStot)) {
- $this->goodnessOfFit = 1;
- } else {
- $this->goodnessOfFit = 1 - ($SSres / $SStot);
- }
-
- $this->SSRegression = $this->goodnessOfFit * $SStot;
- $this->covariance = $SScov / $this->valueCount;
- $this->correlation = ($this->valueCount * $sumXY - $sumX * $sumY) / sqrt(($this->valueCount * $sumX2 - $sumX ** 2) * ($this->valueCount * $sumY2 - $sumY ** 2));
- $this->slopeSE = $this->stdevOfResiduals / sqrt($SSsex);
- $this->intersectSE = $this->stdevOfResiduals * sqrt(1 / ($this->valueCount - ($sumX * $sumX) / $sumX2));
- if ($this->SSResiduals != 0.0) {
- if ($this->DFResiduals == 0.0) {
- $this->f = 0.0;
- } else {
- $this->f = $this->SSRegression / ($this->SSResiduals / $this->DFResiduals);
- }
- } else {
- if ($this->DFResiduals == 0.0) {
- $this->f = 0.0;
- } else {
- $this->f = $this->SSRegression / $this->DFResiduals;
- }
- }
- }
-
- /** @return float|int */
- private function sumSquares(array $values)
- {
- return array_sum(
- array_map(
- function ($value) {
- return $value ** 2;
- },
- $values
- )
- );
- }
-
- /**
- * @param float[] $yValues
- * @param float[] $xValues
- */
- protected function leastSquareFit(array $yValues, array $xValues, bool $const): void
- {
- // calculate sums
- $sumValuesX = array_sum($xValues);
- $sumValuesY = array_sum($yValues);
- $meanValueX = $sumValuesX / $this->valueCount;
- $meanValueY = $sumValuesY / $this->valueCount;
- $sumSquaresX = $this->sumSquares($xValues);
- $sumSquaresY = $this->sumSquares($yValues);
- $mBase = $mDivisor = 0.0;
- $xy_sum = 0.0;
- for ($i = 0; $i < $this->valueCount; ++$i) {
- $xy_sum += $xValues[$i] * $yValues[$i];
-
- if ($const === true) {
- $mBase += ($xValues[$i] - $meanValueX) * ($yValues[$i] - $meanValueY);
- $mDivisor += ($xValues[$i] - $meanValueX) * ($xValues[$i] - $meanValueX);
- } else {
- $mBase += $xValues[$i] * $yValues[$i];
- $mDivisor += $xValues[$i] * $xValues[$i];
- }
- }
-
- // calculate slope
- $this->slope = $mBase / $mDivisor;
-
- // calculate intersect
- $this->intersect = ($const === true) ? $meanValueY - ($this->slope * $meanValueX) : 0.0;
-
- $this->calculateGoodnessOfFit($sumValuesX, $sumValuesY, $sumSquaresX, $sumSquaresY, $xy_sum, $meanValueX, $meanValueY, $const);
- }
-
- /**
- * Define the regression.
- *
- * @param float[] $yValues The set of Y-values for this regression
- * @param float[] $xValues The set of X-values for this regression
- */
- public function __construct($yValues, $xValues = [])
- {
- // Calculate number of points
- $yValueCount = count($yValues);
- $xValueCount = count($xValues);
-
- // Define X Values if necessary
- if ($xValueCount === 0) {
- $xValues = range(1, $yValueCount);
- } elseif ($yValueCount !== $xValueCount) {
- // Ensure both arrays of points are the same size
- $this->error = true;
- }
-
- $this->valueCount = $yValueCount;
- $this->xValues = $xValues;
- $this->yValues = $yValues;
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/ExponentialBestFit.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/ExponentialBestFit.php
deleted file mode 100644
index eb8cd746d36..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/ExponentialBestFit.php
+++ /dev/null
@@ -1,119 +0,0 @@
-getIntersect() * $this->getSlope() ** ($xValue - $this->xOffset);
- }
-
- /**
- * Return the X-Value for a specified value of Y.
- *
- * @param float $yValue Y-Value
- *
- * @return float X-Value
- */
- public function getValueOfXForY($yValue)
- {
- return log(($yValue + $this->yOffset) / $this->getIntersect()) / log($this->getSlope());
- }
-
- /**
- * Return the Equation of the best-fit line.
- *
- * @param int $dp Number of places of decimal precision to display
- *
- * @return string
- */
- public function getEquation($dp = 0)
- {
- $slope = $this->getSlope($dp);
- $intersect = $this->getIntersect($dp);
-
- return 'Y = ' . $intersect . ' * ' . $slope . '^X';
- }
-
- /**
- * Return the Slope of the line.
- *
- * @param int $dp Number of places of decimal precision to display
- *
- * @return float
- */
- public function getSlope($dp = 0)
- {
- if ($dp != 0) {
- return round(exp($this->slope), $dp);
- }
-
- return exp($this->slope);
- }
-
- /**
- * Return the Value of X where it intersects Y = 0.
- *
- * @param int $dp Number of places of decimal precision to display
- *
- * @return float
- */
- public function getIntersect($dp = 0)
- {
- if ($dp != 0) {
- return round(exp($this->intersect), $dp);
- }
-
- return exp($this->intersect);
- }
-
- /**
- * Execute the regression and calculate the goodness of fit for a set of X and Y data values.
- *
- * @param float[] $yValues The set of Y-values for this regression
- * @param float[] $xValues The set of X-values for this regression
- */
- private function exponentialRegression(array $yValues, array $xValues, bool $const): void
- {
- $adjustedYValues = array_map(
- function ($value) {
- return ($value < 0.0) ? 0 - log(abs($value)) : log($value);
- },
- $yValues
- );
-
- $this->leastSquareFit($adjustedYValues, $xValues, $const);
- }
-
- /**
- * Define the regression and calculate the goodness of fit for a set of X and Y data values.
- *
- * @param float[] $yValues The set of Y-values for this regression
- * @param float[] $xValues The set of X-values for this regression
- * @param bool $const
- */
- public function __construct($yValues, $xValues = [], $const = true)
- {
- parent::__construct($yValues, $xValues);
-
- if (!$this->error) {
- $this->exponentialRegression($yValues, $xValues, (bool) $const);
- }
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/LinearBestFit.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/LinearBestFit.php
deleted file mode 100644
index 65d6b4ff44d..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/LinearBestFit.php
+++ /dev/null
@@ -1,80 +0,0 @@
-getIntersect() + $this->getSlope() * $xValue;
- }
-
- /**
- * Return the X-Value for a specified value of Y.
- *
- * @param float $yValue Y-Value
- *
- * @return float X-Value
- */
- public function getValueOfXForY($yValue)
- {
- return ($yValue - $this->getIntersect()) / $this->getSlope();
- }
-
- /**
- * Return the Equation of the best-fit line.
- *
- * @param int $dp Number of places of decimal precision to display
- *
- * @return string
- */
- public function getEquation($dp = 0)
- {
- $slope = $this->getSlope($dp);
- $intersect = $this->getIntersect($dp);
-
- return 'Y = ' . $intersect . ' + ' . $slope . ' * X';
- }
-
- /**
- * Execute the regression and calculate the goodness of fit for a set of X and Y data values.
- *
- * @param float[] $yValues The set of Y-values for this regression
- * @param float[] $xValues The set of X-values for this regression
- */
- private function linearRegression(array $yValues, array $xValues, bool $const): void
- {
- $this->leastSquareFit($yValues, $xValues, $const);
- }
-
- /**
- * Define the regression and calculate the goodness of fit for a set of X and Y data values.
- *
- * @param float[] $yValues The set of Y-values for this regression
- * @param float[] $xValues The set of X-values for this regression
- * @param bool $const
- */
- public function __construct($yValues, $xValues = [], $const = true)
- {
- parent::__construct($yValues, $xValues);
-
- if (!$this->error) {
- $this->linearRegression($yValues, $xValues, (bool) $const);
- }
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/LogarithmicBestFit.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/LogarithmicBestFit.php
deleted file mode 100644
index 2366dc636aa..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/LogarithmicBestFit.php
+++ /dev/null
@@ -1,87 +0,0 @@
-getIntersect() + $this->getSlope() * log($xValue - $this->xOffset);
- }
-
- /**
- * Return the X-Value for a specified value of Y.
- *
- * @param float $yValue Y-Value
- *
- * @return float X-Value
- */
- public function getValueOfXForY($yValue)
- {
- return exp(($yValue - $this->getIntersect()) / $this->getSlope());
- }
-
- /**
- * Return the Equation of the best-fit line.
- *
- * @param int $dp Number of places of decimal precision to display
- *
- * @return string
- */
- public function getEquation($dp = 0)
- {
- $slope = $this->getSlope($dp);
- $intersect = $this->getIntersect($dp);
-
- return 'Y = ' . $slope . ' * log(' . $intersect . ' * X)';
- }
-
- /**
- * Execute the regression and calculate the goodness of fit for a set of X and Y data values.
- *
- * @param float[] $yValues The set of Y-values for this regression
- * @param float[] $xValues The set of X-values for this regression
- */
- private function logarithmicRegression(array $yValues, array $xValues, bool $const): void
- {
- $adjustedYValues = array_map(
- function ($value) {
- return ($value < 0.0) ? 0 - log(abs($value)) : log($value);
- },
- $yValues
- );
-
- $this->leastSquareFit($adjustedYValues, $xValues, $const);
- }
-
- /**
- * Define the regression and calculate the goodness of fit for a set of X and Y data values.
- *
- * @param float[] $yValues The set of Y-values for this regression
- * @param float[] $xValues The set of X-values for this regression
- * @param bool $const
- */
- public function __construct($yValues, $xValues = [], $const = true)
- {
- parent::__construct($yValues, $xValues);
-
- if (!$this->error) {
- $this->logarithmicRegression($yValues, $xValues, (bool) $const);
- }
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/PolynomialBestFit.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/PolynomialBestFit.php
deleted file mode 100644
index 222a4230045..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/PolynomialBestFit.php
+++ /dev/null
@@ -1,219 +0,0 @@
-slope is specified where an array is expected in several places.
-// But it seems that it should always be float.
-// This code is probably not exercised at all in unit tests.
-class PolynomialBestFit extends BestFit
-{
- /**
- * Algorithm type to use for best-fit
- * (Name of this Trend class).
- *
- * @var string
- */
- protected $bestFitType = 'polynomial';
-
- /**
- * Polynomial order.
- *
- * @var int
- */
- protected $order = 0;
-
- /**
- * Return the order of this polynomial.
- *
- * @return int
- */
- public function getOrder()
- {
- return $this->order;
- }
-
- /**
- * Return the Y-Value for a specified value of X.
- *
- * @param float $xValue X-Value
- *
- * @return float Y-Value
- */
- public function getValueOfYForX($xValue)
- {
- $retVal = $this->getIntersect();
- $slope = $this->getSlope();
- // Phpstan and Scrutinizer are both correct - getSlope returns float, not array.
- // @phpstan-ignore-next-line
- foreach ($slope as $key => $value) {
- if ($value != 0.0) {
- $retVal += $value * $xValue ** ($key + 1);
- }
- }
-
- return $retVal;
- }
-
- /**
- * Return the X-Value for a specified value of Y.
- *
- * @param float $yValue Y-Value
- *
- * @return float X-Value
- */
- public function getValueOfXForY($yValue)
- {
- return ($yValue - $this->getIntersect()) / $this->getSlope();
- }
-
- /**
- * Return the Equation of the best-fit line.
- *
- * @param int $dp Number of places of decimal precision to display
- *
- * @return string
- */
- public function getEquation($dp = 0)
- {
- $slope = $this->getSlope($dp);
- $intersect = $this->getIntersect($dp);
-
- $equation = 'Y = ' . $intersect;
- // Phpstan and Scrutinizer are both correct - getSlope returns float, not array.
- // @phpstan-ignore-next-line
- foreach ($slope as $key => $value) {
- if ($value != 0.0) {
- $equation .= ' + ' . $value . ' * X';
- if ($key > 0) {
- $equation .= '^' . ($key + 1);
- }
- }
- }
-
- return $equation;
- }
-
- /**
- * Return the Slope of the line.
- *
- * @param int $dp Number of places of decimal precision to display
- *
- * @return float
- */
- public function getSlope($dp = 0)
- {
- if ($dp != 0) {
- $coefficients = [];
- // Scrutinizer is correct - $this->slope is float, not array.
- //* @phpstan-ignore-next-line
- foreach ($this->slope as $coefficient) {
- $coefficients[] = round($coefficient, $dp);
- }
-
- // @phpstan-ignore-next-line
- return $coefficients;
- }
-
- return $this->slope;
- }
-
- /**
- * @param int $dp
- *
- * @return array
- */
- public function getCoefficients($dp = 0)
- {
- // Phpstan and Scrutinizer are both correct - getSlope returns float, not array.
- // @phpstan-ignore-next-line
- return array_merge([$this->getIntersect($dp)], $this->getSlope($dp));
- }
-
- /**
- * Execute the regression and calculate the goodness of fit for a set of X and Y data values.
- *
- * @param int $order Order of Polynomial for this regression
- * @param float[] $yValues The set of Y-values for this regression
- * @param float[] $xValues The set of X-values for this regression
- */
- private function polynomialRegression($order, $yValues, $xValues): void
- {
- // calculate sums
- $x_sum = array_sum($xValues);
- $y_sum = array_sum($yValues);
- $xx_sum = $xy_sum = $yy_sum = 0;
- for ($i = 0; $i < $this->valueCount; ++$i) {
- $xy_sum += $xValues[$i] * $yValues[$i];
- $xx_sum += $xValues[$i] * $xValues[$i];
- $yy_sum += $yValues[$i] * $yValues[$i];
- }
- /*
- * This routine uses logic from the PHP port of polyfit version 0.1
- * written by Michael Bommarito and Paul Meagher
- *
- * The function fits a polynomial function of order $order through
- * a series of x-y data points using least squares.
- *
- */
- $A = [];
- $B = [];
- for ($i = 0; $i < $this->valueCount; ++$i) {
- for ($j = 0; $j <= $order; ++$j) {
- $A[$i][$j] = $xValues[$i] ** $j;
- }
- }
- for ($i = 0; $i < $this->valueCount; ++$i) {
- $B[$i] = [$yValues[$i]];
- }
- $matrixA = new Matrix($A);
- $matrixB = new Matrix($B);
- $C = $matrixA->solve($matrixB);
-
- $coefficients = [];
- for ($i = 0; $i < $C->rows; ++$i) {
- $r = $C->getValue($i + 1, 1); // row and column are origin-1
- if (abs($r) <= 10 ** (-9)) {
- $r = 0;
- }
- $coefficients[] = $r;
- }
-
- $this->intersect = array_shift($coefficients);
- // Phpstan (and maybe Scrutinizer) are correct
- //* @phpstan-ignore-next-line
- $this->slope = $coefficients;
-
- $this->calculateGoodnessOfFit($x_sum, $y_sum, $xx_sum, $yy_sum, $xy_sum, 0, 0, 0);
- foreach ($this->xValues as $xKey => $xValue) {
- $this->yBestFitValues[$xKey] = $this->getValueOfYForX($xValue);
- }
- }
-
- /**
- * Define the regression and calculate the goodness of fit for a set of X and Y data values.
- *
- * @param int $order Order of Polynomial for this regression
- * @param float[] $yValues The set of Y-values for this regression
- * @param float[] $xValues The set of X-values for this regression
- */
- public function __construct($order, $yValues, $xValues = [])
- {
- parent::__construct($yValues, $xValues);
-
- if (!$this->error) {
- if ($order < $this->valueCount) {
- $this->bestFitType .= '_' . $order;
- $this->order = $order;
- $this->polynomialRegression($order, $yValues, $xValues);
- if (($this->getGoodnessOfFit() < 0.0) || ($this->getGoodnessOfFit() > 1.0)) {
- $this->error = true;
- }
- } else {
- $this->error = true;
- }
- }
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/PowerBestFit.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/PowerBestFit.php
deleted file mode 100644
index cafd01158e9..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/PowerBestFit.php
+++ /dev/null
@@ -1,109 +0,0 @@
-getIntersect() * ($xValue - $this->xOffset) ** $this->getSlope();
- }
-
- /**
- * Return the X-Value for a specified value of Y.
- *
- * @param float $yValue Y-Value
- *
- * @return float X-Value
- */
- public function getValueOfXForY($yValue)
- {
- return (($yValue + $this->yOffset) / $this->getIntersect()) ** (1 / $this->getSlope());
- }
-
- /**
- * Return the Equation of the best-fit line.
- *
- * @param int $dp Number of places of decimal precision to display
- *
- * @return string
- */
- public function getEquation($dp = 0)
- {
- $slope = $this->getSlope($dp);
- $intersect = $this->getIntersect($dp);
-
- return 'Y = ' . $intersect . ' * X^' . $slope;
- }
-
- /**
- * Return the Value of X where it intersects Y = 0.
- *
- * @param int $dp Number of places of decimal precision to display
- *
- * @return float
- */
- public function getIntersect($dp = 0)
- {
- if ($dp != 0) {
- return round(exp($this->intersect), $dp);
- }
-
- return exp($this->intersect);
- }
-
- /**
- * Execute the regression and calculate the goodness of fit for a set of X and Y data values.
- *
- * @param float[] $yValues The set of Y-values for this regression
- * @param float[] $xValues The set of X-values for this regression
- */
- private function powerRegression(array $yValues, array $xValues, bool $const): void
- {
- $adjustedYValues = array_map(
- function ($value) {
- return ($value < 0.0) ? 0 - log(abs($value)) : log($value);
- },
- $yValues
- );
- $adjustedXValues = array_map(
- function ($value) {
- return ($value < 0.0) ? 0 - log(abs($value)) : log($value);
- },
- $xValues
- );
-
- $this->leastSquareFit($adjustedYValues, $adjustedXValues, $const);
- }
-
- /**
- * Define the regression and calculate the goodness of fit for a set of X and Y data values.
- *
- * @param float[] $yValues The set of Y-values for this regression
- * @param float[] $xValues The set of X-values for this regression
- * @param bool $const
- */
- public function __construct($yValues, $xValues = [], $const = true)
- {
- parent::__construct($yValues, $xValues);
-
- if (!$this->error) {
- $this->powerRegression($yValues, $xValues, (bool) $const);
- }
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/Trend.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/Trend.php
deleted file mode 100644
index 117848c778b..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/Trend.php
+++ /dev/null
@@ -1,130 +0,0 @@
-getGoodnessOfFit();
- }
- if ($trendType != self::TREND_BEST_FIT_NO_POLY) {
- foreach (self::$trendTypePolynomialOrders as $trendMethod) {
- $order = (int) substr($trendMethod, -1);
- $bestFit[$trendMethod] = new PolynomialBestFit($order, $yValues, $xValues);
- if ($bestFit[$trendMethod]->getError()) {
- unset($bestFit[$trendMethod]);
- } else {
- $bestFitValue[$trendMethod] = $bestFit[$trendMethod]->getGoodnessOfFit();
- }
- }
- }
- // Determine which of our Trend lines is the best fit, and then we return the instance of that Trend class
- arsort($bestFitValue);
- $bestFitType = key($bestFitValue);
-
- return $bestFit[$bestFitType];
- default:
- return false;
- }
- }
-}
diff --git a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/XMLWriter.php b/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/XMLWriter.php
deleted file mode 100644
index d9f403d7ada..00000000000
--- a/lib/phpspreadsheet/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/XMLWriter.php
+++ /dev/null
@@ -1,104 +0,0 @@
-openMemory();
- } else {
- // Create temporary filename
- if ($temporaryStorageFolder === null) {
- $temporaryStorageFolder = File::sysGetTempDir();
- }
- $this->tempFileName = (string) @tempnam($temporaryStorageFolder, 'xml');
-
- // Open storage
- if (empty($this->tempFileName) || $this->openUri($this->tempFileName) === false) {
- // Fallback to memory...
- $this->openMemory();
- }
- }
-
- // Set default values
- if (self::$debugEnabled) {
- $this->setIndent(true);
- }
- }
-
- /**
- * Destructor.
- */
- public function __destruct()
- {
- // Unlink temporary files
- // There is nothing reasonable to do if unlink fails.
- if ($this->tempFileName != '') {
- /** @scrutinizer ignore-unhandled */
- @unlink($this->tempFileName);
- }
- }
-
- public function __wakeup(): void
- {
- $this->tempFileName = '';
-
- throw new SpreadsheetException('Unserialize not permitted');
- }
-
- /**
- * Get written data.
- *
- * @return string
- */
- public function getData()
- {
- if ($this->tempFileName == '') {
- return $this->outputMemory(true);
- }
- $this->flush();
-
- return file_get_contents($this->tempFileName) ?: '';
- }
-
- /**
- * Wrapper method for writeRaw.
- *
- * @param null|string|string[] $rawTextData
- *
- * @return bool
- */
- public function writeRawData($rawTextData)
- {
- if (is_array($rawTextData)) {
- $rawTextData = implode("\n", $rawTextData);
- }
-
- return $this->writeRaw(htmlspecialchars($rawTextData ?? ''));
- }
-}
diff --git a/lib/phpspreadsheet/vendor/psr/http-client/CHANGELOG.md b/lib/phpspreadsheet/vendor/psr/http-client/CHANGELOG.md
deleted file mode 100644
index e2dc25f519b..00000000000
--- a/lib/phpspreadsheet/vendor/psr/http-client/CHANGELOG.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# Changelog
-
-All notable changes to this project will be documented in this file, in reverse chronological order by release.
-
-## 1.0.1
-
-Allow installation with PHP 8. No code changes.
-
-## 1.0.0
-
-First stable release. No changes since 0.3.0.
-
-## 0.3.0
-
-Added Interface suffix on exceptions
-
-## 0.2.0
-
-All exceptions are in `Psr\Http\Client` namespace
-
-## 0.1.0
-
-First release
diff --git a/lib/phpspreadsheet/vendor/psr/http-client/README.md b/lib/phpspreadsheet/vendor/psr/http-client/README.md
deleted file mode 100644
index 84af5c55d5f..00000000000
--- a/lib/phpspreadsheet/vendor/psr/http-client/README.md
+++ /dev/null
@@ -1,12 +0,0 @@
-HTTP Client
-===========
-
-This repository holds all the common code related to [PSR-18 (HTTP Client)][psr-url].
-
-Note that this is not a HTTP Client implementation of its own. It is merely abstractions that describe the components of a HTTP Client.
-
-The installable [package][package-url] and [implementations][implementation-url] are listed on Packagist.
-
-[psr-url]: https://www.php-fig.org/psr/psr-18
-[package-url]: https://packagist.org/packages/psr/http-client
-[implementation-url]: https://packagist.org/providers/psr/http-client-implementation
diff --git a/lib/phpspreadsheet/vendor/psr/http-client/composer.json b/lib/phpspreadsheet/vendor/psr/http-client/composer.json
deleted file mode 100644
index e4cab2f3ea4..00000000000
--- a/lib/phpspreadsheet/vendor/psr/http-client/composer.json
+++ /dev/null
@@ -1,27 +0,0 @@
-{
- "name": "psr/http-client",
- "description": "Common interface for HTTP clients",
- "keywords": ["psr", "psr-18", "http", "http-client"],
- "homepage": "https://github.com/php-fig/http-client",
- "license": "MIT",
- "authors": [
- {
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
- }
- ],
- "require": {
- "php": "^7.0 || ^8.0",
- "psr/http-message": "^1.0 || ^2.0"
- },
- "autoload": {
- "psr-4": {
- "Psr\\Http\\Client\\": "src/"
- }
- },
- "extra": {
- "branch-alias": {
- "dev-master": "1.0.x-dev"
- }
- }
-}
diff --git a/lib/phpspreadsheet/vendor/psr/http-client/src/ClientExceptionInterface.php b/lib/phpspreadsheet/vendor/psr/http-client/src/ClientExceptionInterface.php
deleted file mode 100644
index aa0b9cf14ba..00000000000
--- a/lib/phpspreadsheet/vendor/psr/http-client/src/ClientExceptionInterface.php
+++ /dev/null
@@ -1,10 +0,0 @@
- `RequestInterface`, `ServerRequestInterface`, `ResponseInterface` extend `MessageInterface` because the `Request` and the `Response` are `HTTP Messages`.
-> When using `ServerRequestInterface`, both `RequestInterface` and `Psr\Http\Message\MessageInterface` methods are considered.
-
diff --git a/lib/phpspreadsheet/vendor/psr/http-message/docs/PSR7-Usage.md b/lib/phpspreadsheet/vendor/psr/http-message/docs/PSR7-Usage.md
deleted file mode 100644
index b6d048a341e..00000000000
--- a/lib/phpspreadsheet/vendor/psr/http-message/docs/PSR7-Usage.md
+++ /dev/null
@@ -1,159 +0,0 @@
-### PSR-7 Usage
-
-All PSR-7 applications comply with these interfaces
-They were created to establish a standard between middleware implementations.
-
-> `RequestInterface`, `ServerRequestInterface`, `ResponseInterface` extend `MessageInterface` because the `Request` and the `Response` are `HTTP Messages`.
-> When using `ServerRequestInterface`, both `RequestInterface` and `Psr\Http\Message\MessageInterface` methods are considered.
-
-
-The following examples will illustrate how basic operations are done in PSR-7.
-
-##### Examples
-
-
-For this examples to work (at least) a PSR-7 implementation package is required. (eg: zendframework/zend-diactoros, guzzlehttp/psr7, slim/slim, etc)
-All PSR-7 implementations should have the same behaviour.
-
-The following will be assumed:
-`$request` is an object of `Psr\Http\Message\RequestInterface` and
-
-`$response` is an object implementing `Psr\Http\Message\RequestInterface`
-
-
-### Working with HTTP Headers
-
-#### Adding headers to response:
-
-```php
-$response->withHeader('My-Custom-Header', 'My Custom Message');
-```
-
-#### Appending values to headers
-
-```php
-$response->withAddedHeader('My-Custom-Header', 'The second message');
-```
-
-#### Checking if header exists:
-
-```php
-$request->hasHeader('My-Custom-Header'); // will return false
-$response->hasHeader('My-Custom-Header'); // will return true
-```
-
-> Note: My-Custom-Header was only added in the Response
-
-#### Getting comma-separated values from a header (also applies to request)
-
-```php
-// getting value from request headers
-$request->getHeaderLine('Content-Type'); // will return: "text/html; charset=UTF-8"
-// getting value from response headers
-$response->getHeaderLine('My-Custom-Header'); // will return: "My Custom Message; The second message"
-```
-
-#### Getting array of value from a header (also applies to request)
-```php
-// getting value from request headers
-$request->getHeader('Content-Type'); // will return: ["text/html", "charset=UTF-8"]
-// getting value from response headers
-$response->getHeader('My-Custom-Header'); // will return: ["My Custom Message", "The second message"]
-```
-
-#### Removing headers from HTTP Messages
-```php
-// removing a header from Request, removing deprecated "Content-MD5" header
-$request->withoutHeader('Content-MD5');
-
-// removing a header from Response
-// effect: the browser won't know the size of the stream
-// the browser will download the stream till it ends
-$response->withoutHeader('Content-Length');
-```
-
-### Working with HTTP Message Body
-
-When working with the PSR-7 there are two methods of implementation:
-#### 1. Getting the body separately
-
-> This method makes the body handling easier to understand and is useful when repeatedly calling body methods. (You only call `getBody()` once). Using this method mistakes like `$response->write()` are also prevented.
-
-```php
-$body = $response->getBody();
-// operations on body, eg. read, write, seek
-// ...
-// replacing the old body
-$response->withBody($body);
-// this last statement is optional as we working with objects
-// in this case the "new" body is same with the "old" one
-// the $body variable has the same value as the one in $request, only the reference is passed
-```
-
-#### 2. Working directly on response
-
-> This method is useful when only performing few operations as the `$request->getBody()` statement fragment is required
-
-```php
-$response->getBody()->write('hello');
-```
-
-### Getting the body contents
-
-The following snippet gets the contents of a stream contents.
-> Note: Streams must be rewinded, if content was written into streams, it will be ignored when calling `getContents()` because the stream pointer is set to the last character, which is `\0` - meaning end of stream.
-```php
-$body = $response->getBody();
-$body->rewind(); // or $body->seek(0);
-$bodyText = $body->getContents();
-```
-> Note: If `$body->seek(1)` is called before `$body->getContents()`, the first character will be ommited as the starting pointer is set to `1`, not `0`. This is why using `$body->rewind()` is recommended.
-
-### Append to body
-
-```php
-$response->getBody()->write('Hello'); // writing directly
-$body = $request->getBody(); // which is a `StreamInterface`
-$body->write('xxxxx');
-```
-
-### Prepend to body
-Prepending is different when it comes to streams. The content must be copied before writing the content to be prepended.
-The following example will explain the behaviour of streams.
-
-```php
-// assuming our response is initially empty
-$body = $repsonse->getBody();
-// writing the string "abcd"
-$body->write('abcd');
-
-// seeking to start of stream
-$body->seek(0);
-// writing 'ef'
-$body->write('ef'); // at this point the stream contains "efcd"
-```
-
-#### Prepending by rewriting separately
-
-```php
-// assuming our response body stream only contains: "abcd"
-$body = $response->getBody();
-$body->rewind();
-$contents = $body->getContents(); // abcd
-// seeking the stream to beginning
-$body->rewind();
-$body->write('ef'); // stream contains "efcd"
-$body->write($contents); // stream contains "efabcd"
-```
-
-> Note: `getContents()` seeks the stream while reading it, therefore if the second `rewind()` method call was not present the stream would have resulted in `abcdefabcd` because the `write()` method appends to stream if not preceeded by `rewind()` or `seek(0)`.
-
-#### Prepending by using contents as a string
-```php
-$body = $response->getBody();
-$body->rewind();
-$contents = $body->getContents(); // efabcd
-$contents = 'ef'.$contents;
-$body->rewind();
-$body->write($contents);
-```
diff --git a/lib/phpspreadsheet/vendor/psr/http-message/src/MessageInterface.php b/lib/phpspreadsheet/vendor/psr/http-message/src/MessageInterface.php
deleted file mode 100644
index a83c98518d5..00000000000
--- a/lib/phpspreadsheet/vendor/psr/http-message/src/MessageInterface.php
+++ /dev/null
@@ -1,187 +0,0 @@
-getHeaders() as $name => $values) {
- * echo $name . ": " . implode(", ", $values);
- * }
- *
- * // Emit headers iteratively:
- * foreach ($message->getHeaders() as $name => $values) {
- * foreach ($values as $value) {
- * header(sprintf('%s: %s', $name, $value), false);
- * }
- * }
- *
- * While header names are not case-sensitive, getHeaders() will preserve the
- * exact case in which headers were originally specified.
- *
- * @return string[][] Returns an associative array of the message's headers. Each
- * key MUST be a header name, and each value MUST be an array of strings
- * for that header.
- */
- public function getHeaders(): array;
-
- /**
- * Checks if a header exists by the given case-insensitive name.
- *
- * @param string $name Case-insensitive header field name.
- * @return bool Returns true if any header names match the given header
- * name using a case-insensitive string comparison. Returns false if
- * no matching header name is found in the message.
- */
- public function hasHeader(string $name): bool;
-
- /**
- * Retrieves a message header value by the given case-insensitive name.
- *
- * This method returns an array of all the header values of the given
- * case-insensitive header name.
- *
- * If the header does not appear in the message, this method MUST return an
- * empty array.
- *
- * @param string $name Case-insensitive header field name.
- * @return string[] An array of string values as provided for the given
- * header. If the header does not appear in the message, this method MUST
- * return an empty array.
- */
- public function getHeader(string $name): array;
-
- /**
- * Retrieves a comma-separated string of the values for a single header.
- *
- * This method returns all of the header values of the given
- * case-insensitive header name as a string concatenated together using
- * a comma.
- *
- * NOTE: Not all header values may be appropriately represented using
- * comma concatenation. For such headers, use getHeader() instead
- * and supply your own delimiter when concatenating.
- *
- * If the header does not appear in the message, this method MUST return
- * an empty string.
- *
- * @param string $name Case-insensitive header field name.
- * @return string A string of values as provided for the given header
- * concatenated together using a comma. If the header does not appear in
- * the message, this method MUST return an empty string.
- */
- public function getHeaderLine(string $name): string;
-
- /**
- * Return an instance with the provided value replacing the specified header.
- *
- * While header names are case-insensitive, the casing of the header will
- * be preserved by this function, and returned from getHeaders().
- *
- * This method MUST be implemented in such a way as to retain the
- * immutability of the message, and MUST return an instance that has the
- * new and/or updated header and value.
- *
- * @param string $name Case-insensitive header field name.
- * @param string|string[] $value Header value(s).
- * @return static
- * @throws \InvalidArgumentException for invalid header names or values.
- */
- public function withHeader(string $name, $value): MessageInterface;
-
- /**
- * Return an instance with the specified header appended with the given value.
- *
- * Existing values for the specified header will be maintained. The new
- * value(s) will be appended to the existing list. If the header did not
- * exist previously, it will be added.
- *
- * This method MUST be implemented in such a way as to retain the
- * immutability of the message, and MUST return an instance that has the
- * new header and/or value.
- *
- * @param string $name Case-insensitive header field name to add.
- * @param string|string[] $value Header value(s).
- * @return static
- * @throws \InvalidArgumentException for invalid header names or values.
- */
- public function withAddedHeader(string $name, $value): MessageInterface;
-
- /**
- * Return an instance without the specified header.
- *
- * Header resolution MUST be done without case-sensitivity.
- *
- * This method MUST be implemented in such a way as to retain the
- * immutability of the message, and MUST return an instance that removes
- * the named header.
- *
- * @param string $name Case-insensitive header field name to remove.
- * @return static
- */
- public function withoutHeader(string $name): MessageInterface;
-
- /**
- * Gets the body of the message.
- *
- * @return StreamInterface Returns the body as a stream.
- */
- public function getBody(): StreamInterface;
-
- /**
- * Return an instance with the specified message body.
- *
- * The body MUST be a StreamInterface object.
- *
- * This method MUST be implemented in such a way as to retain the
- * immutability of the message, and MUST return a new instance that has the
- * new body stream.
- *
- * @param StreamInterface $body Body.
- * @return static
- * @throws \InvalidArgumentException When the body is not valid.
- */
- public function withBody(StreamInterface $body): MessageInterface;
-}
diff --git a/lib/phpspreadsheet/vendor/psr/http-message/src/RequestInterface.php b/lib/phpspreadsheet/vendor/psr/http-message/src/RequestInterface.php
deleted file mode 100644
index 33f85e559d0..00000000000
--- a/lib/phpspreadsheet/vendor/psr/http-message/src/RequestInterface.php
+++ /dev/null
@@ -1,130 +0,0 @@
-getQuery()`
- * or from the `QUERY_STRING` server param.
- *
- * @return array
- */
- public function getQueryParams(): array;
-
- /**
- * Return an instance with the specified query string arguments.
- *
- * These values SHOULD remain immutable over the course of the incoming
- * request. They MAY be injected during instantiation, such as from PHP's
- * $_GET superglobal, or MAY be derived from some other value such as the
- * URI. In cases where the arguments are parsed from the URI, the data
- * MUST be compatible with what PHP's parse_str() would return for
- * purposes of how duplicate query parameters are handled, and how nested
- * sets are handled.
- *
- * Setting query string arguments MUST NOT change the URI stored by the
- * request, nor the values in the server params.
- *
- * This method MUST be implemented in such a way as to retain the
- * immutability of the message, and MUST return an instance that has the
- * updated query string arguments.
- *
- * @param array $query Array of query string arguments, typically from
- * $_GET.
- * @return static
- */
- public function withQueryParams(array $query): ServerRequestInterface;
-
- /**
- * Retrieve normalized file upload data.
- *
- * This method returns upload metadata in a normalized tree, with each leaf
- * an instance of Psr\Http\Message\UploadedFileInterface.
- *
- * These values MAY be prepared from $_FILES or the message body during
- * instantiation, or MAY be injected via withUploadedFiles().
- *
- * @return array An array tree of UploadedFileInterface instances; an empty
- * array MUST be returned if no data is present.
- */
- public function getUploadedFiles(): array;
-
- /**
- * Create a new instance with the specified uploaded files.
- *
- * This method MUST be implemented in such a way as to retain the
- * immutability of the message, and MUST return an instance that has the
- * updated body parameters.
- *
- * @param array $uploadedFiles An array tree of UploadedFileInterface instances.
- * @return static
- * @throws \InvalidArgumentException if an invalid structure is provided.
- */
- public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface;
-
- /**
- * Retrieve any parameters provided in the request body.
- *
- * If the request Content-Type is either application/x-www-form-urlencoded
- * or multipart/form-data, and the request method is POST, this method MUST
- * return the contents of $_POST.
- *
- * Otherwise, this method may return any results of deserializing
- * the request body content; as parsing returns structured content, the
- * potential types MUST be arrays or objects only. A null value indicates
- * the absence of body content.
- *
- * @return null|array|object The deserialized body parameters, if any.
- * These will typically be an array or object.
- */
- public function getParsedBody();
-
- /**
- * Return an instance with the specified body parameters.
- *
- * These MAY be injected during instantiation.
- *
- * If the request Content-Type is either application/x-www-form-urlencoded
- * or multipart/form-data, and the request method is POST, use this method
- * ONLY to inject the contents of $_POST.
- *
- * The data IS NOT REQUIRED to come from $_POST, but MUST be the results of
- * deserializing the request body content. Deserialization/parsing returns
- * structured data, and, as such, this method ONLY accepts arrays or objects,
- * or a null value if nothing was available to parse.
- *
- * As an example, if content negotiation determines that the request data
- * is a JSON payload, this method could be used to create a request
- * instance with the deserialized parameters.
- *
- * This method MUST be implemented in such a way as to retain the
- * immutability of the message, and MUST return an instance that has the
- * updated body parameters.
- *
- * @param null|array|object $data The deserialized body data. This will
- * typically be in an array or object.
- * @return static
- * @throws \InvalidArgumentException if an unsupported argument type is
- * provided.
- */
- public function withParsedBody($data): ServerRequestInterface;
-
- /**
- * Retrieve attributes derived from the request.
- *
- * The request "attributes" may be used to allow injection of any
- * parameters derived from the request: e.g., the results of path
- * match operations; the results of decrypting cookies; the results of
- * deserializing non-form-encoded message bodies; etc. Attributes
- * will be application and request specific, and CAN be mutable.
- *
- * @return array Attributes derived from the request.
- */
- public function getAttributes(): array;
-
- /**
- * Retrieve a single derived request attribute.
- *
- * Retrieves a single derived request attribute as described in
- * getAttributes(). If the attribute has not been previously set, returns
- * the default value as provided.
- *
- * This method obviates the need for a hasAttribute() method, as it allows
- * specifying a default value to return if the attribute is not found.
- *
- * @see getAttributes()
- * @param string $name The attribute name.
- * @param mixed $default Default value to return if the attribute does not exist.
- * @return mixed
- */
- public function getAttribute(string $name, $default = null);
-
- /**
- * Return an instance with the specified derived request attribute.
- *
- * This method allows setting a single derived request attribute as
- * described in getAttributes().
- *
- * This method MUST be implemented in such a way as to retain the
- * immutability of the message, and MUST return an instance that has the
- * updated attribute.
- *
- * @see getAttributes()
- * @param string $name The attribute name.
- * @param mixed $value The value of the attribute.
- * @return static
- */
- public function withAttribute(string $name, $value): ServerRequestInterface;
-
- /**
- * Return an instance that removes the specified derived request attribute.
- *
- * This method allows removing a single derived request attribute as
- * described in getAttributes().
- *
- * This method MUST be implemented in such a way as to retain the
- * immutability of the message, and MUST return an instance that removes
- * the attribute.
- *
- * @see getAttributes()
- * @param string $name The attribute name.
- * @return static
- */
- public function withoutAttribute(string $name): ServerRequestInterface;
-}
diff --git a/lib/phpspreadsheet/vendor/psr/http-message/src/StreamInterface.php b/lib/phpspreadsheet/vendor/psr/http-message/src/StreamInterface.php
deleted file mode 100644
index a62aabb8288..00000000000
--- a/lib/phpspreadsheet/vendor/psr/http-message/src/StreamInterface.php
+++ /dev/null
@@ -1,158 +0,0 @@
-
- * [user-info@]host[:port]
- *
- *
- * If the port component is not set or is the standard port for the current
- * scheme, it SHOULD NOT be included.
- *
- * @see https://tools.ietf.org/html/rfc3986#section-3.2
- * @return string The URI authority, in "[user-info@]host[:port]" format.
- */
- public function getAuthority(): string;
-
- /**
- * Retrieve the user information component of the URI.
- *
- * If no user information is present, this method MUST return an empty
- * string.
- *
- * If a user is present in the URI, this will return that value;
- * additionally, if the password is also present, it will be appended to the
- * user value, with a colon (":") separating the values.
- *
- * The trailing "@" character is not part of the user information and MUST
- * NOT be added.
- *
- * @return string The URI user information, in "username[:password]" format.
- */
- public function getUserInfo(): string;
-
- /**
- * Retrieve the host component of the URI.
- *
- * If no host is present, this method MUST return an empty string.
- *
- * The value returned MUST be normalized to lowercase, per RFC 3986
- * Section 3.2.2.
- *
- * @see http://tools.ietf.org/html/rfc3986#section-3.2.2
- * @return string The URI host.
- */
- public function getHost(): string;
-
- /**
- * Retrieve the port component of the URI.
- *
- * If a port is present, and it is non-standard for the current scheme,
- * this method MUST return it as an integer. If the port is the standard port
- * used with the current scheme, this method SHOULD return null.
- *
- * If no port is present, and no scheme is present, this method MUST return
- * a null value.
- *
- * If no port is present, but a scheme is present, this method MAY return
- * the standard port for that scheme, but SHOULD return null.
- *
- * @return null|int The URI port.
- */
- public function getPort(): ?int;
-
- /**
- * Retrieve the path component of the URI.
- *
- * The path can either be empty or absolute (starting with a slash) or
- * rootless (not starting with a slash). Implementations MUST support all
- * three syntaxes.
- *
- * Normally, the empty path "" and absolute path "/" are considered equal as
- * defined in RFC 7230 Section 2.7.3. But this method MUST NOT automatically
- * do this normalization because in contexts with a trimmed base path, e.g.
- * the front controller, this difference becomes significant. It's the task
- * of the user to handle both "" and "/".
- *
- * The value returned MUST be percent-encoded, but MUST NOT double-encode
- * any characters. To determine what characters to encode, please refer to
- * RFC 3986, Sections 2 and 3.3.
- *
- * As an example, if the value should include a slash ("/") not intended as
- * delimiter between path segments, that value MUST be passed in encoded
- * form (e.g., "%2F") to the instance.
- *
- * @see https://tools.ietf.org/html/rfc3986#section-2
- * @see https://tools.ietf.org/html/rfc3986#section-3.3
- * @return string The URI path.
- */
- public function getPath(): string;
-
- /**
- * Retrieve the query string of the URI.
- *
- * If no query string is present, this method MUST return an empty string.
- *
- * The leading "?" character is not part of the query and MUST NOT be
- * added.
- *
- * The value returned MUST be percent-encoded, but MUST NOT double-encode
- * any characters. To determine what characters to encode, please refer to
- * RFC 3986, Sections 2 and 3.4.
- *
- * As an example, if a value in a key/value pair of the query string should
- * include an ampersand ("&") not intended as a delimiter between values,
- * that value MUST be passed in encoded form (e.g., "%26") to the instance.
- *
- * @see https://tools.ietf.org/html/rfc3986#section-2
- * @see https://tools.ietf.org/html/rfc3986#section-3.4
- * @return string The URI query string.
- */
- public function getQuery(): string;
-
- /**
- * Retrieve the fragment component of the URI.
- *
- * If no fragment is present, this method MUST return an empty string.
- *
- * The leading "#" character is not part of the fragment and MUST NOT be
- * added.
- *
- * The value returned MUST be percent-encoded, but MUST NOT double-encode
- * any characters. To determine what characters to encode, please refer to
- * RFC 3986, Sections 2 and 3.5.
- *
- * @see https://tools.ietf.org/html/rfc3986#section-2
- * @see https://tools.ietf.org/html/rfc3986#section-3.5
- * @return string The URI fragment.
- */
- public function getFragment(): string;
-
- /**
- * Return an instance with the specified scheme.
- *
- * This method MUST retain the state of the current instance, and return
- * an instance that contains the specified scheme.
- *
- * Implementations MUST support the schemes "http" and "https" case
- * insensitively, and MAY accommodate other schemes if required.
- *
- * An empty scheme is equivalent to removing the scheme.
- *
- * @param string $scheme The scheme to use with the new instance.
- * @return static A new instance with the specified scheme.
- * @throws \InvalidArgumentException for invalid or unsupported schemes.
- */
- public function withScheme(string $scheme): UriInterface;
-
- /**
- * Return an instance with the specified user information.
- *
- * This method MUST retain the state of the current instance, and return
- * an instance that contains the specified user information.
- *
- * Password is optional, but the user information MUST include the
- * user; an empty string for the user is equivalent to removing user
- * information.
- *
- * @param string $user The user name to use for authority.
- * @param null|string $password The password associated with $user.
- * @return static A new instance with the specified user information.
- */
- public function withUserInfo(string $user, ?string $password = null): UriInterface;
-
- /**
- * Return an instance with the specified host.
- *
- * This method MUST retain the state of the current instance, and return
- * an instance that contains the specified host.
- *
- * An empty host value is equivalent to removing the host.
- *
- * @param string $host The hostname to use with the new instance.
- * @return static A new instance with the specified host.
- * @throws \InvalidArgumentException for invalid hostnames.
- */
- public function withHost(string $host): UriInterface;
-
- /**
- * Return an instance with the specified port.
- *
- * This method MUST retain the state of the current instance, and return
- * an instance that contains the specified port.
- *
- * Implementations MUST raise an exception for ports outside the
- * established TCP and UDP port ranges.
- *
- * A null value provided for the port is equivalent to removing the port
- * information.
- *
- * @param null|int $port The port to use with the new instance; a null value
- * removes the port information.
- * @return static A new instance with the specified port.
- * @throws \InvalidArgumentException for invalid ports.
- */
- public function withPort(?int $port): UriInterface;
-
- /**
- * Return an instance with the specified path.
- *
- * This method MUST retain the state of the current instance, and return
- * an instance that contains the specified path.
- *
- * The path can either be empty or absolute (starting with a slash) or
- * rootless (not starting with a slash). Implementations MUST support all
- * three syntaxes.
- *
- * If the path is intended to be domain-relative rather than path relative then
- * it must begin with a slash ("/"). Paths not starting with a slash ("/")
- * are assumed to be relative to some base path known to the application or
- * consumer.
- *
- * Users can provide both encoded and decoded path characters.
- * Implementations ensure the correct encoding as outlined in getPath().
- *
- * @param string $path The path to use with the new instance.
- * @return static A new instance with the specified path.
- * @throws \InvalidArgumentException for invalid paths.
- */
- public function withPath(string $path): UriInterface;
-
- /**
- * Return an instance with the specified query string.
- *
- * This method MUST retain the state of the current instance, and return
- * an instance that contains the specified query string.
- *
- * Users can provide both encoded and decoded query characters.
- * Implementations ensure the correct encoding as outlined in getQuery().
- *
- * An empty query string value is equivalent to removing the query string.
- *
- * @param string $query The query string to use with the new instance.
- * @return static A new instance with the specified query string.
- * @throws \InvalidArgumentException for invalid query strings.
- */
- public function withQuery(string $query): UriInterface;
-
- /**
- * Return an instance with the specified URI fragment.
- *
- * This method MUST retain the state of the current instance, and return
- * an instance that contains the specified URI fragment.
- *
- * Users can provide both encoded and decoded fragment characters.
- * Implementations ensure the correct encoding as outlined in getFragment().
- *
- * An empty fragment value is equivalent to removing the fragment.
- *
- * @param string $fragment The fragment to use with the new instance.
- * @return static A new instance with the specified fragment.
- */
- public function withFragment(string $fragment): UriInterface;
-
- /**
- * Return the string representation as a URI reference.
- *
- * Depending on which components of the URI are present, the resulting
- * string is either a full URI or relative reference according to RFC 3986,
- * Section 4.1. The method concatenates the various components of the URI,
- * using the appropriate delimiters:
- *
- * - If a scheme is present, it MUST be suffixed by ":".
- * - If an authority is present, it MUST be prefixed by "//".
- * - The path can be concatenated without delimiters. But there are two
- * cases where the path has to be adjusted to make the URI reference
- * valid as PHP does not allow to throw an exception in __toString():
- * - If the path is rootless and an authority is present, the path MUST
- * be prefixed by "/".
- * - If the path is starting with more than one "/" and no authority is
- * present, the starting slashes MUST be reduced to one.
- * - If a query is present, it MUST be prefixed by "?".
- * - If a fragment is present, it MUST be prefixed by "#".
- *
- * @see http://tools.ietf.org/html/rfc3986#section-4.1
- * @return string
- */
- public function __toString(): string;
-}
diff --git a/lib/phpspreadsheet/vendor/psr/http-factory/LICENSE b/lib/psr/http-server-handler/LICENSE
similarity index 92%
rename from lib/phpspreadsheet/vendor/psr/http-factory/LICENSE
rename to lib/psr/http-server-handler/LICENSE
index 3f1559b2ad6..b71ec5dfc20 100644
--- a/lib/phpspreadsheet/vendor/psr/http-factory/LICENSE
+++ b/lib/psr/http-server-handler/LICENSE
@@ -1,6 +1,6 @@
-MIT License
+The MIT License (MIT)
-Copyright (c) 2018 PHP-FIG
+Copyright (c) 2016 PHP Framework Interoperability Group
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/lib/psr/http-server-handler/README.md b/lib/psr/http-server-handler/README.md
new file mode 100644
index 00000000000..111a9ed2e8b
--- /dev/null
+++ b/lib/psr/http-server-handler/README.md
@@ -0,0 +1,12 @@
+HTTP Server Request Handlers for Middleware
+===========================================
+
+This repository holds the `RequestHandlerInterface` related to [PSR-15 (HTTP Server Request Handlers)][psr-url].
+
+Note that this is not a Server Request Handler implementation of its own. It is merely the interface that describe a Server Request Handler.
+
+The installable [package][package-url] and [implementations][implementation-url] are listed on Packagist.
+
+[psr-url]: https://www.php-fig.org/psr/psr-15/
+[package-url]: https://packagist.org/packages/psr/http-server-handler
+[implementation-url]: https://packagist.org/providers/psr/http-server-handler-implementation
diff --git a/lib/phpspreadsheet/vendor/psr/http-factory/composer.json b/lib/psr/http-server-handler/composer.json
similarity index 64%
rename from lib/phpspreadsheet/vendor/psr/http-factory/composer.json
rename to lib/psr/http-server-handler/composer.json
index d1bbddeea36..c54b2dee822 100644
--- a/lib/phpspreadsheet/vendor/psr/http-factory/composer.json
+++ b/lib/psr/http-server-handler/composer.json
@@ -1,13 +1,14 @@
{
- "name": "psr/http-factory",
- "description": "Common interfaces for PSR-7 HTTP message factories",
+ "name": "psr/http-server-handler",
+ "description": "Common interface for HTTP server-side request handler",
"keywords": [
"psr",
"psr-7",
- "psr-17",
+ "psr-15",
+ "http-interop",
"http",
- "factory",
- "message",
+ "server",
+ "handler",
"request",
"response"
],
@@ -19,12 +20,12 @@
}
],
"require": {
- "php": ">=7.0.0",
+ "php": ">=7.0",
"psr/http-message": "^1.0 || ^2.0"
},
"autoload": {
"psr-4": {
- "Psr\\Http\\Message\\": "src/"
+ "Psr\\Http\\Server\\": "src/"
}
},
"extra": {
diff --git a/lib/psr/http-server-handler/src/RequestHandlerInterface.php b/lib/psr/http-server-handler/src/RequestHandlerInterface.php
new file mode 100644
index 00000000000..83911e265b8
--- /dev/null
+++ b/lib/psr/http-server-handler/src/RequestHandlerInterface.php
@@ -0,0 +1,22 @@
+=7.0",
+ "psr/http-message": "^1.0 || ^2.0",
+ "psr/http-server-handler": "^1.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Server\\": "src/"
+ }
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ }
+}
diff --git a/lib/psr/http-server-middleware/src/MiddlewareInterface.php b/lib/psr/http-server-middleware/src/MiddlewareInterface.php
new file mode 100644
index 00000000000..a6c14f8c729
--- /dev/null
+++ b/lib/psr/http-server-middleware/src/MiddlewareInterface.php
@@ -0,0 +1,25 @@
+logger = $logger;
+ }
+
+ public function doSomething()
+ {
+ if ($this->logger) {
+ $this->logger->info('Doing work');
+ }
+
+ try {
+ $this->doSomethingElse();
+ } catch (Exception $exception) {
+ $this->logger->error('Oh no!', array('exception' => $exception));
+ }
+
+ // do something useful
+ }
+}
+```
+
+You can then pick one of the implementations of the interface to get a logger.
+
+If you want to implement the interface, you can require this package and
+implement `Psr\Log\LoggerInterface` in your code. Please read the
+[specification text](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md)
+for details.
diff --git a/lib/psr/log/composer.json b/lib/psr/log/composer.json
new file mode 100644
index 00000000000..879fc6f53bb
--- /dev/null
+++ b/lib/psr/log/composer.json
@@ -0,0 +1,26 @@
+{
+ "name": "psr/log",
+ "description": "Common interface for logging libraries",
+ "keywords": ["psr", "psr-3", "log"],
+ "homepage": "https://github.com/php-fig/log",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Log\\": "src"
+ }
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ }
+}
diff --git a/lib/psr/log/src/AbstractLogger.php b/lib/psr/log/src/AbstractLogger.php
new file mode 100644
index 00000000000..d60a091affa
--- /dev/null
+++ b/lib/psr/log/src/AbstractLogger.php
@@ -0,0 +1,15 @@
+logger = $logger;
+ }
+}
diff --git a/lib/psr/log/src/LoggerInterface.php b/lib/psr/log/src/LoggerInterface.php
new file mode 100644
index 00000000000..b3a24b5f7e9
--- /dev/null
+++ b/lib/psr/log/src/LoggerInterface.php
@@ -0,0 +1,125 @@
+log(LogLevel::EMERGENCY, $message, $context);
+ }
+
+ /**
+ * Action must be taken immediately.
+ *
+ * Example: Entire website down, database unavailable, etc. This should
+ * trigger the SMS alerts and wake you up.
+ *
+ * @param string|\Stringable $message
+ * @param array $context
+ *
+ * @return void
+ */
+ public function alert(string|\Stringable $message, array $context = []): void
+ {
+ $this->log(LogLevel::ALERT, $message, $context);
+ }
+
+ /**
+ * Critical conditions.
+ *
+ * Example: Application component unavailable, unexpected exception.
+ *
+ * @param string|\Stringable $message
+ * @param array $context
+ *
+ * @return void
+ */
+ public function critical(string|\Stringable $message, array $context = []): void
+ {
+ $this->log(LogLevel::CRITICAL, $message, $context);
+ }
+
+ /**
+ * Runtime errors that do not require immediate action but should typically
+ * be logged and monitored.
+ *
+ * @param string|\Stringable $message
+ * @param array $context
+ *
+ * @return void
+ */
+ public function error(string|\Stringable $message, array $context = []): void
+ {
+ $this->log(LogLevel::ERROR, $message, $context);
+ }
+
+ /**
+ * Exceptional occurrences that are not errors.
+ *
+ * Example: Use of deprecated APIs, poor use of an API, undesirable things
+ * that are not necessarily wrong.
+ *
+ * @param string|\Stringable $message
+ * @param array $context
+ *
+ * @return void
+ */
+ public function warning(string|\Stringable $message, array $context = []): void
+ {
+ $this->log(LogLevel::WARNING, $message, $context);
+ }
+
+ /**
+ * Normal but significant events.
+ *
+ * @param string|\Stringable $message
+ * @param array $context
+ *
+ * @return void
+ */
+ public function notice(string|\Stringable $message, array $context = []): void
+ {
+ $this->log(LogLevel::NOTICE, $message, $context);
+ }
+
+ /**
+ * Interesting events.
+ *
+ * Example: User logs in, SQL logs.
+ *
+ * @param string|\Stringable $message
+ * @param array $context
+ *
+ * @return void
+ */
+ public function info(string|\Stringable $message, array $context = []): void
+ {
+ $this->log(LogLevel::INFO, $message, $context);
+ }
+
+ /**
+ * Detailed debug information.
+ *
+ * @param string|\Stringable $message
+ * @param array $context
+ *
+ * @return void
+ */
+ public function debug(string|\Stringable $message, array $context = []): void
+ {
+ $this->log(LogLevel::DEBUG, $message, $context);
+ }
+
+ /**
+ * Logs with an arbitrary level.
+ *
+ * @param mixed $level
+ * @param string|\Stringable $message
+ * @param array $context
+ *
+ * @return void
+ *
+ * @throws \Psr\Log\InvalidArgumentException
+ */
+ abstract public function log($level, string|\Stringable $message, array $context = []): void;
+}
diff --git a/lib/psr/log/src/NullLogger.php b/lib/psr/log/src/NullLogger.php
new file mode 100644
index 00000000000..c1cc3c0692a
--- /dev/null
+++ b/lib/psr/log/src/NullLogger.php
@@ -0,0 +1,30 @@
+logger) { }`
+ * blocks.
+ */
+class NullLogger extends AbstractLogger
+{
+ /**
+ * Logs with an arbitrary level.
+ *
+ * @param mixed $level
+ * @param string|\Stringable $message
+ * @param array $context
+ *
+ * @return void
+ *
+ * @throws \Psr\Log\InvalidArgumentException
+ */
+ public function log($level, string|\Stringable $message, array $context = []): void
+ {
+ // noop
+ }
+}
diff --git a/lib/phpspreadsheet/vendor/psr/simple-cache/LICENSE.md b/lib/psr/simple-cache/LICENSE.md
similarity index 100%
rename from lib/phpspreadsheet/vendor/psr/simple-cache/LICENSE.md
rename to lib/psr/simple-cache/LICENSE.md
diff --git a/lib/phpspreadsheet/vendor/psr/simple-cache/README.md b/lib/psr/simple-cache/README.md
similarity index 100%
rename from lib/phpspreadsheet/vendor/psr/simple-cache/README.md
rename to lib/psr/simple-cache/README.md
diff --git a/lib/phpspreadsheet/vendor/psr/simple-cache/composer.json b/lib/psr/simple-cache/composer.json
similarity index 100%
rename from lib/phpspreadsheet/vendor/psr/simple-cache/composer.json
rename to lib/psr/simple-cache/composer.json
diff --git a/lib/psr/simple-cache/readme_moodle.txt b/lib/psr/simple-cache/readme_moodle.txt
new file mode 100644
index 00000000000..71603d33d01
--- /dev/null
+++ b/lib/psr/simple-cache/readme_moodle.txt
@@ -0,0 +1,10 @@
+# PSR-16 Simple Cache
+
+This is a description for including the PSR-16 Interfaces in Moodle
+
+## Installation
+
+1. Visit https://github.com/php-fig/simple-cache
+2. Download the latest release
+3. Unzip in this folder
+4. Update `thirdpartylibs.xml`
diff --git a/lib/phpspreadsheet/vendor/psr/simple-cache/src/CacheException.php b/lib/psr/simple-cache/src/CacheException.php
similarity index 100%
rename from lib/phpspreadsheet/vendor/psr/simple-cache/src/CacheException.php
rename to lib/psr/simple-cache/src/CacheException.php
diff --git a/lib/phpspreadsheet/vendor/psr/simple-cache/src/CacheInterface.php b/lib/psr/simple-cache/src/CacheInterface.php
similarity index 100%
rename from lib/phpspreadsheet/vendor/psr/simple-cache/src/CacheInterface.php
rename to lib/psr/simple-cache/src/CacheInterface.php
diff --git a/lib/phpspreadsheet/vendor/psr/simple-cache/src/InvalidArgumentException.php b/lib/psr/simple-cache/src/InvalidArgumentException.php
similarity index 100%
rename from lib/phpspreadsheet/vendor/psr/simple-cache/src/InvalidArgumentException.php
rename to lib/psr/simple-cache/src/InvalidArgumentException.php
diff --git a/lib/setup.php b/lib/setup.php
index 14218f96137..1fb63fc3e6f 100644
--- a/lib/setup.php
+++ b/lib/setup.php
@@ -196,6 +196,9 @@ if (!isset($CFG->wwwroot) or $CFG->wwwroot === 'http://example.com/moodle') {
exit(1);
}
+// The router configuration is mandatory.
+$CFG->routerconfigured = !empty($CFG->routerconfigured);
+
// Make sure there is some database table prefix.
if (!isset($CFG->prefix)) {
$CFG->prefix = '';
diff --git a/lib/setuplib.php b/lib/setuplib.php
index d0ecf7aae10..a67355e3a56 100644
--- a/lib/setuplib.php
+++ b/lib/setuplib.php
@@ -130,10 +130,10 @@ function get_whoops(): ?\Whoops\Run {
/**
* Default exception handler.
*
- * @param Exception $ex
+ * @param Throwable $ex
* @return void -does not return. Terminates execution!
*/
-function default_exception_handler($ex) {
+function default_exception_handler(Throwable $ex): void {
global $CFG, $DB, $OUTPUT, $USER, $FULLME, $SESSION, $PAGE;
// detect active db transactions, rollback and log as error
diff --git a/lib/slim/readme_moodle.txt b/lib/slim/readme_moodle.txt
new file mode 100644
index 00000000000..5547f12a635
--- /dev/null
+++ b/lib/slim/readme_moodle.txt
@@ -0,0 +1,5 @@
+Instructions to import/update the Slim Framework Moodle:
+
+1. Update any dependencies, including Guzzle
+2. Download the latest release from https://github.com/slimphp/Slim
+3. Replace the files in slim/* with those in the zip
diff --git a/lib/slim/slim/CHANGELOG.md b/lib/slim/slim/CHANGELOG.md
new file mode 100644
index 00000000000..6bcb127dd7e
--- /dev/null
+++ b/lib/slim/slim/CHANGELOG.md
@@ -0,0 +1,237 @@
+# Changelog
+
+# 4.11.0 - 2022-11-06
+- [3180: Declare types](https://github.com/slimphp/Slim/pull/3180) thanks to @nbayramberdiyev
+- [3181: Update laminas/laminas-diactoros requirement from ^2.8 to ^2.9](https://github.com/slimphp/Slim/pull/3181) thanks to @dependabot[bot]
+- [3182: Update guzzlehttp/psr7 requirement from ^2.1 to ^2.2](https://github.com/slimphp/Slim/pull/3182) thanks to @dependabot[bot]
+- [3183: Update phpstan/phpstan requirement from ^1.4 to ^1.5](https://github.com/slimphp/Slim/pull/3183) thanks to @dependabot[bot]
+- [3184: Update adriansuter/php-autoload-override requirement from ^1.2 to ^1.3](https://github.com/slimphp/Slim/pull/3184) thanks to @dependabot[bot]
+- [3189: Update phpstan/phpstan requirement from ^1.5 to ^1.6](https://github.com/slimphp/Slim/pull/3189) thanks to @dependabot[bot]
+- [3191: Adding property types to Middleware classes](https://github.com/slimphp/Slim/pull/3191) thanks to @ashleycoles
+- [3193: Handlers types](https://github.com/slimphp/Slim/pull/3193) thanks to @ashleycoles
+- [3194: Adding types to AbstractErrorRenderer](https://github.com/slimphp/Slim/pull/3194) thanks to @ashleycoles
+- [3195: Adding prop types for Exception classes](https://github.com/slimphp/Slim/pull/3195) thanks to @ashleycoles
+- [3196: Adding property type declarations for Factory classes](https://github.com/slimphp/Slim/pull/3196) thanks to @ashleycoles
+- [3197: Remove redundant docblock types](https://github.com/slimphp/Slim/pull/3197) thanks to @theodorejb
+- [3199: Update laminas/laminas-diactoros requirement from ^2.9 to ^2.11](https://github.com/slimphp/Slim/pull/3199) thanks to @dependabot[bot]
+- [3200: Update phpstan/phpstan requirement from ^1.6 to ^1.7](https://github.com/slimphp/Slim/pull/3200) thanks to @dependabot[bot]
+- [3205: Update guzzlehttp/psr7 requirement from ^2.2 to ^2.4](https://github.com/slimphp/Slim/pull/3205) thanks to @dependabot[bot]
+- [3206: Update squizlabs/php_codesniffer requirement from ^3.6 to ^3.7](https://github.com/slimphp/Slim/pull/3206) thanks to @dependabot[bot]
+- [3207: Update phpstan/phpstan requirement from ^1.7 to ^1.8](https://github.com/slimphp/Slim/pull/3207) thanks to @dependabot[bot]
+- [3211: Assign null coalescing to coalesce equal](https://github.com/slimphp/Slim/pull/3211) thanks to @MathiasReker
+- [3213: Void return](https://github.com/slimphp/Slim/pull/3213) thanks to @MathiasReker
+- [3214: Is null](https://github.com/slimphp/Slim/pull/3214) thanks to @MathiasReker
+- [3216: Refactor](https://github.com/slimphp/Slim/pull/3216) thanks to @mehdihasanpour
+- [3218: Refactor some code](https://github.com/slimphp/Slim/pull/3218) thanks to @mehdihasanpour
+- [3221: Cleanup](https://github.com/slimphp/Slim/pull/3221) thanks to @mehdihasanpour
+- [3225: Update laminas/laminas-diactoros requirement from ^2.11 to ^2.14](https://github.com/slimphp/Slim/pull/3225) thanks to @dependabot[bot]
+- [3228: Using assertSame to let assert equal be restricted](https://github.com/slimphp/Slim/pull/3228) thanks to @peter279k
+- [3229: Update laminas/laminas-diactoros requirement from ^2.14 to ^2.17](https://github.com/slimphp/Slim/pull/3229) thanks to @dependabot[bot]
+- [3235: Persist routes indexed by name in RouteCollector for improved performance.](https://github.com/slimphp/Slim/pull/3235) thanks to @BusterNeece
+
+# 4.10.0 - 2022-03-14
+- [3120: Add a new PSR-17 factory to Psr17FactoryProvider](https://github.com/slimphp/Slim/pull/3120) thanks to @solventt
+- [3123: Replace deprecated setMethods() in tests](https://github.com/slimphp/Slim/pull/3123) thanks to @solventt
+- [3126: Update guzzlehttp/psr7 requirement from ^2.0 to ^2.1](https://github.com/slimphp/Slim/pull/3126) thanks to @dependabot[bot]
+- [3127: PHPStan v1.0](https://github.com/slimphp/Slim/pull/3127) thanks to @t0mmy742
+- [3128: Update phpstan/phpstan requirement from ^1.0 to ^1.2](https://github.com/slimphp/Slim/pull/3128) thanks to @dependabot[bot]
+- [3129: Deprecate PHP 7.3](https://github.com/slimphp/Slim/pull/3129) thanks to @l0gicgate
+- [3130: Removed double defined PHP 7.4](https://github.com/slimphp/Slim/pull/3130) thanks to @flangofas
+- [3132: Add new `RequestResponseNamedArgs` route strategy](https://github.com/slimphp/Slim/pull/3132) thanks to @adoy
+- [3133: Improve typehinting for `RouteParserInterface`](https://github.com/slimphp/Slim/pull/3133) thanks to @jerowork
+- [3135: Update phpstan/phpstan requirement from ^1.2 to ^1.3](https://github.com/slimphp/Slim/pull/3135) thanks to @dependabot[bot]
+- [3137: Update phpspec/prophecy requirement from ^1.14 to ^1.15](https://github.com/slimphp/Slim/pull/3137) thanks to @dependabot[bot]
+- [3138: Update license year](https://github.com/slimphp/Slim/pull/3138) thanks to @Awilum
+- [3139: Fixed #1730 (reintroduced in 4.x)](https://github.com/slimphp/Slim/pull/3139) thanks to @adoy
+- [3145: Update phpstan/phpstan requirement from ^1.3 to ^1.4](https://github.com/slimphp/Slim/pull/3145) thanks to @dependabot[bot]
+- [3146: Inherit HttpException from RuntimeException](https://github.com/slimphp/Slim/pull/3146) thanks to @nbayramberdiyev
+- [3148: Upgrade to HTML5](https://github.com/slimphp/Slim/pull/3148) thanks to @nbayramberdiyev
+- [3172: Update nyholm/psr7 requirement from ^1.4 to ^1.5](https://github.com/slimphp/Slim/pull/3172) thanks to @dependabot[bot]
+
+# 4.9.0 - 2021-10-05
+- [3058: Implement exception class for Gone Http error](https://github.com/slimphp/Slim/pull/3058) thanks to @TheKernelPanic
+- [3086: Update slim/psr7 requirement from ^1.3 to ^1.4](https://github.com/slimphp/Slim/pull/3086) thanks to @dependabot[bot]
+- [3087: Update nyholm/psr7-server requirement from ^1.0.1 to ^1.0.2](https://github.com/slimphp/Slim/pull/3087) thanks to @dependabot[bot]
+- [3093: Update phpstan/phpstan requirement from ^0.12.85 to ^0.12.90](https://github.com/slimphp/Slim/pull/3093) thanks to @dependabot[bot]
+- [3099: Allow updated psr log](https://github.com/slimphp/Slim/pull/3099) thanks to @t0mmy742
+- [3104: Drop php7.2](https://github.com/slimphp/Slim/pull/3104) thanks to @t0mmy742
+- [3106: Use PSR-17 factory from Guzzle/psr7 2.0](https://github.com/slimphp/Slim/pull/3106) thanks to @t0mmy742
+- [3108: Update README file](https://github.com/slimphp/Slim/pull/3108) thanks to @t0mmy742
+- [3112: Update laminas/laminas-diactoros requirement from ^2.6 to ^2.8](https://github.com/slimphp/Slim/pull/3112) thanks to @dependabot[bot]
+- [3114: Update slim/psr7 requirement from ^1.4 to ^1.5](https://github.com/slimphp/Slim/pull/3114) thanks to @dependabot[bot]
+- [3115: Update phpstan/phpstan requirement from ^0.12.96 to ^0.12.99](https://github.com/slimphp/Slim/pull/3115) thanks to @dependabot[bot]
+- [3116: Remove Zend Diactoros references](https://github.com/slimphp/Slim/pull/3116) thanks to @l0gicgate
+
+# 4.8.0 - 2021-05-19
+- [3034: Fix phpunit dependency version](https://github.com/slimphp/Slim/pull/3034) thanks to @l0gicgate
+- [3037: Replace Travis by GitHub Actions](https://github.com/slimphp/Slim/pull/3037) thanks to @t0mmy742
+- [3043: Cover App creation from AppFactory with empty Container](https://github.com/slimphp/Slim/pull/3043) thanks to @t0mmy742
+- [3045: Update phpstan/phpstan requirement from ^0.12.58 to ^0.12.64](https://github.com/slimphp/Slim/pull/3045) thanks to @dependabot-preview[bot]
+- [3047: documentation: min php 7.2 required](https://github.com/slimphp/Slim/pull/3047) thanks to @Rotzbua
+- [3054: Update phpstan/phpstan requirement from ^0.12.64 to ^0.12.70](https://github.com/slimphp/Slim/pull/3054) thanks to @dependabot-preview[bot]
+- [3056: Fix docblock in ErrorMiddleware](https://github.com/slimphp/Slim/pull/3056) thanks to @piotr-cz
+- [3060: Update phpstan/phpstan requirement from ^0.12.70 to ^0.12.80](https://github.com/slimphp/Slim/pull/3060) thanks to @dependabot-preview[bot]
+- [3061: Update nyholm/psr7 requirement from ^1.3 to ^1.4](https://github.com/slimphp/Slim/pull/3061) thanks to @dependabot-preview[bot]
+- [3063: Allow ^1.0 || ^2.0 in psr/container](https://github.com/slimphp/Slim/pull/3063) thanks to @Ayesh
+- [3069: Classname/Method Callable Arrays](https://github.com/slimphp/Slim/pull/3069) thanks to @ddrv
+- [3078: Update squizlabs/php_codesniffer requirement from ^3.5 to ^3.6](https://github.com/slimphp/Slim/pull/3078) thanks to @dependabot[bot]
+- [3079: Update phpspec/prophecy requirement from ^1.12 to ^1.13](https://github.com/slimphp/Slim/pull/3079) thanks to @dependabot[bot]
+- [3080: Update guzzlehttp/psr7 requirement from ^1.7 to ^1.8](https://github.com/slimphp/Slim/pull/3080) thanks to @dependabot[bot]
+- [3082: Update phpstan/phpstan requirement from ^0.12.80 to ^0.12.85](https://github.com/slimphp/Slim/pull/3082) thanks to @dependabot[bot]
+
+# 4.7.0 - 2020-11-30
+
+### Fixed
+- [3027: Fix: FastRoute dispatcher and data generator should match](https://github.com/slimphp/Slim/pull/3027) thanks to @edudobay
+
+### Added
+- [3015: PHP 8 support](https://github.com/slimphp/Slim/pull/3015) thanks to @edudobay
+
+### Optimizations
+- [3024: Randomize tests](https://github.com/slimphp/Slim/pull/3024) thanks to @pawel-slowik
+
+## 4.6.0 - 2020-11-15
+
+### Fixed
+- [2942: Fix PHPdoc for error handlers in ErrorMiddleware ](https://github.com/slimphp/Slim/pull/2942) thanks to @TiMESPLiNTER
+- [2944: Remove unused function in ErrorHandler](https://github.com/slimphp/Slim/pull/2944) thanks to @l0gicgate
+- [2960: Fix phpstan 0.12 errors](https://github.com/slimphp/Slim/pull/2960) thanks to @adriansuter
+- [2982: Removing cloning statements in tests](https://github.com/slimphp/Slim/pull/2982) thanks to @l0gicgate
+- [3017: Fix request creator factory test](https://github.com/slimphp/Slim/pull/3017) thanks to @pawel-slowik
+- [3022: Ensure RouteParser Always Present After Routing](https://github.com/slimphp/Slim/pull/3022) thanks to @l0gicgate
+
+### Added
+- [2949: Add the support in composer.json](https://github.com/slimphp/Slim/pull/2949) thanks to @ddrv
+- [2958: Strict empty string content type checking in BodyParsingMiddleware::getMediaType](https://github.com/slimphp/Slim/pull/2958) thanks to @Ayesh
+- [2997: Add hints to methods](https://github.com/slimphp/Slim/pull/2997) thanks to @evgsavosin - [3000: Fix route controller test](https://github.com/slimphp/Slim/pull/3000) thanks to @pawel-slowik
+- [3001: Add missing `$strategy` parameter in a Route test](https://github.com/slimphp/Slim/pull/3001) thanks to @pawel-slowik
+
+### Optimizations
+- [2951: Minor optimizations in if() blocks](https://github.com/slimphp/Slim/pull/2951) thanks to @Ayesh
+- [2959: Micro optimization: Declare closures in BodyParsingMiddleware as static](https://github.com/slimphp/Slim/pull/2959) thanks to @Ayesh
+- [2978: Split the routing results to its own function.](https://github.com/slimphp/Slim/pull/2978) thanks to @dlundgren
+
+### Dependencies Updated
+- [2953: Update nyholm/psr7-server requirement from ^0.4.1](https://github.com/slimphp/Slim/pull/2953) thanks to @dependabot-preview[bot]
+- [2954: Update laminas/laminas-diactoros requirement from ^2.1 to ^2.3](https://github.com/slimphp/Slim/pull/2954) thanks to @dependabot-preview[bot]
+- [2955: Update guzzlehttp/psr7 requirement from ^1.5 to ^1.6](https://github.com/slimphp/Slim/pull/2955) thanks to @dependabot-preview[bot]
+- [2956: Update slim/psr7 requirement from ^1.0 to ^1.1](https://github.com/slimphp/Slim/pull/2956) thanks to @dependabot-preview[bot]
+- [2957: Update nyholm/psr7 requirement from ^1.1 to ^1.2](https://github.com/slimphp/Slim/pull/2957) thanks to @dependabot-preview[bot]
+- [2963: Update phpstan/phpstan requirement from ^0.12.23 to ^0.12.25](https://github.com/slimphp/Slim/pull/2963) thanks to @dependabot-preview[bot]
+- [2965: Update adriansuter/php-autoload-override requirement from ^1.0 to ^1.1](https://github.com/slimphp/Slim/pull/2965) thanks to @dependabot-preview[bot]
+- [2967: Update nyholm/psr7 requirement from ^1.2 to ^1.3](https://github.com/slimphp/Slim/pull/2967) thanks to @dependabot-preview[bot]
+- [2969: Update nyholm/psr7-server requirement from ^0.4.1 to ^1.0.0](https://github.com/slimphp/Slim/pull/2969) thanks to @dependabot-preview[bot]
+- [2970: Update phpstan/phpstan requirement from ^0.12.25 to ^0.12.26](https://github.com/slimphp/Slim/pull/2970) thanks to @dependabot-preview[bot]
+- [2971: Update phpstan/phpstan requirement from ^0.12.26 to ^0.12.27](https://github.com/slimphp/Slim/pull/2971) thanks to @dependabot-preview[bot]
+- [2972: Update phpstan/phpstan requirement from ^0.12.27 to ^0.12.28](https://github.com/slimphp/Slim/pull/2972) thanks to @dependabot-preview[bot]
+- [2973: Update phpstan/phpstan requirement from ^0.12.28 to ^0.12.29](https://github.com/slimphp/Slim/pull/2973) thanks to @dependabot-preview[bot]
+- [2975: Update phpstan/phpstan requirement from ^0.12.29 to ^0.12.30](https://github.com/slimphp/Slim/pull/2975) thanks to @dependabot-preview[bot]
+- [2976: Update phpstan/phpstan requirement from ^0.12.30 to ^0.12.31](https://github.com/slimphp/Slim/pull/2976) thanks to @dependabot-preview[bot]
+- [2980: Update phpstan/phpstan requirement from ^0.12.31 to ^0.12.32](https://github.com/slimphp/Slim/pull/2980) thanks to @dependabot-preview[bot]
+- [2981: Update phpspec/prophecy requirement from ^1.10 to ^1.11](https://github.com/slimphp/Slim/pull/2981) thanks to @dependabot-preview[bot]
+- [2986: Update phpstan/phpstan requirement from ^0.12.32 to ^0.12.33](https://github.com/slimphp/Slim/pull/2986) thanks to @dependabot-preview[bot]
+- [2990: Update phpstan/phpstan requirement from ^0.12.33 to ^0.12.34](https://github.com/slimphp/Slim/pull/2990) thanks to @dependabot-preview[bot]
+- [2991: Update phpstan/phpstan requirement from ^0.12.34 to ^0.12.35](https://github.com/slimphp/Slim/pull/2991) thanks to @dependabot-preview[bot]
+- [2993: Update phpstan/phpstan requirement from ^0.12.35 to ^0.12.36](https://github.com/slimphp/Slim/pull/2993) thanks to @dependabot-preview[bot]
+- [2995: Update phpstan/phpstan requirement from ^0.12.36 to ^0.12.37](https://github.com/slimphp/Slim/pull/2995) thanks to @dependabot-preview[bot]
+- [3010: Update guzzlehttp/psr7 requirement from ^1.6 to ^1.7](https://github.com/slimphp/Slim/pull/3010) thanks to @dependabot-preview[bot]
+- [3011: Update phpspec/prophecy requirement from ^1.11 to ^1.12](https://github.com/slimphp/Slim/pull/3011) thanks to @dependabot-preview[bot]
+- [3012: Update slim/http requirement from ^1.0 to ^1.1](https://github.com/slimphp/Slim/pull/3012) thanks to @dependabot-preview[bot]
+- [3013: Update slim/psr7 requirement from ^1.1 to ^1.2](https://github.com/slimphp/Slim/pull/3013) thanks to @dependabot-preview[bot]
+- [3014: Update laminas/laminas-diactoros requirement from ^2.3 to ^2.4](https://github.com/slimphp/Slim/pull/3014) thanks to @dependabot-preview[bot]
+- [3018: Update phpstan/phpstan requirement from ^0.12.37 to ^0.12.54](https://github.com/slimphp/Slim/pull/3018) thanks to @dependabot-preview[bot]
+
+## 4.5.0 - 2020-04-14
+
+### Added
+- [2928](https://github.com/slimphp/Slim/pull/2928) Test against PHP 7.4
+- [2937](https://github.com/slimphp/Slim/pull/2937) Add support for PSR-3
+
+### Fixed
+- [2916](https://github.com/slimphp/Slim/pull/2916) Rename phpcs.xml to phpcs.xml.dist
+- [2917](https://github.com/slimphp/Slim/pull/2917) Update .editorconfig
+- [2925](https://github.com/slimphp/Slim/pull/2925) ResponseEmitter: Don't remove Content-Type and Content-Length when body is empt
+- [2932](https://github.com/slimphp/Slim/pull/2932) Update the Tidelift enterprise language
+- [2938](https://github.com/slimphp/Slim/pull/2938) Modify usage of deprecated expectExceptionMessageRegExp() method
+
+## 4.4.0 - 2020-01-04
+
+### Added
+- [2862](https://github.com/slimphp/Slim/pull/2862) Optionally handle subclasses of exceptions in custom error handler
+- [2869](https://github.com/slimphp/Slim/pull/2869) php-di/php-di added in composer suggestion
+- [2874](https://github.com/slimphp/Slim/pull/2874) Add `null` to param type-hints
+- [2889](https://github.com/slimphp/Slim/pull/2889) Make `RouteContext` attributes customizable and change default to use private names
+- [2907](https://github.com/slimphp/Slim/pull/2907) Migrate to PSR-12 convention
+- [2910](https://github.com/slimphp/Slim/pull/2910) Migrate Zend to Laminas
+- [2912](https://github.com/slimphp/Slim/pull/2912) Add Laminas PSR17 Factory
+- [2913](https://github.com/slimphp/Slim/pull/2913) Update php-autoload-override version
+- [2914](https://github.com/slimphp/Slim/pull/2914) Added ability to add handled exceptions as an array
+
+### Fixed
+- [2864](https://github.com/slimphp/Slim/pull/2864) Optimize error message in error handling if displayErrorDetails was not set
+- [2876](https://github.com/slimphp/Slim/pull/2876) Update links from http to https
+- [2877](https://github.com/slimphp/Slim/pull/2877) Fix docblock for `Slim\Routing\RouteCollector::cacheFile`
+- [2878](https://github.com/slimphp/Slim/pull/2878) check body is writable only on ouput buffering append
+- [2896](https://github.com/slimphp/Slim/pull/2896) Render errors uniformly
+- [2902](https://github.com/slimphp/Slim/pull/2902) Fix prophecies
+- [2908](https://github.com/slimphp/Slim/pull/2908) Use autoload-dev for `Slim\Tests` namespace
+
+### Removed
+- [2871](https://github.com/slimphp/Slim/pull/2871) Remove explicit type-hint
+- [2872](https://github.com/slimphp/Slim/pull/2872) Remove type-hint
+
+## 4.3.0 - 2019-10-05
+
+### Added
+- [2819](https://github.com/slimphp/Slim/pull/2819) Added description to addRoutingMiddleware()
+- [2820](https://github.com/slimphp/Slim/pull/2820) Update link to homepage in composer.json
+- [2828](https://github.com/slimphp/Slim/pull/2828) Allow URIs with leading multi-slashes
+- [2832](https://github.com/slimphp/Slim/pull/2832) Refactor `FastRouteDispatcher`
+- [2835](https://github.com/slimphp/Slim/pull/2835) Rename `pathFor` to `urlFor` in docblock
+- [2846](https://github.com/slimphp/Slim/pull/2846) Correcting the branch name as per issue-2843
+- [2849](https://github.com/slimphp/Slim/pull/2849) Create class alias for FastRoute\RouteCollector
+- [2855](https://github.com/slimphp/Slim/pull/2855) Add list of allowed methods to HttpMethodNotAllowedException
+- [2860](https://github.com/slimphp/Slim/pull/2860) Add base path to `$request` and use `RouteContext` to read
+
+### Fixed
+- [2839](https://github.com/slimphp/Slim/pull/2839) Fix description for handler signature in phpdocs
+- [2844](https://github.com/slimphp/Slim/pull/2844) Handle base path by routeCollector instead of RouteCollectorProxy
+- [2845](https://github.com/slimphp/Slim/pull/2845) Fix composer scripts
+- [2851](https://github.com/slimphp/Slim/pull/2851) Fix example of 'Hello World' app
+- [2854](https://github.com/slimphp/Slim/pull/2854) Fix undefined property in tests
+
+### Removed
+- [2853](https://github.com/slimphp/Slim/pull/2853) Remove unused classes
+
+## 4.2.0 - 2019-08-20
+
+### Added
+- [2787](https://github.com/slimphp/Slim/pull/2787) Add an advanced callable resolver
+- [2791](https://github.com/slimphp/Slim/pull/2791) Add `inferPrivatePropertyTypeFromConstructor` to phpstan
+- [2793](https://github.com/slimphp/Slim/pull/2793) Add ability to configure application via a container in `AppFactory`
+- [2798](https://github.com/slimphp/Slim/pull/2798) Add PSR-7 Agnostic Body Parsing Middleware
+- [2801](https://github.com/slimphp/Slim/pull/2801) Add `setLogErrorRenderer()` method to `ErrorHandler`
+- [2807](https://github.com/slimphp/Slim/pull/2807) Add check for Slim callable notation if no resolver given
+- [2803](https://github.com/slimphp/Slim/pull/2803) Add ability to emit non seekable streams in `ResponseEmitter`
+- [2817](https://github.com/slimphp/Slim/pull/2817) Add the ability to pass in a custom `MiddlewareDispatcherInterface` to the `App`
+
+### Fixed
+- [2789](https://github.com/slimphp/Slim/pull/2789) Fix Cookie header detection in `ResponseEmitter`
+- [2796](https://github.com/slimphp/Slim/pull/2796) Fix http message format
+- [2800](https://github.com/slimphp/Slim/pull/2800) Fix null comparisons more clear in `ErrorHandler`
+- [2802](https://github.com/slimphp/Slim/pull/2802) Fix incorrect search of a header in stack
+- [2806](https://github.com/slimphp/Slim/pull/2806) Simplify `Route::prepare()` method argument preparation
+- [2809](https://github.com/slimphp/Slim/pull/2809) Eliminate a duplicate code via HOF in `MiddlewareDispatcher`
+- [2816](https://github.com/slimphp/Slim/pull/2816) Fix RouteCollectorProxy::redirect() bug
+
+### Removed
+- [2811](https://github.com/slimphp/Slim/pull/2811) Remove `DeferredCallable`
+
+## 4.1.0 - 2019-08-06
+
+### Added
+- [#2779](https://github.com/slimphp/Slim/pull/2774) Add support for Slim callables `Class:method` resolution & Container Closure auto-binding in `MiddlewareDispatcher`
+- [#2774](https://github.com/slimphp/Slim/pull/2774) Add possibility for custom `RequestHandler` invocation strategies
+
+### Fixed
+- [#2776](https://github.com/slimphp/Slim/pull/2774) Fix group middleware on multiple nested groups
diff --git a/lib/phpspreadsheet/vendor/composer/LICENSE b/lib/slim/slim/LICENSE.md
similarity index 95%
rename from lib/phpspreadsheet/vendor/composer/LICENSE
rename to lib/slim/slim/LICENSE.md
index f27399a042d..d6fd559c787 100644
--- a/lib/phpspreadsheet/vendor/composer/LICENSE
+++ b/lib/slim/slim/LICENSE.md
@@ -1,5 +1,4 @@
-
-Copyright (c) Nils Adermann, Jordi Boggiano
+Copyright (c) 2011-2022 Josh Lockhart
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -18,4 +17,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
-
diff --git a/lib/slim/slim/MAINTAINERS.md b/lib/slim/slim/MAINTAINERS.md
new file mode 100644
index 00000000000..2b8ad04964b
--- /dev/null
+++ b/lib/slim/slim/MAINTAINERS.md
@@ -0,0 +1,17 @@
+# Maintainers
+
+There aren't many rules for maintainers of Slim to remember; what we have is listed here.
+
+## We don't merge our own PRs
+
+Our code is better if more than one set of eyes looks at it. Therefore we do not merge our own pull requests unless there is an exceptional circumstance. This helps to spot errors in the patch and also enables us to share information about the project around the maintainer team.
+
+## PRs tagged `WIP` are not ready to be merged
+
+Sometimes it's helpful to collaborate on a patch before it's ready to be merged. We use the text `WIP` (for _Work in Progress_) in the title to mark these PRs.
+
+If a PR has `WIP` in its title, then it is not to be merged. The person who raised the PR will remove the `WIP` text when they are ready for a full review and merge.
+
+## Assign a merged PR to a milestone
+
+By ensuring that all merged PRs are assigned to a milestone, we can easily find which PRs were in which release.
diff --git a/lib/slim/slim/SECURITY.md b/lib/slim/slim/SECURITY.md
new file mode 100644
index 00000000000..a5b6df0b4cc
--- /dev/null
+++ b/lib/slim/slim/SECURITY.md
@@ -0,0 +1,14 @@
+# Security Policy
+
+### Supported Versions
+
+
+| Version | Supported |
+| ------- | ------------------ |
+| 3.x.x | :white_check_mark: |
+| 4.x.x | :white_check_mark: |
+
+
+### Reporting a Vulnerability
+
+To report a vulnerability please send an email to security@slimframework.com
diff --git a/lib/slim/slim/Slim/App.php b/lib/slim/slim/Slim/App.php
new file mode 100644
index 00000000000..ebcc751f6d7
--- /dev/null
+++ b/lib/slim/slim/Slim/App.php
@@ -0,0 +1,216 @@
+routeResolver = $routeResolver ?? new RouteResolver($this->routeCollector);
+ $routeRunner = new RouteRunner($this->routeResolver, $this->routeCollector->getRouteParser(), $this);
+
+ if (!$middlewareDispatcher) {
+ $middlewareDispatcher = new MiddlewareDispatcher($routeRunner, $this->callableResolver, $container);
+ } else {
+ $middlewareDispatcher->seedMiddlewareStack($routeRunner);
+ }
+
+ $this->middlewareDispatcher = $middlewareDispatcher;
+ }
+
+ /**
+ * @return RouteResolverInterface
+ */
+ public function getRouteResolver(): RouteResolverInterface
+ {
+ return $this->routeResolver;
+ }
+
+ /**
+ * @return MiddlewareDispatcherInterface
+ */
+ public function getMiddlewareDispatcher(): MiddlewareDispatcherInterface
+ {
+ return $this->middlewareDispatcher;
+ }
+
+ /**
+ * @param MiddlewareInterface|string|callable $middleware
+ */
+ public function add($middleware): self
+ {
+ $this->middlewareDispatcher->add($middleware);
+ return $this;
+ }
+
+ /**
+ * @param MiddlewareInterface $middleware
+ */
+ public function addMiddleware(MiddlewareInterface $middleware): self
+ {
+ $this->middlewareDispatcher->addMiddleware($middleware);
+ return $this;
+ }
+
+ /**
+ * Add the Slim built-in routing middleware to the app middleware stack
+ *
+ * This method can be used to control middleware order and is not required for default routing operation.
+ *
+ * @return RoutingMiddleware
+ */
+ public function addRoutingMiddleware(): RoutingMiddleware
+ {
+ $routingMiddleware = new RoutingMiddleware(
+ $this->getRouteResolver(),
+ $this->getRouteCollector()->getRouteParser()
+ );
+ $this->add($routingMiddleware);
+ return $routingMiddleware;
+ }
+
+ /**
+ * Add the Slim built-in error middleware to the app middleware stack
+ *
+ * @param bool $displayErrorDetails
+ * @param bool $logErrors
+ * @param bool $logErrorDetails
+ * @param LoggerInterface|null $logger
+ *
+ * @return ErrorMiddleware
+ */
+ public function addErrorMiddleware(
+ bool $displayErrorDetails,
+ bool $logErrors,
+ bool $logErrorDetails,
+ ?LoggerInterface $logger = null
+ ): ErrorMiddleware {
+ $errorMiddleware = new ErrorMiddleware(
+ $this->getCallableResolver(),
+ $this->getResponseFactory(),
+ $displayErrorDetails,
+ $logErrors,
+ $logErrorDetails,
+ $logger
+ );
+ $this->add($errorMiddleware);
+ return $errorMiddleware;
+ }
+
+ /**
+ * Add the Slim body parsing middleware to the app middleware stack
+ *
+ * @param callable[] $bodyParsers
+ *
+ * @return BodyParsingMiddleware
+ */
+ public function addBodyParsingMiddleware(array $bodyParsers = []): BodyParsingMiddleware
+ {
+ $bodyParsingMiddleware = new BodyParsingMiddleware($bodyParsers);
+ $this->add($bodyParsingMiddleware);
+ return $bodyParsingMiddleware;
+ }
+
+ /**
+ * Run application
+ *
+ * This method traverses the application middleware stack and then sends the
+ * resultant Response object to the HTTP client.
+ *
+ * @param ServerRequestInterface|null $request
+ * @return void
+ */
+ public function run(?ServerRequestInterface $request = null): void
+ {
+ if (!$request) {
+ $serverRequestCreator = ServerRequestCreatorFactory::create();
+ $request = $serverRequestCreator->createServerRequestFromGlobals();
+ }
+
+ $response = $this->handle($request);
+ $responseEmitter = new ResponseEmitter();
+ $responseEmitter->emit($response);
+ }
+
+ /**
+ * Handle a request
+ *
+ * This method traverses the application middleware stack and then returns the
+ * resultant Response object.
+ *
+ * @param ServerRequestInterface $request
+ * @return ResponseInterface
+ */
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ $response = $this->middlewareDispatcher->handle($request);
+
+ /**
+ * This is to be in compliance with RFC 2616, Section 9.
+ * If the incoming request method is HEAD, we need to ensure that the response body
+ * is empty as the request may fall back on a GET route handler due to FastRoute's
+ * routing logic which could potentially append content to the response body
+ * https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4
+ */
+ $method = strtoupper($request->getMethod());
+ if ($method === 'HEAD') {
+ $emptyBody = $this->responseFactory->createResponse()->getBody();
+ return $response->withBody($emptyBody);
+ }
+
+ return $response;
+ }
+}
diff --git a/lib/slim/slim/Slim/CallableResolver.php b/lib/slim/slim/Slim/CallableResolver.php
new file mode 100644
index 00000000000..66f225de890
--- /dev/null
+++ b/lib/slim/slim/Slim/CallableResolver.php
@@ -0,0 +1,193 @@
+container = $container;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function resolve($toResolve): callable
+ {
+ $toResolve = $this->prepareToResolve($toResolve);
+ if (is_callable($toResolve)) {
+ return $this->bindToContainer($toResolve);
+ }
+ $resolved = $toResolve;
+ if (is_string($toResolve)) {
+ $resolved = $this->resolveSlimNotation($toResolve);
+ $resolved[1] ??= '__invoke';
+ }
+ $callable = $this->assertCallable($resolved, $toResolve);
+ return $this->bindToContainer($callable);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function resolveRoute($toResolve): callable
+ {
+ return $this->resolveByPredicate($toResolve, [$this, 'isRoute'], 'handle');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function resolveMiddleware($toResolve): callable
+ {
+ return $this->resolveByPredicate($toResolve, [$this, 'isMiddleware'], 'process');
+ }
+
+ /**
+ * @param string|callable $toResolve
+ *
+ * @throws RuntimeException
+ */
+ private function resolveByPredicate($toResolve, callable $predicate, string $defaultMethod): callable
+ {
+ $toResolve = $this->prepareToResolve($toResolve);
+ if (is_callable($toResolve)) {
+ return $this->bindToContainer($toResolve);
+ }
+ $resolved = $toResolve;
+ if ($predicate($toResolve)) {
+ $resolved = [$toResolve, $defaultMethod];
+ }
+ if (is_string($toResolve)) {
+ [$instance, $method] = $this->resolveSlimNotation($toResolve);
+ if ($method === null && $predicate($instance)) {
+ $method = $defaultMethod;
+ }
+ $resolved = [$instance, $method ?? '__invoke'];
+ }
+ $callable = $this->assertCallable($resolved, $toResolve);
+ return $this->bindToContainer($callable);
+ }
+
+ /**
+ * @param mixed $toResolve
+ */
+ private function isRoute($toResolve): bool
+ {
+ return $toResolve instanceof RequestHandlerInterface;
+ }
+
+ /**
+ * @param mixed $toResolve
+ */
+ private function isMiddleware($toResolve): bool
+ {
+ return $toResolve instanceof MiddlewareInterface;
+ }
+
+ /**
+ * @throws RuntimeException
+ *
+ * @return array{object, string|null} [Instance, Method Name]
+ */
+ private function resolveSlimNotation(string $toResolve): array
+ {
+ preg_match(CallableResolver::$callablePattern, $toResolve, $matches);
+ [$class, $method] = $matches ? [$matches[1], $matches[2]] : [$toResolve, null];
+
+ /** @var string $class */
+ /** @var string|null $method */
+ if ($this->container && $this->container->has($class)) {
+ $instance = $this->container->get($class);
+ if (!is_object($instance)) {
+ throw new RuntimeException(sprintf('%s container entry is not an object', $class));
+ }
+ } else {
+ if (!class_exists($class)) {
+ if ($method) {
+ $class .= '::' . $method . '()';
+ }
+ throw new RuntimeException(sprintf('Callable %s does not exist', $class));
+ }
+ $instance = new $class($this->container);
+ }
+ return [$instance, $method];
+ }
+
+ /**
+ * @param mixed $resolved
+ * @param mixed $toResolve
+ *
+ * @throws RuntimeException
+ */
+ private function assertCallable($resolved, $toResolve): callable
+ {
+ if (!is_callable($resolved)) {
+ if (is_callable($toResolve) || is_object($toResolve) || is_array($toResolve)) {
+ $formatedToResolve = ($toResolveJson = json_encode($toResolve)) !== false ? $toResolveJson : '';
+ } else {
+ $formatedToResolve = is_string($toResolve) ? $toResolve : '';
+ }
+ throw new RuntimeException(sprintf('%s is not resolvable', $formatedToResolve));
+ }
+ return $resolved;
+ }
+
+ private function bindToContainer(callable $callable): callable
+ {
+ if (is_array($callable) && $callable[0] instanceof Closure) {
+ $callable = $callable[0];
+ }
+ if ($this->container && $callable instanceof Closure) {
+ /** @var Closure $callable */
+ $callable = $callable->bindTo($this->container);
+ }
+ return $callable;
+ }
+
+ /**
+ * @param string|callable $toResolve
+ * @return string|callable
+ */
+ private function prepareToResolve($toResolve)
+ {
+ if (!is_array($toResolve)) {
+ return $toResolve;
+ }
+ $candidate = $toResolve;
+ $class = array_shift($candidate);
+ $method = array_shift($candidate);
+ if (is_string($class) && is_string($method)) {
+ return $class . ':' . $method;
+ }
+ return $toResolve;
+ }
+}
diff --git a/lib/slim/slim/Slim/Error/AbstractErrorRenderer.php b/lib/slim/slim/Slim/Error/AbstractErrorRenderer.php
new file mode 100644
index 00000000000..90b290d410f
--- /dev/null
+++ b/lib/slim/slim/Slim/Error/AbstractErrorRenderer.php
@@ -0,0 +1,46 @@
+getTitle();
+ }
+
+ return $this->defaultErrorTitle;
+ }
+
+ protected function getErrorDescription(Throwable $exception): string
+ {
+ if ($exception instanceof HttpException) {
+ return $exception->getDescription();
+ }
+
+ return $this->defaultErrorDescription;
+ }
+}
diff --git a/lib/slim/slim/Slim/Error/Renderers/HtmlErrorRenderer.php b/lib/slim/slim/Slim/Error/Renderers/HtmlErrorRenderer.php
new file mode 100644
index 00000000000..e030522aa87
--- /dev/null
+++ b/lib/slim/slim/Slim/Error/Renderers/HtmlErrorRenderer.php
@@ -0,0 +1,84 @@
+The application could not run because of the following error:
';
+ $html .= 'Details ';
+ $html .= $this->renderExceptionFragment($exception);
+ } else {
+ $html = "{$this->getErrorDescription($exception)}
";
+ }
+
+ return $this->renderHtmlBody($this->getErrorTitle($exception), $html);
+ }
+
+ private function renderExceptionFragment(Throwable $exception): string
+ {
+ $html = sprintf('Type: %s
', get_class($exception));
+
+ /** @var int|string $code */
+ $code = $exception->getCode();
+ $html .= sprintf('Code: %s
', $code);
+
+ $html .= sprintf('Message: %s
', htmlentities($exception->getMessage()));
+
+ $html .= sprintf('File: %s
', $exception->getFile());
+
+ $html .= sprintf('Line: %s
', $exception->getLine());
+
+ $html .= 'Trace ';
+ $html .= sprintf('%s ', htmlentities($exception->getTraceAsString()));
+
+ return $html;
+ }
+
+ public function renderHtmlBody(string $title = '', string $html = ''): string
+ {
+ return sprintf(
+ '' .
+ '' .
+ ' ' .
+ ' ' .
+ ' ' .
+ ' %s ' .
+ ' ' .
+ ' ' .
+ ' ' .
+ ' %s ' .
+ ' %s
' .
+ ' Go Back ' .
+ ' ' .
+ '',
+ $title,
+ $title,
+ $html
+ );
+ }
+}
diff --git a/lib/slim/slim/Slim/Error/Renderers/JsonErrorRenderer.php b/lib/slim/slim/Slim/Error/Renderers/JsonErrorRenderer.php
new file mode 100644
index 00000000000..63d905b3cd3
--- /dev/null
+++ b/lib/slim/slim/Slim/Error/Renderers/JsonErrorRenderer.php
@@ -0,0 +1,56 @@
+ $this->getErrorTitle($exception)];
+
+ if ($displayErrorDetails) {
+ $error['exception'] = [];
+ do {
+ $error['exception'][] = $this->formatExceptionFragment($exception);
+ } while ($exception = $exception->getPrevious());
+ }
+
+ return (string) json_encode($error, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ }
+
+ /**
+ * @return array
+ */
+ private function formatExceptionFragment(Throwable $exception): array
+ {
+ /** @var int|string $code */
+ $code = $exception->getCode();
+ return [
+ 'type' => get_class($exception),
+ 'code' => $code,
+ 'message' => $exception->getMessage(),
+ 'file' => $exception->getFile(),
+ 'line' => $exception->getLine(),
+ ];
+ }
+}
diff --git a/lib/slim/slim/Slim/Error/Renderers/PlainTextErrorRenderer.php b/lib/slim/slim/Slim/Error/Renderers/PlainTextErrorRenderer.php
new file mode 100644
index 00000000000..3d80c74be96
--- /dev/null
+++ b/lib/slim/slim/Slim/Error/Renderers/PlainTextErrorRenderer.php
@@ -0,0 +1,59 @@
+getErrorTitle($exception)}\n";
+
+ if ($displayErrorDetails) {
+ $text .= $this->formatExceptionFragment($exception);
+
+ while ($exception = $exception->getPrevious()) {
+ $text .= "\nPrevious Error:\n";
+ $text .= $this->formatExceptionFragment($exception);
+ }
+ }
+
+ return $text;
+ }
+
+ private function formatExceptionFragment(Throwable $exception): string
+ {
+ $text = sprintf("Type: %s\n", get_class($exception));
+
+ $code = $exception->getCode();
+ /** @var int|string $code */
+ $text .= sprintf("Code: %s\n", $code);
+
+ $text .= sprintf("Message: %s\n", htmlentities($exception->getMessage()));
+
+ $text .= sprintf("File: %s\n", $exception->getFile());
+
+ $text .= sprintf("Line: %s\n", $exception->getLine());
+
+ $text .= sprintf('Trace: %s', $exception->getTraceAsString());
+
+ return $text;
+ }
+}
diff --git a/lib/slim/slim/Slim/Error/Renderers/XmlErrorRenderer.php b/lib/slim/slim/Slim/Error/Renderers/XmlErrorRenderer.php
new file mode 100644
index 00000000000..1171b79b2d4
--- /dev/null
+++ b/lib/slim/slim/Slim/Error/Renderers/XmlErrorRenderer.php
@@ -0,0 +1,54 @@
+\n";
+ $xml .= "\n " . $this->createCdataSection($this->getErrorTitle($exception)) . " \n";
+
+ if ($displayErrorDetails) {
+ do {
+ $xml .= " \n";
+ $xml .= ' ' . get_class($exception) . " \n";
+ $xml .= ' ' . $exception->getCode() . "\n";
+ $xml .= ' ' . $this->createCdataSection($exception->getMessage()) . " \n";
+ $xml .= ' ' . $exception->getFile() . " \n";
+ $xml .= ' ' . $exception->getLine() . " \n";
+ $xml .= " \n";
+ } while ($exception = $exception->getPrevious());
+ }
+
+ $xml .= ' ';
+
+ return $xml;
+ }
+
+ /**
+ * Returns a CDATA section with the given content.
+ */
+ private function createCdataSection(string $content): string
+ {
+ return sprintf('', str_replace(']]>', ']]]]>', $content));
+ }
+}
diff --git a/lib/slim/slim/Slim/Exception/HttpBadRequestException.php b/lib/slim/slim/Slim/Exception/HttpBadRequestException.php
new file mode 100644
index 00000000000..9be551cf999
--- /dev/null
+++ b/lib/slim/slim/Slim/Exception/HttpBadRequestException.php
@@ -0,0 +1,28 @@
+request = $request;
+ }
+
+ public function getRequest(): ServerRequestInterface
+ {
+ return $this->request;
+ }
+
+ public function getTitle(): string
+ {
+ return $this->title;
+ }
+
+ public function setTitle(string $title): self
+ {
+ $this->title = $title;
+ return $this;
+ }
+
+ public function getDescription(): string
+ {
+ return $this->description;
+ }
+
+ public function setDescription(string $description): self
+ {
+ $this->description = $description;
+ return $this;
+ }
+}
diff --git a/lib/slim/slim/Slim/Exception/HttpForbiddenException.php b/lib/slim/slim/Slim/Exception/HttpForbiddenException.php
new file mode 100644
index 00000000000..dd3bb230a30
--- /dev/null
+++ b/lib/slim/slim/Slim/Exception/HttpForbiddenException.php
@@ -0,0 +1,27 @@
+allowedMethods;
+ }
+
+ /**
+ * @param string[] $methods
+ */
+ public function setAllowedMethods(array $methods): self
+ {
+ $this->allowedMethods = $methods;
+ $this->message = 'Method not allowed. Must be one of: ' . implode(', ', $methods);
+ return $this;
+ }
+}
diff --git a/lib/slim/slim/Slim/Exception/HttpNotFoundException.php b/lib/slim/slim/Slim/Exception/HttpNotFoundException.php
new file mode 100644
index 00000000000..865146d68f8
--- /dev/null
+++ b/lib/slim/slim/Slim/Exception/HttpNotFoundException.php
@@ -0,0 +1,27 @@
+message = $message;
+ }
+
+ parent::__construct($request, $this->message, $this->code, $previous);
+ }
+}
diff --git a/lib/slim/slim/Slim/Exception/HttpTooManyRequestsException.php b/lib/slim/slim/Slim/Exception/HttpTooManyRequestsException.php
new file mode 100644
index 00000000000..be4b29bb3c6
--- /dev/null
+++ b/lib/slim/slim/Slim/Exception/HttpTooManyRequestsException.php
@@ -0,0 +1,28 @@
+has(ResponseFactoryInterface::class)
+ && (
+ $responseFactoryFromContainer = $container->get(ResponseFactoryInterface::class)
+ ) instanceof ResponseFactoryInterface
+ ? $responseFactoryFromContainer
+ : self::determineResponseFactory();
+
+ $callableResolver = $container->has(CallableResolverInterface::class)
+ && (
+ $callableResolverFromContainer = $container->get(CallableResolverInterface::class)
+ ) instanceof CallableResolverInterface
+ ? $callableResolverFromContainer
+ : null;
+
+ $routeCollector = $container->has(RouteCollectorInterface::class)
+ && (
+ $routeCollectorFromContainer = $container->get(RouteCollectorInterface::class)
+ ) instanceof RouteCollectorInterface
+ ? $routeCollectorFromContainer
+ : null;
+
+ $routeResolver = $container->has(RouteResolverInterface::class)
+ && (
+ $routeResolverFromContainer = $container->get(RouteResolverInterface::class)
+ ) instanceof RouteResolverInterface
+ ? $routeResolverFromContainer
+ : null;
+
+ $middlewareDispatcher = $container->has(MiddlewareDispatcherInterface::class)
+ && (
+ $middlewareDispatcherFromContainer = $container->get(MiddlewareDispatcherInterface::class)
+ ) instanceof MiddlewareDispatcherInterface
+ ? $middlewareDispatcherFromContainer
+ : null;
+
+ return new App(
+ $responseFactory,
+ $container,
+ $callableResolver,
+ $routeCollector,
+ $routeResolver,
+ $middlewareDispatcher
+ );
+ }
+
+ /**
+ * @throws RuntimeException
+ */
+ public static function determineResponseFactory(): ResponseFactoryInterface
+ {
+ if (static::$responseFactory) {
+ if (static::$streamFactory) {
+ return static::attemptResponseFactoryDecoration(static::$responseFactory, static::$streamFactory);
+ }
+ return static::$responseFactory;
+ }
+
+ $psr17FactoryProvider = static::$psr17FactoryProvider ?? new Psr17FactoryProvider();
+
+ /** @var Psr17Factory $psr17factory */
+ foreach ($psr17FactoryProvider->getFactories() as $psr17factory) {
+ if ($psr17factory::isResponseFactoryAvailable()) {
+ $responseFactory = $psr17factory::getResponseFactory();
+
+ if (static::$streamFactory || $psr17factory::isStreamFactoryAvailable()) {
+ $streamFactory = static::$streamFactory ?? $psr17factory::getStreamFactory();
+ return static::attemptResponseFactoryDecoration($responseFactory, $streamFactory);
+ }
+
+ return $responseFactory;
+ }
+ }
+
+ throw new RuntimeException(
+ "Could not detect any PSR-17 ResponseFactory implementations. " .
+ "Please install a supported implementation in order to use `AppFactory::create()`. " .
+ "See https://github.com/slimphp/Slim/blob/4.x/README.md for a list of supported implementations."
+ );
+ }
+
+ protected static function attemptResponseFactoryDecoration(
+ ResponseFactoryInterface $responseFactory,
+ StreamFactoryInterface $streamFactory
+ ): ResponseFactoryInterface {
+ if (
+ static::$slimHttpDecoratorsAutomaticDetectionEnabled
+ && SlimHttpPsr17Factory::isResponseFactoryAvailable()
+ ) {
+ return SlimHttpPsr17Factory::createDecoratedResponseFactory($responseFactory, $streamFactory);
+ }
+
+ return $responseFactory;
+ }
+
+ public static function setPsr17FactoryProvider(Psr17FactoryProviderInterface $psr17FactoryProvider): void
+ {
+ static::$psr17FactoryProvider = $psr17FactoryProvider;
+ }
+
+ public static function setResponseFactory(ResponseFactoryInterface $responseFactory): void
+ {
+ static::$responseFactory = $responseFactory;
+ }
+
+ public static function setStreamFactory(StreamFactoryInterface $streamFactory): void
+ {
+ static::$streamFactory = $streamFactory;
+ }
+
+ public static function setContainer(ContainerInterface $container): void
+ {
+ static::$container = $container;
+ }
+
+ public static function setCallableResolver(CallableResolverInterface $callableResolver): void
+ {
+ static::$callableResolver = $callableResolver;
+ }
+
+ public static function setRouteCollector(RouteCollectorInterface $routeCollector): void
+ {
+ static::$routeCollector = $routeCollector;
+ }
+
+ public static function setRouteResolver(RouteResolverInterface $routeResolver): void
+ {
+ static::$routeResolver = $routeResolver;
+ }
+
+ public static function setMiddlewareDispatcher(MiddlewareDispatcherInterface $middlewareDispatcher): void
+ {
+ static::$middlewareDispatcher = $middlewareDispatcher;
+ }
+
+ public static function setSlimHttpDecoratorsAutomaticDetection(bool $enabled): void
+ {
+ static::$slimHttpDecoratorsAutomaticDetectionEnabled = $enabled;
+ }
+}
diff --git a/lib/slim/slim/Slim/Factory/Psr17/GuzzlePsr17Factory.php b/lib/slim/slim/Slim/Factory/Psr17/GuzzlePsr17Factory.php
new file mode 100644
index 00000000000..32a548a6720
--- /dev/null
+++ b/lib/slim/slim/Slim/Factory/Psr17/GuzzlePsr17Factory.php
@@ -0,0 +1,19 @@
+serverRequestCreator = $serverRequestCreator;
+ $this->serverRequestCreatorMethod = $serverRequestCreatorMethod;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function createServerRequestFromGlobals(): ServerRequestInterface
+ {
+ /** @var callable $callable */
+ $callable = [$this->serverRequestCreator, $this->serverRequestCreatorMethod];
+ return (Closure::fromCallable($callable))();
+ }
+}
diff --git a/lib/slim/slim/Slim/Factory/Psr17/SlimHttpPsr17Factory.php b/lib/slim/slim/Slim/Factory/Psr17/SlimHttpPsr17Factory.php
new file mode 100644
index 00000000000..5d636318bc8
--- /dev/null
+++ b/lib/slim/slim/Slim/Factory/Psr17/SlimHttpPsr17Factory.php
@@ -0,0 +1,39 @@
+serverRequestCreator = $serverRequestCreator;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function createServerRequestFromGlobals(): ServerRequestInterface
+ {
+ if (!static::isServerRequestDecoratorAvailable()) {
+ throw new RuntimeException('The Slim-Http ServerRequest decorator is not available.');
+ }
+
+ $request = $this->serverRequestCreator->createServerRequestFromGlobals();
+
+ if (
+ !((
+ $decoratedServerRequest = new static::$serverRequestDecoratorClass($request)
+ ) instanceof ServerRequestInterface)
+ ) {
+ throw new RuntimeException(get_called_class() . ' could not instantiate a decorated server request.');
+ }
+
+ return $decoratedServerRequest;
+ }
+
+ public static function isServerRequestDecoratorAvailable(): bool
+ {
+ return class_exists(static::$serverRequestDecoratorClass);
+ }
+}
diff --git a/lib/slim/slim/Slim/Factory/Psr17/SlimPsr17Factory.php b/lib/slim/slim/Slim/Factory/Psr17/SlimPsr17Factory.php
new file mode 100644
index 00000000000..46c46f9ce3b
--- /dev/null
+++ b/lib/slim/slim/Slim/Factory/Psr17/SlimPsr17Factory.php
@@ -0,0 +1,19 @@
+getFactories() as $psr17Factory) {
+ if ($psr17Factory::isServerRequestCreatorAvailable()) {
+ $serverRequestCreator = $psr17Factory::getServerRequestCreator();
+ return static::attemptServerRequestCreatorDecoration($serverRequestCreator);
+ }
+ }
+
+ throw new RuntimeException(
+ "Could not detect any ServerRequest creator implementations. " .
+ "Please install a supported implementation in order to use `App::run()` " .
+ "without having to pass in a `ServerRequest` object. " .
+ "See https://github.com/slimphp/Slim/blob/4.x/README.md for a list of supported implementations."
+ );
+ }
+
+ protected static function attemptServerRequestCreatorDecoration(
+ ServerRequestCreatorInterface $serverRequestCreator
+ ): ServerRequestCreatorInterface {
+ if (
+ static::$slimHttpDecoratorsAutomaticDetectionEnabled
+ && SlimHttpServerRequestCreator::isServerRequestDecoratorAvailable()
+ ) {
+ return new SlimHttpServerRequestCreator($serverRequestCreator);
+ }
+
+ return $serverRequestCreator;
+ }
+
+ public static function setPsr17FactoryProvider(Psr17FactoryProviderInterface $psr17FactoryProvider): void
+ {
+ static::$psr17FactoryProvider = $psr17FactoryProvider;
+ }
+
+ public static function setServerRequestCreator(ServerRequestCreatorInterface $serverRequestCreator): void
+ {
+ self::$serverRequestCreator = $serverRequestCreator;
+ }
+
+ public static function setSlimHttpDecoratorsAutomaticDetection(bool $enabled): void
+ {
+ static::$slimHttpDecoratorsAutomaticDetectionEnabled = $enabled;
+ }
+}
diff --git a/lib/slim/slim/Slim/Handlers/ErrorHandler.php b/lib/slim/slim/Slim/Handlers/ErrorHandler.php
new file mode 100644
index 00000000000..f9606e364ba
--- /dev/null
+++ b/lib/slim/slim/Slim/Handlers/ErrorHandler.php
@@ -0,0 +1,308 @@
+
+ */
+ protected array $errorRenderers = [
+ 'application/json' => JsonErrorRenderer::class,
+ 'application/xml' => XmlErrorRenderer::class,
+ 'text/xml' => XmlErrorRenderer::class,
+ 'text/html' => HtmlErrorRenderer::class,
+ 'text/plain' => PlainTextErrorRenderer::class,
+ ];
+
+ protected bool $displayErrorDetails = false;
+
+ protected bool $logErrors;
+
+ protected bool $logErrorDetails = false;
+
+ protected ?string $contentType = null;
+
+ protected ?string $method = null;
+
+ protected ServerRequestInterface $request;
+
+ protected Throwable $exception;
+
+ protected int $statusCode;
+
+ protected CallableResolverInterface $callableResolver;
+
+ protected ResponseFactoryInterface $responseFactory;
+
+ protected LoggerInterface $logger;
+
+ public function __construct(
+ CallableResolverInterface $callableResolver,
+ ResponseFactoryInterface $responseFactory,
+ ?LoggerInterface $logger = null
+ ) {
+ $this->callableResolver = $callableResolver;
+ $this->responseFactory = $responseFactory;
+ $this->logger = $logger ?: $this->getDefaultLogger();
+ }
+
+ /**
+ * Invoke error handler
+ *
+ * @param ServerRequestInterface $request The most recent Request object
+ * @param Throwable $exception The caught Exception object
+ * @param bool $displayErrorDetails Whether or not to display the error details
+ * @param bool $logErrors Whether or not to log errors
+ * @param bool $logErrorDetails Whether or not to log error details
+ */
+ public function __invoke(
+ ServerRequestInterface $request,
+ Throwable $exception,
+ bool $displayErrorDetails,
+ bool $logErrors,
+ bool $logErrorDetails
+ ): ResponseInterface {
+ $this->displayErrorDetails = $displayErrorDetails;
+ $this->logErrors = $logErrors;
+ $this->logErrorDetails = $logErrorDetails;
+ $this->request = $request;
+ $this->exception = $exception;
+ $this->method = $request->getMethod();
+ $this->statusCode = $this->determineStatusCode();
+ if ($this->contentType === null) {
+ $this->contentType = $this->determineContentType($request);
+ }
+
+ if ($logErrors) {
+ $this->writeToErrorLog();
+ }
+
+ return $this->respond();
+ }
+
+ /**
+ * Force the content type for all error handler responses.
+ *
+ * @param string|null $contentType The content type
+ */
+ public function forceContentType(?string $contentType): void
+ {
+ $this->contentType = $contentType;
+ }
+
+ protected function determineStatusCode(): int
+ {
+ if ($this->method === 'OPTIONS') {
+ return 200;
+ }
+
+ if ($this->exception instanceof HttpException) {
+ return $this->exception->getCode();
+ }
+
+ return 500;
+ }
+
+ /**
+ * Determine which content type we know about is wanted using Accept header
+ *
+ * Note: This method is a bare-bones implementation designed specifically for
+ * Slim's error handling requirements. Consider a fully-feature solution such
+ * as willdurand/negotiation for any other situation.
+ */
+ protected function determineContentType(ServerRequestInterface $request): ?string
+ {
+ $acceptHeader = $request->getHeaderLine('Accept');
+ $selectedContentTypes = array_intersect(
+ explode(',', $acceptHeader),
+ array_keys($this->errorRenderers)
+ );
+ $count = count($selectedContentTypes);
+
+ if ($count) {
+ $current = current($selectedContentTypes);
+
+ /**
+ * Ensure other supported content types take precedence over text/plain
+ * when multiple content types are provided via Accept header.
+ */
+ if ($current === 'text/plain' && $count > 1) {
+ $next = next($selectedContentTypes);
+ if (is_string($next)) {
+ return $next;
+ }
+ }
+
+ if (is_string($current)) {
+ return $current;
+ }
+ }
+
+ if (preg_match('/\+(json|xml)/', $acceptHeader, $matches)) {
+ $mediaType = 'application/' . $matches[1];
+ if (array_key_exists($mediaType, $this->errorRenderers)) {
+ return $mediaType;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Determine which renderer to use based on content type
+ *
+ * @throws RuntimeException
+ */
+ protected function determineRenderer(): callable
+ {
+ if ($this->contentType !== null && array_key_exists($this->contentType, $this->errorRenderers)) {
+ $renderer = $this->errorRenderers[$this->contentType];
+ } else {
+ $renderer = $this->defaultErrorRenderer;
+ }
+
+ return $this->callableResolver->resolve($renderer);
+ }
+
+ /**
+ * Register an error renderer for a specific content-type
+ *
+ * @param string $contentType The content-type this renderer should be registered to
+ * @param ErrorRendererInterface|string|callable $errorRenderer The error renderer
+ */
+ public function registerErrorRenderer(string $contentType, $errorRenderer): void
+ {
+ $this->errorRenderers[$contentType] = $errorRenderer;
+ }
+
+ /**
+ * Set the default error renderer
+ *
+ * @param string $contentType The content type of the default error renderer
+ * @param ErrorRendererInterface|string|callable $errorRenderer The default error renderer
+ */
+ public function setDefaultErrorRenderer(string $contentType, $errorRenderer): void
+ {
+ $this->defaultErrorRendererContentType = $contentType;
+ $this->defaultErrorRenderer = $errorRenderer;
+ }
+
+ /**
+ * Set the renderer for the error logger
+ *
+ * @param ErrorRendererInterface|string|callable $logErrorRenderer
+ */
+ public function setLogErrorRenderer($logErrorRenderer): void
+ {
+ $this->logErrorRenderer = $logErrorRenderer;
+ }
+
+ /**
+ * Write to the error log if $logErrors has been set to true
+ */
+ protected function writeToErrorLog(): void
+ {
+ $renderer = $this->callableResolver->resolve($this->logErrorRenderer);
+ $error = $renderer($this->exception, $this->logErrorDetails);
+ if (!$this->displayErrorDetails) {
+ $error .= "\nTips: To display error details in HTTP response ";
+ $error .= 'set "displayErrorDetails" to true in the ErrorHandler constructor.';
+ }
+ $this->logError($error);
+ }
+
+ /**
+ * Wraps the error_log function so that this can be easily tested
+ */
+ protected function logError(string $error): void
+ {
+ $this->logger->error($error);
+ }
+
+ /**
+ * Returns a default logger implementation.
+ */
+ protected function getDefaultLogger(): LoggerInterface
+ {
+ return new Logger();
+ }
+
+ protected function respond(): ResponseInterface
+ {
+ $response = $this->responseFactory->createResponse($this->statusCode);
+ if ($this->contentType !== null && array_key_exists($this->contentType, $this->errorRenderers)) {
+ $response = $response->withHeader('Content-type', $this->contentType);
+ } else {
+ $response = $response->withHeader('Content-type', $this->defaultErrorRendererContentType);
+ }
+
+ if ($this->exception instanceof HttpMethodNotAllowedException) {
+ $allowedMethods = implode(', ', $this->exception->getAllowedMethods());
+ $response = $response->withHeader('Allow', $allowedMethods);
+ }
+
+ $renderer = $this->determineRenderer();
+ $body = call_user_func($renderer, $this->exception, $this->displayErrorDetails);
+ if ($body !== false) {
+ /** @var string $body */
+ $response->getBody()->write($body);
+ }
+
+ return $response;
+ }
+}
diff --git a/lib/slim/slim/Slim/Handlers/Strategies/RequestHandler.php b/lib/slim/slim/Slim/Handlers/Strategies/RequestHandler.php
new file mode 100644
index 00000000000..ea88a5f12dc
--- /dev/null
+++ b/lib/slim/slim/Slim/Handlers/Strategies/RequestHandler.php
@@ -0,0 +1,48 @@
+appendRouteArgumentsToRequestAttributes = $appendRouteArgumentsToRequestAttributes;
+ }
+
+ /**
+ * Invoke a route callable that implements RequestHandlerInterface
+ *
+ * @param array $routeArguments
+ */
+ public function __invoke(
+ callable $callable,
+ ServerRequestInterface $request,
+ ResponseInterface $response,
+ array $routeArguments
+ ): ResponseInterface {
+ if ($this->appendRouteArgumentsToRequestAttributes) {
+ foreach ($routeArguments as $k => $v) {
+ $request = $request->withAttribute($k, $v);
+ }
+ }
+
+ return $callable($request);
+ }
+}
diff --git a/lib/slim/slim/Slim/Handlers/Strategies/RequestResponse.php b/lib/slim/slim/Slim/Handlers/Strategies/RequestResponse.php
new file mode 100644
index 00000000000..45b2c05a497
--- /dev/null
+++ b/lib/slim/slim/Slim/Handlers/Strategies/RequestResponse.php
@@ -0,0 +1,40 @@
+ $routeArguments
+ */
+ public function __invoke(
+ callable $callable,
+ ServerRequestInterface $request,
+ ResponseInterface $response,
+ array $routeArguments
+ ): ResponseInterface {
+ foreach ($routeArguments as $k => $v) {
+ $request = $request->withAttribute($k, $v);
+ }
+
+ return $callable($request, $response, $routeArguments);
+ }
+}
diff --git a/lib/slim/slim/Slim/Handlers/Strategies/RequestResponseArgs.php b/lib/slim/slim/Slim/Handlers/Strategies/RequestResponseArgs.php
new file mode 100644
index 00000000000..c4ab16df574
--- /dev/null
+++ b/lib/slim/slim/Slim/Handlers/Strategies/RequestResponseArgs.php
@@ -0,0 +1,38 @@
+ $routeArguments
+ */
+ public function __invoke(
+ callable $callable,
+ ServerRequestInterface $request,
+ ResponseInterface $response,
+ array $routeArguments
+ ): ResponseInterface {
+ return $callable($request, $response, ...array_values($routeArguments));
+ }
+}
diff --git a/lib/slim/slim/Slim/Handlers/Strategies/RequestResponseNamedArgs.php b/lib/slim/slim/Slim/Handlers/Strategies/RequestResponseNamedArgs.php
new file mode 100644
index 00000000000..651111ddeb6
--- /dev/null
+++ b/lib/slim/slim/Slim/Handlers/Strategies/RequestResponseNamedArgs.php
@@ -0,0 +1,44 @@
+= 8.0.0');
+ }
+ }
+
+ /**
+ * Invoke a route callable with request, response and all route parameters
+ * as individual arguments.
+ *
+ * @param array $routeArguments
+ */
+ public function __invoke(
+ callable $callable,
+ ServerRequestInterface $request,
+ ResponseInterface $response,
+ array $routeArguments
+ ): ResponseInterface {
+ return $callable($request, $response, ...$routeArguments);
+ }
+}
diff --git a/lib/slim/slim/Slim/Interfaces/AdvancedCallableResolverInterface.php b/lib/slim/slim/Slim/Interfaces/AdvancedCallableResolverInterface.php
new file mode 100644
index 00000000000..aa1d897de61
--- /dev/null
+++ b/lib/slim/slim/Slim/Interfaces/AdvancedCallableResolverInterface.php
@@ -0,0 +1,28 @@
+ $routeArguments The route's placeholder arguments
+ *
+ * @return ResponseInterface The response from the callable.
+ */
+ public function __invoke(
+ callable $callable,
+ ServerRequestInterface $request,
+ ResponseInterface $response,
+ array $routeArguments
+ ): ResponseInterface;
+}
diff --git a/lib/slim/slim/Slim/Interfaces/MiddlewareDispatcherInterface.php b/lib/slim/slim/Slim/Interfaces/MiddlewareDispatcherInterface.php
new file mode 100644
index 00000000000..aa7a26ad27e
--- /dev/null
+++ b/lib/slim/slim/Slim/Interfaces/MiddlewareDispatcherInterface.php
@@ -0,0 +1,42 @@
+
+ */
+ public function getArguments(): array;
+
+ /**
+ * Set a route argument
+ */
+ public function setArgument(string $name, string $value): RouteInterface;
+
+ /**
+ * Replace route arguments
+ *
+ * @param array $arguments
+ */
+ public function setArguments(array $arguments): self;
+
+ /**
+ * @param MiddlewareInterface|string|callable $middleware
+ */
+ public function add($middleware): self;
+
+ public function addMiddleware(MiddlewareInterface $middleware): self;
+
+ /**
+ * Prepare the route for use
+ *
+ * @param array $arguments
+ */
+ public function prepare(array $arguments): self;
+
+ /**
+ * Run route
+ *
+ * This method traverses the middleware stack, including the route's callable
+ * and captures the resultant HTTP response object. It then sends the response
+ * back to the Application.
+ */
+ public function run(ServerRequestInterface $request): ResponseInterface;
+}
diff --git a/lib/slim/slim/Slim/Interfaces/RouteParserInterface.php b/lib/slim/slim/Slim/Interfaces/RouteParserInterface.php
new file mode 100644
index 00000000000..03d93266519
--- /dev/null
+++ b/lib/slim/slim/Slim/Interfaces/RouteParserInterface.php
@@ -0,0 +1,52 @@
+ $data Named argument replacement data
+ * @param array $queryParams Optional query string parameters
+ *
+ * @throws RuntimeException If named route does not exist
+ * @throws InvalidArgumentException If required data not provided
+ */
+ public function relativeUrlFor(string $routeName, array $data = [], array $queryParams = []): string;
+
+ /**
+ * Build the path for a named route including the base path
+ *
+ * @param string $routeName Route name
+ * @param array $data Named argument replacement data
+ * @param array $queryParams Optional query string parameters
+ *
+ * @throws RuntimeException If named route does not exist
+ * @throws InvalidArgumentException If required data not provided
+ */
+ public function urlFor(string $routeName, array $data = [], array $queryParams = []): string;
+
+ /**
+ * Get fully qualified URL for named route
+ *
+ * @param UriInterface $uri
+ * @param string $routeName Route name
+ * @param array $data Named argument replacement data
+ * @param array $queryParams Optional query string parameters
+ */
+ public function fullUrlFor(UriInterface $uri, string $routeName, array $data = [], array $queryParams = []): string;
+}
diff --git a/lib/slim/slim/Slim/Interfaces/RouteResolverInterface.php b/lib/slim/slim/Slim/Interfaces/RouteResolverInterface.php
new file mode 100644
index 00000000000..256a3599759
--- /dev/null
+++ b/lib/slim/slim/Slim/Interfaces/RouteResolverInterface.php
@@ -0,0 +1,17 @@
+getPath()
+ */
+ public function computeRoutingResults(string $uri, string $method): RoutingResults;
+
+ public function resolveRoute(string $identifier): RouteInterface;
+}
diff --git a/lib/slim/slim/Slim/Interfaces/ServerRequestCreatorInterface.php b/lib/slim/slim/Slim/Interfaces/ServerRequestCreatorInterface.php
new file mode 100644
index 00000000000..54d231edd5f
--- /dev/null
+++ b/lib/slim/slim/Slim/Interfaces/ServerRequestCreatorInterface.php
@@ -0,0 +1,18 @@
+ $context
+ *
+ * @throws InvalidArgumentException
+ */
+ public function log($level, $message, array $context = []): void
+ {
+ error_log((string) $message);
+ }
+}
diff --git a/lib/slim/slim/Slim/Middleware/BodyParsingMiddleware.php b/lib/slim/slim/Slim/Middleware/BodyParsingMiddleware.php
new file mode 100644
index 00000000000..9a90f30c4a5
--- /dev/null
+++ b/lib/slim/slim/Slim/Middleware/BodyParsingMiddleware.php
@@ -0,0 +1,196 @@
+ callable
+ */
+ public function __construct(array $bodyParsers = [])
+ {
+ $this->registerDefaultBodyParsers();
+
+ foreach ($bodyParsers as $mediaType => $parser) {
+ $this->registerBodyParser($mediaType, $parser);
+ }
+ }
+
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+ {
+ $parsedBody = $request->getParsedBody();
+
+ if (empty($parsedBody)) {
+ $parsedBody = $this->parseBody($request);
+ $request = $request->withParsedBody($parsedBody);
+ }
+
+ return $handler->handle($request);
+ }
+
+ /**
+ * @param string $mediaType A HTTP media type (excluding content-type params).
+ * @param callable $callable A callable that returns parsed contents for media type.
+ */
+ public function registerBodyParser(string $mediaType, callable $callable): self
+ {
+ $this->bodyParsers[$mediaType] = $callable;
+ return $this;
+ }
+
+ /**
+ * @param string $mediaType A HTTP media type (excluding content-type params).
+ */
+ public function hasBodyParser(string $mediaType): bool
+ {
+ return isset($this->bodyParsers[$mediaType]);
+ }
+
+ /**
+ * @param string $mediaType A HTTP media type (excluding content-type params).
+ * @throws RuntimeException
+ */
+ public function getBodyParser(string $mediaType): callable
+ {
+ if (!isset($this->bodyParsers[$mediaType])) {
+ throw new RuntimeException('No parser for type ' . $mediaType);
+ }
+ return $this->bodyParsers[$mediaType];
+ }
+
+ protected function registerDefaultBodyParsers(): void
+ {
+ $this->registerBodyParser('application/json', static function ($input) {
+ $result = json_decode($input, true);
+
+ if (!is_array($result)) {
+ return null;
+ }
+
+ return $result;
+ });
+
+ $this->registerBodyParser('application/x-www-form-urlencoded', static function ($input) {
+ parse_str($input, $data);
+ return $data;
+ });
+
+ $xmlCallable = static function ($input) {
+ $backup = self::disableXmlEntityLoader(true);
+ $backup_errors = libxml_use_internal_errors(true);
+ $result = simplexml_load_string($input);
+
+ self::disableXmlEntityLoader($backup);
+ libxml_clear_errors();
+ libxml_use_internal_errors($backup_errors);
+
+ if ($result === false) {
+ return null;
+ }
+
+ return $result;
+ };
+
+ $this->registerBodyParser('application/xml', $xmlCallable);
+ $this->registerBodyParser('text/xml', $xmlCallable);
+ }
+
+ /**
+ * @return null|array|object
+ */
+ protected function parseBody(ServerRequestInterface $request)
+ {
+ $mediaType = $this->getMediaType($request);
+ if ($mediaType === null) {
+ return null;
+ }
+
+ // Check if this specific media type has a parser registered first
+ if (!isset($this->bodyParsers[$mediaType])) {
+ // If not, look for a media type with a structured syntax suffix (RFC 6839)
+ $parts = explode('+', $mediaType);
+ if (count($parts) >= 2) {
+ $mediaType = 'application/' . $parts[count($parts) - 1];
+ }
+ }
+
+ if (isset($this->bodyParsers[$mediaType])) {
+ $body = (string)$request->getBody();
+ $parsed = $this->bodyParsers[$mediaType]($body);
+
+ if ($parsed !== null && !is_object($parsed) && !is_array($parsed)) {
+ throw new RuntimeException(
+ 'Request body media type parser return value must be an array, an object, or null'
+ );
+ }
+
+ return $parsed;
+ }
+
+ return null;
+ }
+
+ /**
+ * @return string|null The serverRequest media type, minus content-type params
+ */
+ protected function getMediaType(ServerRequestInterface $request): ?string
+ {
+ $contentType = $request->getHeader('Content-Type')[0] ?? null;
+
+ if (is_string($contentType) && trim($contentType) !== '') {
+ $contentTypeParts = explode(';', $contentType);
+ return strtolower(trim($contentTypeParts[0]));
+ }
+
+ return null;
+ }
+
+ protected static function disableXmlEntityLoader(bool $disable): bool
+ {
+ if (LIBXML_VERSION >= 20900) {
+ // libxml >= 2.9.0 disables entity loading by default, so it is
+ // safe to skip the real call (deprecated in PHP 8).
+ return true;
+ }
+
+ // @codeCoverageIgnoreStart
+ return libxml_disable_entity_loader($disable);
+ // @codeCoverageIgnoreEnd
+ }
+}
diff --git a/lib/slim/slim/Slim/Middleware/ContentLengthMiddleware.php b/lib/slim/slim/Slim/Middleware/ContentLengthMiddleware.php
new file mode 100644
index 00000000000..8fa13bcf779
--- /dev/null
+++ b/lib/slim/slim/Slim/Middleware/ContentLengthMiddleware.php
@@ -0,0 +1,32 @@
+handle($request);
+
+ // Add Content-Length header if not already added
+ $size = $response->getBody()->getSize();
+ if ($size !== null && !$response->hasHeader('Content-Length')) {
+ $response = $response->withHeader('Content-Length', (string) $size);
+ }
+
+ return $response;
+ }
+}
diff --git a/lib/slim/slim/Slim/Middleware/ErrorMiddleware.php b/lib/slim/slim/Slim/Middleware/ErrorMiddleware.php
new file mode 100644
index 00000000000..2eb5cc96ffa
--- /dev/null
+++ b/lib/slim/slim/Slim/Middleware/ErrorMiddleware.php
@@ -0,0 +1,212 @@
+callableResolver = $callableResolver;
+ $this->responseFactory = $responseFactory;
+ $this->displayErrorDetails = $displayErrorDetails;
+ $this->logErrors = $logErrors;
+ $this->logErrorDetails = $logErrorDetails;
+ $this->logger = $logger;
+ }
+
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+ {
+ try {
+ return $handler->handle($request);
+ } catch (Throwable $e) {
+ return $this->handleException($request, $e);
+ }
+ }
+
+ public function handleException(ServerRequestInterface $request, Throwable $exception): ResponseInterface
+ {
+ if ($exception instanceof HttpException) {
+ $request = $exception->getRequest();
+ }
+
+ $exceptionType = get_class($exception);
+ $handler = $this->getErrorHandler($exceptionType);
+
+ return $handler($request, $exception, $this->displayErrorDetails, $this->logErrors, $this->logErrorDetails);
+ }
+
+ /**
+ * Get callable to handle scenarios where an error
+ * occurs when processing the current request.
+ *
+ * @param string $type Exception/Throwable name. ie: RuntimeException::class
+ * @return callable|ErrorHandler
+ */
+ public function getErrorHandler(string $type)
+ {
+ if (isset($this->handlers[$type])) {
+ return $this->callableResolver->resolve($this->handlers[$type]);
+ }
+
+ if (isset($this->subClassHandlers[$type])) {
+ return $this->callableResolver->resolve($this->subClassHandlers[$type]);
+ }
+
+ foreach ($this->subClassHandlers as $class => $handler) {
+ if (is_subclass_of($type, $class)) {
+ return $this->callableResolver->resolve($handler);
+ }
+ }
+
+ return $this->getDefaultErrorHandler();
+ }
+
+ /**
+ * Get default error handler
+ *
+ * @return ErrorHandler|callable
+ */
+ public function getDefaultErrorHandler()
+ {
+ if ($this->defaultErrorHandler === null) {
+ $this->defaultErrorHandler = new ErrorHandler(
+ $this->callableResolver,
+ $this->responseFactory,
+ $this->logger
+ );
+ }
+
+ return $this->callableResolver->resolve($this->defaultErrorHandler);
+ }
+
+ /**
+ * Set callable as the default Slim application error handler.
+ *
+ * The callable signature MUST match the ErrorHandlerInterface
+ *
+ * @see \Slim\Interfaces\ErrorHandlerInterface
+ *
+ * 1. Instance of \Psr\Http\Message\ServerRequestInterface
+ * 2. Instance of \Throwable
+ * 3. Boolean $displayErrorDetails
+ * 4. Boolean $logErrors
+ * 5. Boolean $logErrorDetails
+ *
+ * The callable MUST return an instance of
+ * \Psr\Http\Message\ResponseInterface.
+ *
+ * @param string|callable|ErrorHandler $handler
+ */
+ public function setDefaultErrorHandler($handler): self
+ {
+ $this->defaultErrorHandler = $handler;
+ return $this;
+ }
+
+ /**
+ * Set callable to handle scenarios where an error
+ * occurs when processing the current request.
+ *
+ * The callable signature MUST match the ErrorHandlerInterface
+ *
+ * Pass true to $handleSubclasses to make the handler handle all subclasses of
+ * the type as well. Pass an array of classes to make the same function handle multiple exceptions.
+ *
+ * @see \Slim\Interfaces\ErrorHandlerInterface
+ *
+ * 1. Instance of \Psr\Http\Message\ServerRequestInterface
+ * 2. Instance of \Throwable
+ * 3. Boolean $displayErrorDetails
+ * 4. Boolean $logErrors
+ * 5. Boolean $logErrorDetails
+ *
+ * The callable MUST return an instance of
+ * \Psr\Http\Message\ResponseInterface.
+ *
+ * @param string|string[] $typeOrTypes Exception/Throwable name.
+ * ie: RuntimeException::class or an array of classes
+ * ie: [HttpNotFoundException::class, HttpMethodNotAllowedException::class]
+ * @param string|callable|ErrorHandlerInterface $handler
+ */
+ public function setErrorHandler($typeOrTypes, $handler, bool $handleSubclasses = false): self
+ {
+ if (is_array($typeOrTypes)) {
+ foreach ($typeOrTypes as $type) {
+ $this->addErrorHandler($type, $handler, $handleSubclasses);
+ }
+ } else {
+ $this->addErrorHandler($typeOrTypes, $handler, $handleSubclasses);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Used internally to avoid code repetition when passing multiple exceptions to setErrorHandler().
+ * @param string|callable|ErrorHandlerInterface $handler
+ */
+ private function addErrorHandler(string $type, $handler, bool $handleSubclasses): void
+ {
+ if ($handleSubclasses) {
+ $this->subClassHandlers[$type] = $handler;
+ } else {
+ $this->handlers[$type] = $handler;
+ }
+ }
+}
diff --git a/lib/slim/slim/Slim/Middleware/MethodOverrideMiddleware.php b/lib/slim/slim/Slim/Middleware/MethodOverrideMiddleware.php
new file mode 100644
index 00000000000..079a1f18988
--- /dev/null
+++ b/lib/slim/slim/Slim/Middleware/MethodOverrideMiddleware.php
@@ -0,0 +1,43 @@
+getHeaderLine('X-Http-Method-Override');
+
+ if ($methodHeader) {
+ $request = $request->withMethod($methodHeader);
+ } elseif (strtoupper($request->getMethod()) === 'POST') {
+ $body = $request->getParsedBody();
+
+ if (is_array($body) && !empty($body['_METHOD'])) {
+ $request = $request->withMethod($body['_METHOD']);
+ }
+
+ if ($request->getBody()->eof()) {
+ $request->getBody()->rewind();
+ }
+ }
+
+ return $handler->handle($request);
+ }
+}
diff --git a/lib/slim/slim/Slim/Middleware/OutputBufferingMiddleware.php b/lib/slim/slim/Slim/Middleware/OutputBufferingMiddleware.php
new file mode 100644
index 00000000000..69ee1f6f1fd
--- /dev/null
+++ b/lib/slim/slim/Slim/Middleware/OutputBufferingMiddleware.php
@@ -0,0 +1,74 @@
+streamFactory = $streamFactory;
+ $this->style = $style;
+
+ if (!in_array($style, [static::APPEND, static::PREPEND], true)) {
+ throw new InvalidArgumentException("Invalid style `{$style}`. Must be `append` or `prepend`");
+ }
+ }
+
+ /**
+ * @throws Throwable
+ */
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+ {
+ try {
+ ob_start();
+ $response = $handler->handle($request);
+ $output = ob_get_clean();
+ } catch (Throwable $e) {
+ ob_end_clean();
+ throw $e;
+ }
+
+ if (!empty($output)) {
+ if ($this->style === static::PREPEND) {
+ $body = $this->streamFactory->createStream();
+ $body->write($output . $response->getBody());
+ $response = $response->withBody($body);
+ } elseif ($this->style === static::APPEND && $response->getBody()->isWritable()) {
+ $response->getBody()->write($output);
+ }
+ }
+
+ return $response;
+ }
+}
diff --git a/lib/slim/slim/Slim/Middleware/RoutingMiddleware.php b/lib/slim/slim/Slim/Middleware/RoutingMiddleware.php
new file mode 100644
index 00000000000..a3d3085bd4b
--- /dev/null
+++ b/lib/slim/slim/Slim/Middleware/RoutingMiddleware.php
@@ -0,0 +1,98 @@
+routeResolver = $routeResolver;
+ $this->routeParser = $routeParser;
+ }
+
+ /**
+ * @throws HttpNotFoundException
+ * @throws HttpMethodNotAllowedException
+ * @throws RuntimeException
+ */
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+ {
+ $request = $this->performRouting($request);
+ return $handler->handle($request);
+ }
+
+ /**
+ * Perform routing
+ *
+ * @param ServerRequestInterface $request PSR7 Server Request
+ *
+ * @throws HttpNotFoundException
+ * @throws HttpMethodNotAllowedException
+ * @throws RuntimeException
+ */
+ public function performRouting(ServerRequestInterface $request): ServerRequestInterface
+ {
+ $request = $request->withAttribute(RouteContext::ROUTE_PARSER, $this->routeParser);
+
+ $routingResults = $this->resolveRoutingResultsFromRequest($request);
+ $routeStatus = $routingResults->getRouteStatus();
+
+ $request = $request->withAttribute(RouteContext::ROUTING_RESULTS, $routingResults);
+
+ switch ($routeStatus) {
+ case RoutingResults::FOUND:
+ $routeArguments = $routingResults->getRouteArguments();
+ $routeIdentifier = $routingResults->getRouteIdentifier() ?? '';
+ $route = $this->routeResolver
+ ->resolveRoute($routeIdentifier)
+ ->prepare($routeArguments);
+ return $request->withAttribute(RouteContext::ROUTE, $route);
+
+ case RoutingResults::NOT_FOUND:
+ throw new HttpNotFoundException($request);
+
+ case RoutingResults::METHOD_NOT_ALLOWED:
+ $exception = new HttpMethodNotAllowedException($request);
+ $exception->setAllowedMethods($routingResults->getAllowedMethods());
+ throw $exception;
+
+ default:
+ throw new RuntimeException('An unexpected error occurred while performing routing.');
+ }
+ }
+
+ /**
+ * Resolves the route from the given request
+ */
+ protected function resolveRoutingResultsFromRequest(ServerRequestInterface $request): RoutingResults
+ {
+ return $this->routeResolver->computeRoutingResults(
+ $request->getUri()->getPath(),
+ $request->getMethod()
+ );
+ }
+}
diff --git a/lib/slim/slim/Slim/MiddlewareDispatcher.php b/lib/slim/slim/Slim/MiddlewareDispatcher.php
new file mode 100644
index 00000000000..7e05644142c
--- /dev/null
+++ b/lib/slim/slim/Slim/MiddlewareDispatcher.php
@@ -0,0 +1,275 @@
+seedMiddlewareStack($kernel);
+ $this->callableResolver = $callableResolver;
+ $this->container = $container;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function seedMiddlewareStack(RequestHandlerInterface $kernel): void
+ {
+ $this->tip = $kernel;
+ }
+
+ /**
+ * Invoke the middleware stack
+ */
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ return $this->tip->handle($request);
+ }
+
+ /**
+ * Add a new middleware to the stack
+ *
+ * Middleware are organized as a stack. That means middleware
+ * that have been added before will be executed after the newly
+ * added one (last in, first out).
+ *
+ * @param MiddlewareInterface|string|callable $middleware
+ */
+ public function add($middleware): MiddlewareDispatcherInterface
+ {
+ if ($middleware instanceof MiddlewareInterface) {
+ return $this->addMiddleware($middleware);
+ }
+
+ if (is_string($middleware)) {
+ return $this->addDeferred($middleware);
+ }
+
+ if (is_callable($middleware)) {
+ return $this->addCallable($middleware);
+ }
+
+ /** @phpstan-ignore-next-line */
+ throw new RuntimeException(
+ 'A middleware must be an object/class name referencing an implementation of ' .
+ 'MiddlewareInterface or a callable with a matching signature.'
+ );
+ }
+
+ /**
+ * Add a new middleware to the stack
+ *
+ * Middleware are organized as a stack. That means middleware
+ * that have been added before will be executed after the newly
+ * added one (last in, first out).
+ */
+ public function addMiddleware(MiddlewareInterface $middleware): MiddlewareDispatcherInterface
+ {
+ $next = $this->tip;
+ $this->tip = new class ($middleware, $next) implements RequestHandlerInterface {
+ private MiddlewareInterface $middleware;
+
+ private RequestHandlerInterface $next;
+
+ public function __construct(MiddlewareInterface $middleware, RequestHandlerInterface $next)
+ {
+ $this->middleware = $middleware;
+ $this->next = $next;
+ }
+
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ return $this->middleware->process($request, $this->next);
+ }
+ };
+
+ return $this;
+ }
+
+ /**
+ * Add a new middleware by class name
+ *
+ * Middleware are organized as a stack. That means middleware
+ * that have been added before will be executed after the newly
+ * added one (last in, first out).
+ */
+ public function addDeferred(string $middleware): self
+ {
+ $next = $this->tip;
+ $this->tip = new class (
+ $middleware,
+ $next,
+ $this->container,
+ $this->callableResolver
+ ) implements RequestHandlerInterface {
+ private string $middleware;
+
+ private RequestHandlerInterface $next;
+
+ private ?ContainerInterface $container;
+
+ private ?CallableResolverInterface $callableResolver;
+
+ public function __construct(
+ string $middleware,
+ RequestHandlerInterface $next,
+ ?ContainerInterface $container = null,
+ ?CallableResolverInterface $callableResolver = null
+ ) {
+ $this->middleware = $middleware;
+ $this->next = $next;
+ $this->container = $container;
+ $this->callableResolver = $callableResolver;
+ }
+
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ if ($this->callableResolver instanceof AdvancedCallableResolverInterface) {
+ $callable = $this->callableResolver->resolveMiddleware($this->middleware);
+ return $callable($request, $this->next);
+ }
+
+ $callable = null;
+
+ if ($this->callableResolver instanceof CallableResolverInterface) {
+ try {
+ $callable = $this->callableResolver->resolve($this->middleware);
+ } catch (RuntimeException $e) {
+ // Do Nothing
+ }
+ }
+
+ if (!$callable) {
+ $resolved = $this->middleware;
+ $instance = null;
+ $method = null;
+
+ // Check for Slim callable as `class:method`
+ if (preg_match(CallableResolver::$callablePattern, $resolved, $matches)) {
+ $resolved = $matches[1];
+ $method = $matches[2];
+ }
+
+ if ($this->container && $this->container->has($resolved)) {
+ $instance = $this->container->get($resolved);
+ if ($instance instanceof MiddlewareInterface) {
+ return $instance->process($request, $this->next);
+ }
+ } elseif (!function_exists($resolved)) {
+ if (!class_exists($resolved)) {
+ throw new RuntimeException(sprintf('Middleware %s does not exist', $resolved));
+ }
+ $instance = new $resolved($this->container);
+ }
+
+ if ($instance && $instance instanceof MiddlewareInterface) {
+ return $instance->process($request, $this->next);
+ }
+
+ $callable = $instance ?? $resolved;
+ if ($instance && $method) {
+ $callable = [$instance, $method];
+ }
+
+ if ($this->container && $callable instanceof Closure) {
+ $callable = $callable->bindTo($this->container);
+ }
+ }
+
+ if (!is_callable($callable)) {
+ throw new RuntimeException(
+ sprintf(
+ 'Middleware %s is not resolvable',
+ $this->middleware
+ )
+ );
+ }
+
+ return $callable($request, $this->next);
+ }
+ };
+
+ return $this;
+ }
+
+ /**
+ * Add a (non-standard) callable middleware to the stack
+ *
+ * Middleware are organized as a stack. That means middleware
+ * that have been added before will be executed after the newly
+ * added one (last in, first out).
+ */
+ public function addCallable(callable $middleware): self
+ {
+ $next = $this->tip;
+
+ if ($this->container && $middleware instanceof Closure) {
+ /** @var Closure $middleware */
+ $middleware = $middleware->bindTo($this->container);
+ }
+
+ $this->tip = new class ($middleware, $next) implements RequestHandlerInterface {
+ /**
+ * @var callable
+ */
+ private $middleware;
+
+ /**
+ * @var RequestHandlerInterface
+ */
+ private $next;
+
+ public function __construct(callable $middleware, RequestHandlerInterface $next)
+ {
+ $this->middleware = $middleware;
+ $this->next = $next;
+ }
+
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ return ($this->middleware)($request, $this->next);
+ }
+ };
+
+ return $this;
+ }
+}
diff --git a/lib/slim/slim/Slim/ResponseEmitter.php b/lib/slim/slim/Slim/ResponseEmitter.php
new file mode 100644
index 00000000000..fac36e9e7c7
--- /dev/null
+++ b/lib/slim/slim/Slim/ResponseEmitter.php
@@ -0,0 +1,136 @@
+responseChunkSize = $responseChunkSize;
+ }
+
+ /**
+ * Send the response the client
+ */
+ public function emit(ResponseInterface $response): void
+ {
+ $isEmpty = $this->isResponseEmpty($response);
+ if (headers_sent() === false) {
+ $this->emitHeaders($response);
+
+ // Set the status _after_ the headers, because of PHP's "helpful" behavior with location headers.
+ // See https://github.com/slimphp/Slim/issues/1730
+
+ $this->emitStatusLine($response);
+ }
+
+ if (!$isEmpty) {
+ $this->emitBody($response);
+ }
+ }
+
+ /**
+ * Emit Response Headers
+ */
+ private function emitHeaders(ResponseInterface $response): void
+ {
+ foreach ($response->getHeaders() as $name => $values) {
+ $first = strtolower($name) !== 'set-cookie';
+ foreach ($values as $value) {
+ $header = sprintf('%s: %s', $name, $value);
+ header($header, $first);
+ $first = false;
+ }
+ }
+ }
+
+ /**
+ * Emit Status Line
+ */
+ private function emitStatusLine(ResponseInterface $response): void
+ {
+ $statusLine = sprintf(
+ 'HTTP/%s %s %s',
+ $response->getProtocolVersion(),
+ $response->getStatusCode(),
+ $response->getReasonPhrase()
+ );
+ header($statusLine, true, $response->getStatusCode());
+ }
+
+ /**
+ * Emit Body
+ */
+ private function emitBody(ResponseInterface $response): void
+ {
+ $body = $response->getBody();
+ if ($body->isSeekable()) {
+ $body->rewind();
+ }
+
+ $amountToRead = (int) $response->getHeaderLine('Content-Length');
+ if (!$amountToRead) {
+ $amountToRead = $body->getSize();
+ }
+
+ if ($amountToRead) {
+ while ($amountToRead > 0 && !$body->eof()) {
+ $length = min($this->responseChunkSize, $amountToRead);
+ $data = $body->read($length);
+ echo $data;
+
+ $amountToRead -= strlen($data);
+
+ if (connection_status() !== CONNECTION_NORMAL) {
+ break;
+ }
+ }
+ } else {
+ while (!$body->eof()) {
+ echo $body->read($this->responseChunkSize);
+ if (connection_status() !== CONNECTION_NORMAL) {
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Asserts response body is empty or status code is 204, 205 or 304
+ */
+ public function isResponseEmpty(ResponseInterface $response): bool
+ {
+ if (in_array($response->getStatusCode(), [204, 205, 304], true)) {
+ return true;
+ }
+ $stream = $response->getBody();
+ $seekable = $stream->isSeekable();
+ if ($seekable) {
+ $stream->rewind();
+ }
+ return $seekable ? $stream->read(1) === '' : $stream->eof();
+ }
+}
diff --git a/lib/slim/slim/Slim/Routing/Dispatcher.php b/lib/slim/slim/Slim/Routing/Dispatcher.php
new file mode 100644
index 00000000000..e33eac39644
--- /dev/null
+++ b/lib/slim/slim/Slim/Routing/Dispatcher.php
@@ -0,0 +1,78 @@
+routeCollector = $routeCollector;
+ }
+
+ protected function createDispatcher(): FastRouteDispatcher
+ {
+ if ($this->dispatcher) {
+ return $this->dispatcher;
+ }
+
+ $routeDefinitionCallback = function (FastRouteCollector $r): void {
+ $basePath = $this->routeCollector->getBasePath();
+
+ foreach ($this->routeCollector->getRoutes() as $route) {
+ $r->addRoute($route->getMethods(), $basePath . $route->getPattern(), $route->getIdentifier());
+ }
+ };
+
+ $cacheFile = $this->routeCollector->getCacheFile();
+ if ($cacheFile) {
+ /** @var FastRouteDispatcher $dispatcher */
+ $dispatcher = \FastRoute\cachedDispatcher($routeDefinitionCallback, [
+ 'dataGenerator' => GroupCountBased::class,
+ 'dispatcher' => FastRouteDispatcher::class,
+ 'routeParser' => new Std(),
+ 'cacheFile' => $cacheFile,
+ ]);
+ } else {
+ /** @var FastRouteDispatcher $dispatcher */
+ $dispatcher = \FastRoute\simpleDispatcher($routeDefinitionCallback, [
+ 'dataGenerator' => GroupCountBased::class,
+ 'dispatcher' => FastRouteDispatcher::class,
+ 'routeParser' => new Std(),
+ ]);
+ }
+
+ $this->dispatcher = $dispatcher;
+ return $this->dispatcher;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function dispatch(string $method, string $uri): RoutingResults
+ {
+ $dispatcher = $this->createDispatcher();
+ $results = $dispatcher->dispatch($method, $uri);
+ return new RoutingResults($this, $method, $uri, $results[0], $results[1], $results[2]);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAllowedMethods(string $uri): array
+ {
+ $dispatcher = $this->createDispatcher();
+ return $dispatcher->getAllowedMethods($uri);
+ }
+}
diff --git a/lib/slim/slim/Slim/Routing/FastRouteDispatcher.php b/lib/slim/slim/Slim/Routing/FastRouteDispatcher.php
new file mode 100644
index 00000000000..797746bbb10
--- /dev/null
+++ b/lib/slim/slim/Slim/Routing/FastRouteDispatcher.php
@@ -0,0 +1,109 @@
+}
+ */
+ public function dispatch($httpMethod, $uri): array
+ {
+ $routingResults = $this->routingResults($httpMethod, $uri);
+ if ($routingResults[0] === self::FOUND) {
+ return $routingResults;
+ }
+
+ // For HEAD requests, attempt fallback to GET
+ if ($httpMethod === 'HEAD') {
+ $routingResults = $this->routingResults('GET', $uri);
+ if ($routingResults[0] === self::FOUND) {
+ return $routingResults;
+ }
+ }
+
+ // If nothing else matches, try fallback routes
+ $routingResults = $this->routingResults('*', $uri);
+ if ($routingResults[0] === self::FOUND) {
+ return $routingResults;
+ }
+
+ if (!empty($this->getAllowedMethods($uri))) {
+ return [self::METHOD_NOT_ALLOWED, null, []];
+ }
+
+ return [self::NOT_FOUND, null, []];
+ }
+
+ /**
+ * @param string $httpMethod
+ * @param string $uri
+ *
+ * @return array{int, string|null, array}
+ */
+ private function routingResults(string $httpMethod, string $uri): array
+ {
+ if (isset($this->staticRouteMap[$httpMethod][$uri])) {
+ /** @var string $routeIdentifier */
+ $routeIdentifier = $this->staticRouteMap[$httpMethod][$uri];
+ return [self::FOUND, $routeIdentifier, []];
+ }
+
+ if (isset($this->variableRouteData[$httpMethod])) {
+ /** @var array{0: int, 1?: string, 2?: array} $result */
+ $result = $this->dispatchVariableRoute($this->variableRouteData[$httpMethod], $uri);
+ if ($result[0] === self::FOUND) {
+ /** @var array{int, string, array} $result */
+ return [self::FOUND, $result[1], $result[2]];
+ }
+ }
+
+ return [self::NOT_FOUND, null, []];
+ }
+
+ /**
+ * @param string $uri
+ *
+ * @return string[]
+ */
+ public function getAllowedMethods(string $uri): array
+ {
+ if (isset($this->allowedMethods[$uri])) {
+ return $this->allowedMethods[$uri];
+ }
+
+ $allowedMethods = [];
+ foreach ($this->staticRouteMap as $method => $uriMap) {
+ if (isset($uriMap[$uri])) {
+ $allowedMethods[$method] = true;
+ }
+ }
+
+ foreach ($this->variableRouteData as $method => $routeData) {
+ $result = $this->dispatchVariableRoute($routeData, $uri);
+ if ($result[0] === self::FOUND) {
+ $allowedMethods[$method] = true;
+ }
+ }
+
+ return $this->allowedMethods[$uri] = array_keys($allowedMethods);
+ }
+}
diff --git a/lib/slim/slim/Slim/Routing/Route.php b/lib/slim/slim/Slim/Routing/Route.php
new file mode 100644
index 00000000000..2cd9fa567ed
--- /dev/null
+++ b/lib/slim/slim/Slim/Routing/Route.php
@@ -0,0 +1,360 @@
+
+ */
+ protected array $arguments = [];
+
+ /**
+ * Route arguments parameters
+ *
+ * @var string[]
+ */
+ protected array $savedArguments = [];
+
+ /**
+ * Container
+ */
+ protected ?ContainerInterface $container = null;
+
+ protected MiddlewareDispatcher $middlewareDispatcher;
+
+ /**
+ * Route callable
+ *
+ * @var callable|string
+ */
+ protected $callable;
+
+ protected CallableResolverInterface $callableResolver;
+
+ protected ResponseFactoryInterface $responseFactory;
+
+ /**
+ * Route pattern
+ */
+ protected string $pattern;
+
+ protected bool $groupMiddlewareAppended = false;
+
+ /**
+ * @param string[] $methods The route HTTP methods
+ * @param string $pattern The route pattern
+ * @param callable|string $callable The route callable
+ * @param ResponseFactoryInterface $responseFactory
+ * @param CallableResolverInterface $callableResolver
+ * @param ContainerInterface|null $container
+ * @param InvocationStrategyInterface|null $invocationStrategy
+ * @param RouteGroupInterface[] $groups The parent route groups
+ * @param int $identifier The route identifier
+ */
+ public function __construct(
+ array $methods,
+ string $pattern,
+ $callable,
+ ResponseFactoryInterface $responseFactory,
+ CallableResolverInterface $callableResolver,
+ ?ContainerInterface $container = null,
+ ?InvocationStrategyInterface $invocationStrategy = null,
+ array $groups = [],
+ int $identifier = 0
+ ) {
+ $this->methods = $methods;
+ $this->pattern = $pattern;
+ $this->callable = $callable;
+ $this->responseFactory = $responseFactory;
+ $this->callableResolver = $callableResolver;
+ $this->container = $container;
+ $this->invocationStrategy = $invocationStrategy ?? new RequestResponse();
+ $this->groups = $groups;
+ $this->identifier = 'route' . $identifier;
+ $this->middlewareDispatcher = new MiddlewareDispatcher($this, $callableResolver, $container);
+ }
+
+ public function getCallableResolver(): CallableResolverInterface
+ {
+ return $this->callableResolver;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getInvocationStrategy(): InvocationStrategyInterface
+ {
+ return $this->invocationStrategy;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setInvocationStrategy(InvocationStrategyInterface $invocationStrategy): RouteInterface
+ {
+ $this->invocationStrategy = $invocationStrategy;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getMethods(): array
+ {
+ return $this->methods;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getPattern(): string
+ {
+ return $this->pattern;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setPattern(string $pattern): RouteInterface
+ {
+ $this->pattern = $pattern;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCallable()
+ {
+ return $this->callable;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setCallable($callable): RouteInterface
+ {
+ $this->callable = $callable;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setName(string $name): RouteInterface
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getIdentifier(): string
+ {
+ return $this->identifier;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getArgument(string $name, ?string $default = null): ?string
+ {
+ if (array_key_exists($name, $this->arguments)) {
+ return $this->arguments[$name];
+ }
+ return $default;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getArguments(): array
+ {
+ return $this->arguments;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setArguments(array $arguments, bool $includeInSavedArguments = true): RouteInterface
+ {
+ if ($includeInSavedArguments) {
+ $this->savedArguments = $arguments;
+ }
+
+ $this->arguments = $arguments;
+ return $this;
+ }
+
+ /**
+ * @return RouteGroupInterface[]
+ */
+ public function getGroups(): array
+ {
+ return $this->groups;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function add($middleware): RouteInterface
+ {
+ $this->middlewareDispatcher->add($middleware);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function addMiddleware(MiddlewareInterface $middleware): RouteInterface
+ {
+ $this->middlewareDispatcher->addMiddleware($middleware);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function prepare(array $arguments): RouteInterface
+ {
+ $this->arguments = array_replace($this->savedArguments, $arguments);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setArgument(string $name, string $value, bool $includeInSavedArguments = true): RouteInterface
+ {
+ if ($includeInSavedArguments) {
+ $this->savedArguments[$name] = $value;
+ }
+
+ $this->arguments[$name] = $value;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function run(ServerRequestInterface $request): ResponseInterface
+ {
+ if (!$this->groupMiddlewareAppended) {
+ $this->appendGroupMiddlewareToRoute();
+ }
+
+ return $this->middlewareDispatcher->handle($request);
+ }
+
+ /**
+ * @return void
+ */
+ protected function appendGroupMiddlewareToRoute(): void
+ {
+ $inner = $this->middlewareDispatcher;
+ $this->middlewareDispatcher = new MiddlewareDispatcher($inner, $this->callableResolver, $this->container);
+
+ /** @var RouteGroupInterface $group */
+ foreach (array_reverse($this->groups) as $group) {
+ $group->appendMiddlewareToDispatcher($this->middlewareDispatcher);
+ }
+
+ $this->groupMiddlewareAppended = true;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ if ($this->callableResolver instanceof AdvancedCallableResolverInterface) {
+ $callable = $this->callableResolver->resolveRoute($this->callable);
+ } else {
+ $callable = $this->callableResolver->resolve($this->callable);
+ }
+ $strategy = $this->invocationStrategy;
+
+ /** @var string[] $strategyImplements */
+ $strategyImplements = class_implements($strategy);
+
+ if (
+ is_array($callable)
+ && $callable[0] instanceof RequestHandlerInterface
+ && !in_array(RequestHandlerInvocationStrategyInterface::class, $strategyImplements)
+ ) {
+ $strategy = new RequestHandler();
+ }
+
+ $response = $this->responseFactory->createResponse();
+ return $strategy($callable, $request, $response, $this->arguments);
+ }
+}
diff --git a/lib/slim/slim/Slim/Routing/RouteCollector.php b/lib/slim/slim/Slim/Routing/RouteCollector.php
new file mode 100644
index 00000000000..61b450300b9
--- /dev/null
+++ b/lib/slim/slim/Slim/Routing/RouteCollector.php
@@ -0,0 +1,293 @@
+responseFactory = $responseFactory;
+ $this->callableResolver = $callableResolver;
+ $this->container = $container;
+ $this->defaultInvocationStrategy = $defaultInvocationStrategy ?? new RequestResponse();
+ $this->routeParser = $routeParser ?? new RouteParser($this);
+
+ if ($cacheFile) {
+ $this->setCacheFile($cacheFile);
+ }
+ }
+
+ public function getRouteParser(): RouteParserInterface
+ {
+ return $this->routeParser;
+ }
+
+ /**
+ * Get default route invocation strategy
+ */
+ public function getDefaultInvocationStrategy(): InvocationStrategyInterface
+ {
+ return $this->defaultInvocationStrategy;
+ }
+
+ public function setDefaultInvocationStrategy(InvocationStrategyInterface $strategy): RouteCollectorInterface
+ {
+ $this->defaultInvocationStrategy = $strategy;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCacheFile(): ?string
+ {
+ return $this->cacheFile;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setCacheFile(string $cacheFile): RouteCollectorInterface
+ {
+ if (file_exists($cacheFile) && !is_readable($cacheFile)) {
+ throw new RuntimeException(
+ sprintf('Route collector cache file `%s` is not readable', $cacheFile)
+ );
+ }
+
+ if (!file_exists($cacheFile) && !is_writable(dirname($cacheFile))) {
+ throw new RuntimeException(
+ sprintf('Route collector cache file directory `%s` is not writable', dirname($cacheFile))
+ );
+ }
+
+ $this->cacheFile = $cacheFile;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getBasePath(): string
+ {
+ return $this->basePath;
+ }
+
+ /**
+ * Set the base path used in urlFor()
+ */
+ public function setBasePath(string $basePath): RouteCollectorInterface
+ {
+ $this->basePath = $basePath;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getRoutes(): array
+ {
+ return $this->routes;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function removeNamedRoute(string $name): RouteCollectorInterface
+ {
+ $route = $this->getNamedRoute($name);
+
+ unset($this->routesByName[$route->getName()], $this->routes[$route->getIdentifier()]);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getNamedRoute(string $name): RouteInterface
+ {
+ if (isset($this->routesByName[$name])) {
+ $route = $this->routesByName[$name];
+ if ($route->getName() === $name) {
+ return $route;
+ }
+
+ unset($this->routesByName[$name]);
+ }
+
+ foreach ($this->routes as $route) {
+ if ($name === $route->getName()) {
+ $this->routesByName[$name] = $route;
+ return $route;
+ }
+ }
+
+ throw new RuntimeException('Named route does not exist for name: ' . $name);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function lookupRoute(string $identifier): RouteInterface
+ {
+ if (!isset($this->routes[$identifier])) {
+ throw new RuntimeException('Route not found, looks like your route cache is stale.');
+ }
+ return $this->routes[$identifier];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function group(string $pattern, $callable): RouteGroupInterface
+ {
+ $routeGroup = $this->createGroup($pattern, $callable);
+ $this->routeGroups[] = $routeGroup;
+
+ $routeGroup->collectRoutes();
+ array_pop($this->routeGroups);
+
+ return $routeGroup;
+ }
+
+ /**
+ * @param string|callable $callable
+ */
+ protected function createGroup(string $pattern, $callable): RouteGroupInterface
+ {
+ $routeCollectorProxy = $this->createProxy($pattern);
+ return new RouteGroup($pattern, $callable, $this->callableResolver, $routeCollectorProxy);
+ }
+
+ protected function createProxy(string $pattern): RouteCollectorProxyInterface
+ {
+ return new RouteCollectorProxy(
+ $this->responseFactory,
+ $this->callableResolver,
+ $this->container,
+ $this,
+ $pattern
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function map(array $methods, string $pattern, $handler): RouteInterface
+ {
+ $route = $this->createRoute($methods, $pattern, $handler);
+ $this->routes[$route->getIdentifier()] = $route;
+
+ $routeName = $route->getName();
+ if ($routeName !== null && !isset($this->routesByName[$routeName])) {
+ $this->routesByName[$routeName] = $route;
+ }
+
+ $this->routeCounter++;
+
+ return $route;
+ }
+
+ /**
+ * @param string[] $methods
+ * @param callable|string $callable
+ */
+ protected function createRoute(array $methods, string $pattern, $callable): RouteInterface
+ {
+ return new Route(
+ $methods,
+ $pattern,
+ $callable,
+ $this->responseFactory,
+ $this->callableResolver,
+ $this->container,
+ $this->defaultInvocationStrategy,
+ $this->routeGroups,
+ $this->routeCounter
+ );
+ }
+}
diff --git a/lib/slim/slim/Slim/Routing/RouteCollectorProxy.php b/lib/slim/slim/Slim/Routing/RouteCollectorProxy.php
new file mode 100644
index 00000000000..f8bc232bc46
--- /dev/null
+++ b/lib/slim/slim/Slim/Routing/RouteCollectorProxy.php
@@ -0,0 +1,187 @@
+responseFactory = $responseFactory;
+ $this->callableResolver = $callableResolver;
+ $this->container = $container;
+ $this->routeCollector = $routeCollector ?? new RouteCollector($responseFactory, $callableResolver, $container);
+ $this->groupPattern = $groupPattern;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getResponseFactory(): ResponseFactoryInterface
+ {
+ return $this->responseFactory;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCallableResolver(): CallableResolverInterface
+ {
+ return $this->callableResolver;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getContainer(): ?ContainerInterface
+ {
+ return $this->container;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getRouteCollector(): RouteCollectorInterface
+ {
+ return $this->routeCollector;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getBasePath(): string
+ {
+ return $this->routeCollector->getBasePath();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setBasePath(string $basePath): RouteCollectorProxyInterface
+ {
+ $this->routeCollector->setBasePath($basePath);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function get(string $pattern, $callable): RouteInterface
+ {
+ return $this->map(['GET'], $pattern, $callable);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function post(string $pattern, $callable): RouteInterface
+ {
+ return $this->map(['POST'], $pattern, $callable);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function put(string $pattern, $callable): RouteInterface
+ {
+ return $this->map(['PUT'], $pattern, $callable);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function patch(string $pattern, $callable): RouteInterface
+ {
+ return $this->map(['PATCH'], $pattern, $callable);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function delete(string $pattern, $callable): RouteInterface
+ {
+ return $this->map(['DELETE'], $pattern, $callable);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function options(string $pattern, $callable): RouteInterface
+ {
+ return $this->map(['OPTIONS'], $pattern, $callable);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function any(string $pattern, $callable): RouteInterface
+ {
+ return $this->map(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], $pattern, $callable);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function map(array $methods, string $pattern, $callable): RouteInterface
+ {
+ $pattern = $this->groupPattern . $pattern;
+
+ return $this->routeCollector->map($methods, $pattern, $callable);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function group(string $pattern, $callable): RouteGroupInterface
+ {
+ $pattern = $this->groupPattern . $pattern;
+
+ return $this->routeCollector->group($pattern, $callable);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function redirect(string $from, $to, int $status = 302): RouteInterface
+ {
+ $responseFactory = $this->responseFactory;
+
+ $handler = function () use ($to, $status, $responseFactory) {
+ $response = $responseFactory->createResponse($status);
+ return $response->withHeader('Location', (string) $to);
+ };
+
+ return $this->get($from, $handler);
+ }
+}
diff --git a/lib/slim/slim/Slim/Routing/RouteContext.php b/lib/slim/slim/Slim/Routing/RouteContext.php
new file mode 100644
index 00000000000..3ba5e23a615
--- /dev/null
+++ b/lib/slim/slim/Slim/Routing/RouteContext.php
@@ -0,0 +1,88 @@
+getAttribute(self::ROUTE);
+ $routeParser = $serverRequest->getAttribute(self::ROUTE_PARSER);
+ $routingResults = $serverRequest->getAttribute(self::ROUTING_RESULTS);
+ $basePath = $serverRequest->getAttribute(self::BASE_PATH);
+
+ if ($routeParser === null || $routingResults === null) {
+ throw new RuntimeException('Cannot create RouteContext before routing has been completed');
+ }
+
+ /** @var RouteInterface|null $route */
+ /** @var RouteParserInterface $routeParser */
+ /** @var RoutingResults $routingResults */
+ /** @var string|null $basePath */
+ return new self($route, $routeParser, $routingResults, $basePath);
+ }
+
+ private ?RouteInterface $route;
+
+ private RouteParserInterface $routeParser;
+
+ private RoutingResults $routingResults;
+
+ private ?string $basePath;
+
+ private function __construct(
+ ?RouteInterface $route,
+ RouteParserInterface $routeParser,
+ RoutingResults $routingResults,
+ ?string $basePath = null
+ ) {
+ $this->route = $route;
+ $this->routeParser = $routeParser;
+ $this->routingResults = $routingResults;
+ $this->basePath = $basePath;
+ }
+
+ public function getRoute(): ?RouteInterface
+ {
+ return $this->route;
+ }
+
+ public function getRouteParser(): RouteParserInterface
+ {
+ return $this->routeParser;
+ }
+
+ public function getRoutingResults(): RoutingResults
+ {
+ return $this->routingResults;
+ }
+
+ public function getBasePath(): string
+ {
+ if ($this->basePath === null) {
+ throw new RuntimeException('No base path defined.');
+ }
+ return $this->basePath;
+ }
+}
diff --git a/lib/slim/slim/Slim/Routing/RouteGroup.php b/lib/slim/slim/Slim/Routing/RouteGroup.php
new file mode 100644
index 00000000000..cd2f4e79a3f
--- /dev/null
+++ b/lib/slim/slim/Slim/Routing/RouteGroup.php
@@ -0,0 +1,104 @@
+pattern = $pattern;
+ $this->callable = $callable;
+ $this->callableResolver = $callableResolver;
+ $this->routeCollectorProxy = $routeCollectorProxy;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function collectRoutes(): RouteGroupInterface
+ {
+ if ($this->callableResolver instanceof AdvancedCallableResolverInterface) {
+ $callable = $this->callableResolver->resolveRoute($this->callable);
+ } else {
+ $callable = $this->callableResolver->resolve($this->callable);
+ }
+ $callable($this->routeCollectorProxy);
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function add($middleware): RouteGroupInterface
+ {
+ $this->middleware[] = $middleware;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function addMiddleware(MiddlewareInterface $middleware): RouteGroupInterface
+ {
+ $this->middleware[] = $middleware;
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function appendMiddlewareToDispatcher(MiddlewareDispatcher $dispatcher): RouteGroupInterface
+ {
+ foreach ($this->middleware as $middleware) {
+ $dispatcher->add($middleware);
+ }
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getPattern(): string
+ {
+ return $this->pattern;
+ }
+}
diff --git a/lib/slim/slim/Slim/Routing/RouteParser.php b/lib/slim/slim/Slim/Routing/RouteParser.php
new file mode 100644
index 00000000000..afb533cc5ad
--- /dev/null
+++ b/lib/slim/slim/Slim/Routing/RouteParser.php
@@ -0,0 +1,127 @@
+routeCollector = $routeCollector;
+ $this->routeParser = new Std();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function relativeUrlFor(string $routeName, array $data = [], array $queryParams = []): string
+ {
+ $route = $this->routeCollector->getNamedRoute($routeName);
+ $pattern = $route->getPattern();
+
+ $segments = [];
+ $segmentName = '';
+
+ /*
+ * $routes is an associative array of expressions representing a route as multiple segments
+ * There is an expression for each optional parameter plus one without the optional parameters
+ * The most specific is last, hence why we reverse the array before iterating over it
+ */
+ $expressions = array_reverse($this->routeParser->parse($pattern));
+ foreach ($expressions as $expression) {
+ foreach ($expression as $segment) {
+ /*
+ * Each $segment is either a string or an array of strings
+ * containing optional parameters of an expression
+ */
+ if (is_string($segment)) {
+ $segments[] = $segment;
+ continue;
+ }
+
+ /** @var string[] $segment */
+ /*
+ * If we don't have a data element for this segment in the provided $data
+ * we cancel testing to move onto the next expression with a less specific item
+ */
+ if (!array_key_exists($segment[0], $data)) {
+ $segments = [];
+ $segmentName = $segment[0];
+ break;
+ }
+
+ $segments[] = $data[$segment[0]];
+ }
+
+ /*
+ * If we get to this logic block we have found all the parameters
+ * for the provided $data which means we don't need to continue testing
+ * less specific expressions
+ */
+ if (!empty($segments)) {
+ break;
+ }
+ }
+
+ if (empty($segments)) {
+ throw new InvalidArgumentException('Missing data for URL segment: ' . $segmentName);
+ }
+
+ $url = implode('', $segments);
+ if ($queryParams) {
+ $url .= '?' . http_build_query($queryParams);
+ }
+
+ return $url;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function urlFor(string $routeName, array $data = [], array $queryParams = []): string
+ {
+ $basePath = $this->routeCollector->getBasePath();
+ $url = $this->relativeUrlFor($routeName, $data, $queryParams);
+
+ if ($basePath) {
+ $url = $basePath . $url;
+ }
+
+ return $url;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function fullUrlFor(UriInterface $uri, string $routeName, array $data = [], array $queryParams = []): string
+ {
+ $path = $this->urlFor($routeName, $data, $queryParams);
+ $scheme = $uri->getScheme();
+ $authority = $uri->getAuthority();
+ $protocol = ($scheme ? $scheme . ':' : '') . ($authority ? '//' . $authority : '');
+ return $protocol . $path;
+ }
+}
diff --git a/lib/slim/slim/Slim/Routing/RouteResolver.php b/lib/slim/slim/Slim/Routing/RouteResolver.php
new file mode 100644
index 00000000000..d4f4eafa326
--- /dev/null
+++ b/lib/slim/slim/Slim/Routing/RouteResolver.php
@@ -0,0 +1,56 @@
+routeCollector = $routeCollector;
+ $this->dispatcher = $dispatcher ?? new Dispatcher($routeCollector);
+ }
+
+ /**
+ * @param string $uri Should be $request->getUri()->getPath()
+ */
+ public function computeRoutingResults(string $uri, string $method): RoutingResults
+ {
+ $uri = rawurldecode($uri);
+ if ($uri === '' || $uri[0] !== '/') {
+ $uri = '/' . $uri;
+ }
+ return $this->dispatcher->dispatch($method, $uri);
+ }
+
+ /**
+ * @throws RuntimeException
+ */
+ public function resolveRoute(string $identifier): RouteInterface
+ {
+ return $this->routeCollector->lookupRoute($identifier);
+ }
+}
diff --git a/lib/slim/slim/Slim/Routing/RouteRunner.php b/lib/slim/slim/Slim/Routing/RouteRunner.php
new file mode 100644
index 00000000000..40946af5607
--- /dev/null
+++ b/lib/slim/slim/Slim/Routing/RouteRunner.php
@@ -0,0 +1,70 @@
+routeResolver = $routeResolver;
+ $this->routeParser = $routeParser;
+ $this->routeCollectorProxy = $routeCollectorProxy;
+ }
+
+ /**
+ * This request handler is instantiated automatically in App::__construct()
+ * It is at the very tip of the middleware queue meaning it will be executed
+ * last and it detects whether or not routing has been performed in the user
+ * defined middleware stack. In the event that the user did not perform routing
+ * it is done here
+ *
+ * @throws HttpNotFoundException
+ * @throws HttpMethodNotAllowedException
+ */
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ // If routing hasn't been done, then do it now so we can dispatch
+ if ($request->getAttribute(RouteContext::ROUTING_RESULTS) === null) {
+ $routingMiddleware = new RoutingMiddleware($this->routeResolver, $this->routeParser);
+ $request = $routingMiddleware->performRouting($request);
+ }
+
+ if ($this->routeCollectorProxy !== null) {
+ $request = $request->withAttribute(
+ RouteContext::BASE_PATH,
+ $this->routeCollectorProxy->getBasePath()
+ );
+ }
+
+ /** @var Route $route */
+ $route = $request->getAttribute(RouteContext::ROUTE);
+ return $route->run($request);
+ }
+}
diff --git a/lib/slim/slim/Slim/Routing/RoutingResults.php b/lib/slim/slim/Slim/Routing/RoutingResults.php
new file mode 100644
index 00000000000..ac2fa64f93f
--- /dev/null
+++ b/lib/slim/slim/Slim/Routing/RoutingResults.php
@@ -0,0 +1,112 @@
+
+ */
+ protected array $routeArguments;
+
+ /**
+ * @param array $routeArguments
+ */
+ public function __construct(
+ DispatcherInterface $dispatcher,
+ string $method,
+ string $uri,
+ int $routeStatus,
+ ?string $routeIdentifier = null,
+ array $routeArguments = []
+ ) {
+ $this->dispatcher = $dispatcher;
+ $this->method = $method;
+ $this->uri = $uri;
+ $this->routeStatus = $routeStatus;
+ $this->routeIdentifier = $routeIdentifier;
+ $this->routeArguments = $routeArguments;
+ }
+
+ public function getDispatcher(): DispatcherInterface
+ {
+ return $this->dispatcher;
+ }
+
+ public function getMethod(): string
+ {
+ return $this->method;
+ }
+
+ public function getUri(): string
+ {
+ return $this->uri;
+ }
+
+ public function getRouteStatus(): int
+ {
+ return $this->routeStatus;
+ }
+
+ public function getRouteIdentifier(): ?string
+ {
+ return $this->routeIdentifier;
+ }
+
+ /**
+ * @return array
+ */
+ public function getRouteArguments(bool $urlDecode = true): array
+ {
+ if (!$urlDecode) {
+ return $this->routeArguments;
+ }
+
+ $routeArguments = [];
+ foreach ($this->routeArguments as $key => $value) {
+ $routeArguments[$key] = rawurldecode($value);
+ }
+
+ return $routeArguments;
+ }
+
+ /**
+ * @return string[]
+ */
+ public function getAllowedMethods(): array
+ {
+ return $this->dispatcher->getAllowedMethods($this->uri);
+ }
+}
diff --git a/lib/slim/slim/composer.json b/lib/slim/slim/composer.json
new file mode 100644
index 00000000000..f98064ff275
--- /dev/null
+++ b/lib/slim/slim/composer.json
@@ -0,0 +1,102 @@
+{
+ "name": "slim/slim",
+ "type": "library",
+ "description": "Slim is a PHP micro framework that helps you quickly write simple yet powerful web applications and APIs",
+ "keywords": ["framework","micro","api","router"],
+ "homepage": "https://www.slimframework.com",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Josh Lockhart",
+ "email": "hello@joshlockhart.com",
+ "homepage": "https://joshlockhart.com"
+ },
+ {
+ "name": "Andrew Smith",
+ "email": "a.smith@silentworks.co.uk",
+ "homepage": "http://silentworks.co.uk"
+ },
+ {
+ "name": "Rob Allen",
+ "email": "rob@akrabat.com",
+ "homepage": "http://akrabat.com"
+ },
+ {
+ "name": "Pierre Berube",
+ "email": "pierre@lgse.com",
+ "homepage": "http://www.lgse.com"
+ },
+ {
+ "name": "Gabriel Manricks",
+ "email": "gmanricks@me.com",
+ "homepage": "http://gabrielmanricks.com"
+ }
+ ],
+ "support": {
+ "docs": "https://www.slimframework.com/docs/v4/",
+ "forum": "https://discourse.slimframework.com/",
+ "irc": "irc://irc.freenode.net:6667/slimphp",
+ "issues": "https://github.com/slimphp/Slim/issues",
+ "rss": "https://www.slimframework.com/blog/feed.rss",
+ "slack": "https://slimphp.slack.com/",
+ "source": "https://github.com/slimphp/Slim",
+ "wiki": "https://github.com/slimphp/Slim/wiki"
+ },
+ "require": {
+ "php": "^7.4 || ^8.0",
+ "ext-json": "*",
+ "nikic/fast-route": "^1.3",
+ "psr/container": "^1.0 || ^2.0",
+ "psr/http-factory": "^1.0",
+ "psr/http-message": "^1.1 || ^2.0",
+ "psr/http-server-handler": "^1.0",
+ "psr/http-server-middleware": "^1.0",
+ "psr/log": "^1.1 || ^2.0 || ^3.0"
+ },
+ "require-dev": {
+ "ext-simplexml": "*",
+ "adriansuter/php-autoload-override": "^1.4",
+ "guzzlehttp/psr7": "^2.6",
+ "httpsoft/http-message": "^1.1",
+ "httpsoft/http-server-request": "^1.1",
+ "laminas/laminas-diactoros": "^2.17 || ^3",
+ "nyholm/psr7": "^1.8",
+ "nyholm/psr7-server": "^1.1",
+ "phpspec/prophecy": "^1.19",
+ "phpspec/prophecy-phpunit": "^2.1",
+ "phpstan/phpstan": "^1.10",
+ "phpunit/phpunit": "^9.6",
+ "slim/http": "^1.3",
+ "slim/psr7": "^1.6",
+ "squizlabs/php_codesniffer": "^3.9"
+ },
+ "autoload": {
+ "psr-4": {
+ "Slim\\": "Slim"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Slim\\Tests\\": "tests"
+ }
+ },
+ "scripts": {
+ "test": [
+ "@phpunit",
+ "@phpcs",
+ "@phpstan"
+ ],
+ "phpunit": "phpunit",
+ "phpcs": "phpcs",
+ "phpstan": "phpstan --memory-limit=-1"
+ },
+ "suggest": {
+ "ext-simplexml": "Needed to support XML format in BodyParsingMiddleware",
+ "ext-xml": "Needed to support XML format in BodyParsingMiddleware",
+ "slim/psr7": "Slim PSR-7 implementation. See https://www.slimframework.com/docs/v4/start/installation.html for more information.",
+ "php-di/php-di": "PHP-DI is the recommended container library to be used with Slim"
+ },
+ "config": {
+ "sort-packages": true
+ }
+}
diff --git a/lib/tests/fixtures/router/mocking_route_loader.php b/lib/tests/fixtures/router/mocking_route_loader.php
new file mode 100644
index 00000000000..7ac516b078e
--- /dev/null
+++ b/lib/tests/fixtures/router/mocking_route_loader.php
@@ -0,0 +1,150 @@
+.
+
+namespace core\router;
+use Slim\App;
+use Slim\Routing\RouteCollectorProxy;
+
+/**
+ * A route loader containing mocked routes.
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mocking_route_loader extends abstract_route_loader implements route_loader_interface {
+ /** @var array[] The mocked routes to configure in the loader */
+ private array $groupdata = [];
+
+ #[\Override]
+ public function configure_routes(App $app): array {
+ $routegroups = [];
+
+ foreach ($this->groupdata as $path => $groupdata) {
+ $routegroups[$path] = $app->group($path, function (
+ RouteCollectorProxy $group,
+ ) use (
+ $groupdata,
+ ): void {
+ foreach ($groupdata as $data) {
+ $group
+ ->map(...$data['mapdata'])
+ ->setName($data['name']);
+ }
+ });
+ }
+
+ return $routegroups;
+ }
+
+ /**
+ * Add a mocked route to the loader.
+ *
+ * @param string $grouppath The path of the RouteGroup to add the route to
+ * @param array $methods The HTTP methods to add the route for
+ * @param string $pattern The path to add the route for
+ * @param callable $callable The callable to add the route for
+ * @param string $name The name of the route
+ */
+ public function mock_route_from_callable(
+ string $grouppath,
+ array $methods,
+ string $pattern,
+ callable $callable,
+ string $name,
+ ): void {
+ $this->add_groupdata(
+ $grouppath,
+ [
+ 'methods' => $methods,
+ 'pattern' => $pattern,
+ 'callable' => $callable,
+ ],
+ $name,
+ );
+ }
+
+ /**
+ * Add all routes in a class to the loader.
+ *
+ * @param string $grouppath Thegroup to add the route to
+ * @param string|\ReflectionMethod $class The class to add to the loader
+ */
+ public function add_all_routes_in_class(
+ string $grouppath,
+ \ReflectionMethod|string $class,
+ ) {
+ $classinfo = $class instanceof \ReflectionClass ? $class : new \ReflectionClass($class);
+
+ $routes = $this->get_all_routes_in_class(
+ componentpath: '',
+ classinfo: $classinfo,
+ );
+
+ foreach ($routes as $mapdata) {
+ $this->add_groupdata(
+ $grouppath,
+ $mapdata,
+ implode('::', $mapdata['callable']),
+ );
+ }
+ }
+
+ /**
+ * Mock a route from a class method.
+ *
+ * @param string $grouppath The path to add the route to
+ * @param \ReflectionMethod $method The method to mock the route from
+ */
+ public function mock_route_from_class_method(
+ string $grouppath,
+ \ReflectionMethod $method,
+ ) {
+ $mapdata = $this->get_route_data_for_method(
+ componentpath: '',
+ classinfo: $method->getDeclaringClass(),
+ methodinfo: $method,
+ );
+
+ $this->add_groupdata(
+ $grouppath,
+ $mapdata,
+ implode('::', $mapdata['callable']),
+ );
+ }
+
+ /**
+ * Add group data to the loader.
+ *
+ * @param string $grouppath The path of the RouteGroup to add the data to
+ * @param array $data The data to add to the group
+ * @param string $name The name of the group
+ */
+ protected function add_groupdata(
+ string $grouppath,
+ array $data,
+ string $name,
+ ): void {
+ if (!array_key_exists($grouppath, $this->groupdata)) {
+ $this->groupdata[$grouppath] = [];
+ }
+
+ $this->groupdata[$grouppath][] = [
+ 'mapdata' => $data,
+ 'name' => $name,
+ ];
+ }
+}
diff --git a/lib/tests/fixtures/router/route_implementing_request_handler_interface.php b/lib/tests/fixtures/router/route_implementing_request_handler_interface.php
new file mode 100644
index 00000000000..95fbf4cad85
--- /dev/null
+++ b/lib/tests/fixtures/router/route_implementing_request_handler_interface.php
@@ -0,0 +1,31 @@
+.
+
+namespace core\router;
+
+/**
+ * A fixture for a route that implements the RequestHandlerInterface.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class route_implementing_request_handler_interface implements \Psr\Http\Server\RequestHandlerInterface {
+ #[\Override]
+ public function handle(\Psr\Http\Message\ServerRequestInterface $request): \Psr\Http\Message\ResponseInterface {
+ return new \GuzzleHttp\Psr7\Response();
+ }
+}
diff --git a/lib/tests/fixtures/router/route_on_class.php b/lib/tests/fixtures/router/route_on_class.php
new file mode 100644
index 00000000000..af9d6013d51
--- /dev/null
+++ b/lib/tests/fixtures/router/route_on_class.php
@@ -0,0 +1,53 @@
+.
+
+namespace core\fixtures;
+
+use core\router\route;
+use GuzzleHttp\Psr7\Response;
+
+/**
+ * Fixture for tests of the router and route classes.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+#[route(
+ path: '/class/path',
+)]
+class route_on_class {
+ /**
+ * A method without a route.
+ *
+ * @return Response
+ */
+ public function method_without_route(): Response {
+ return new Response(200, [], 'test');
+ }
+
+ /**
+ * A method with a route.
+ *
+ * @return Response
+ */
+ #[route(
+ path: '/method/path',
+ )]
+ public function method_with_route(): Response {
+ return new Response(200, [], 'test2');
+ }
+}
diff --git a/lib/tests/fixtures/router/route_on_method_only.php b/lib/tests/fixtures/router/route_on_method_only.php
new file mode 100644
index 00000000000..b70bb202640
--- /dev/null
+++ b/lib/tests/fixtures/router/route_on_method_only.php
@@ -0,0 +1,50 @@
+.
+
+namespace core\fixtures;
+
+use core\router\route;
+use GuzzleHttp\Psr7\Response;
+
+/**
+ * Fixture for tests of the router and route classes.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class route_on_method_only {
+ /**
+ * A method without a route.
+ *
+ * @return Response
+ */
+ public function method_without_route(): Response {
+ return new Response(200, [], 'test');
+ }
+
+ /**
+ * A method with a route.
+ *
+ * @return Response
+ */
+ #[route(
+ path: '/method/path',
+ )]
+ public function method_with_route(): Response {
+ return new Response(200, [], 'test2');
+ }
+}
diff --git a/lib/tests/route/api/templates_test.php b/lib/tests/route/api/templates_test.php
new file mode 100644
index 00000000000..3a189cc83fe
--- /dev/null
+++ b/lib/tests/route/api/templates_test.php
@@ -0,0 +1,112 @@
+.
+
+namespace core\route\api;
+
+use core\tests\route_testcase;
+
+/**
+ * Tests for Templates API.
+ *
+ * @package core
+ * @category test
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\route\api\templates
+ */
+final class templates_test extends route_testcase {
+ /**
+ * Test fetching templates.
+ *
+ * Note: This is a risky test because it relies on data in other parts of Moodle.
+ *
+ * @dataProvider fetch_templates_provider
+ * @param string $path
+ * @param array $requiredtemplates
+ * @param array $requiredstrings
+ */
+ public function test_fetch_known_templates(
+ string $path,
+ array $requiredtemplates,
+ array $requiredstrings,
+ ): void {
+ $this->add_class_routes_to_route_loader(\core\route\api\templates::class);
+ $response = $this->process_api_request('GET', "/templates/{$path}");
+
+ $this->assert_valid_response($response);
+ $payload = $this->decode_response($response, true);
+
+ $this->assert_payload_contains($payload, $requiredtemplates, $requiredstrings);
+ }
+
+ /**
+ * Data propvider for template tests.
+ *
+ * @return array
+ */
+ public static function fetch_templates_provider(): array {
+ return [
+ 'fetch single template' => [
+ 'boost/core/modal',
+ ['core/modal'],
+ [],
+ ],
+ 'foo' => [
+ 'boost/core/notification',
+ [
+ 'core/notification',
+ 'core/notification_success',
+ 'core/notification_warning',
+ 'core/notification_error',
+ 'core/notification_info',
+ ],
+ [
+ 'core/dismissnotification',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Assertthat the payload contains the required templates and strings.
+ *
+ * @param array $payload
+ * @param array $requiredtemplates
+ * @param array $requiredstrings
+ */
+ protected function assert_payload_contains(
+ array $payload,
+ array $requiredtemplates = [],
+ array $requiredstrings = [],
+ ): void {
+ $this->assertArrayHasKey('templates', $payload);
+ $this->assertArrayHasKey('strings', $payload);
+
+ foreach ($requiredtemplates as $template) {
+ $this->assertArrayHasKey($template, $payload['templates']);
+ }
+ foreach ($requiredstrings as $string) {
+ $this->assertArrayHasKey($string, $payload['strings']);
+ }
+ }
+
+ public function test_template_missing(): void {
+ $this->add_class_routes_to_route_loader(\core\route\api\templates::class);
+ $response = $this->process_api_request('GET', '/templates/boost/core/missing');
+
+ $this->assert_not_found_response($response);
+ }
+}
diff --git a/lib/tests/router/abstract_route_loader_test.php b/lib/tests/router/abstract_route_loader_test.php
new file mode 100644
index 00000000000..1cd78df0c98
--- /dev/null
+++ b/lib/tests/router/abstract_route_loader_test.php
@@ -0,0 +1,141 @@
+.
+
+namespace core\router;
+
+/**
+ * Tests for the abstract route loader.
+ *
+ * Note: This is an abstract class used as an optional helper for any other route loader.
+ * All methods on it are protected and testing them requires a concrete implementation.
+ *
+ * @package core
+ * @category test
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\abstract_route_loader
+ */
+final class abstract_route_loader_test extends \advanced_testcase {
+ /**
+ * Ensure that the abstract loader does not implement the interface. That would defeat the point.
+ */
+ public function test_abstract_route_loader_does_not_implement_interface(): void {
+ $reflection = new \ReflectionClass(abstract_route_loader::class);
+ $this->assertFalse($reflection->implementsInterface(route_loader_interface::class));
+ }
+
+ /**
+ * Test that we can fetch routes in a namespace.
+ */
+ public function test_get_all_routes_in_namespace(): void {
+ // phpcs:ignore
+ $loader = new class() extends abstract_route_loader {
+ // phpcs:ignore
+ public function get_routes(): array {
+ return $this->get_all_routes_in_namespace(
+ 'route\api',
+ function (string $component): string {
+ return '/path/to/' . $component;
+ },
+ );
+ }
+ };
+
+ $routes = $loader->get_routes();
+ $this->assertGreaterThan(1, count($routes));
+ foreach ($routes as $route) {
+ $this->assertArrayHasKey('methods', $route);
+ $this->assertArrayHasKey('pattern', $route);
+ $this->assertArrayHasKey('callable', $route);
+ $this->assertStringStartsWith('/path/to/', $route['pattern']);
+ }
+ }
+
+ /**
+ * Test tha the normalise_component_path method works as expected.
+ *
+ * @dataProvider normalise_component_path_provider
+ * @param string $input
+ * @param string $expected
+ */
+ public function test_normalise_component_path(
+ string $input,
+ string $expected,
+ ): void {
+ // phpcs:ignore
+ $loader = new class() extends abstract_route_loader {
+ // phpcs:ignore
+ public function method(...$args): string {
+ return $this->normalise_component_path(...$args);
+ }
+ };
+
+ $this->assertEquals(
+ $expected,
+ $loader->method($input),
+ );
+ }
+
+ /**
+ * Data provider for test_normalise_component_path.
+ *
+ * @return array
+ */
+ public static function normalise_component_path_provider(): array {
+ return [
+ ['core', 'core'],
+ ['core_user', 'user'],
+ ['mod_forum', 'mod_forum'],
+ ['', ''],
+ ];
+ }
+
+ /**
+ * Tests for the set_route_name_for_callable method.
+ */
+ public function test_set_route_name_for_callable(): void {
+ // phpcs:ignore
+ $loader = new class() extends abstract_route_loader {
+ // phpcs:ignore
+ public function call(...$args): ?string {
+ return $this->set_route_name_for_callable(...$args);
+ }
+ };
+
+ $route = $this->createMock(\Slim\Routing\Route::class);
+ $route->expects($this->once())
+ ->method('setName')
+ ->with('routename');
+
+ $name = $loader->call($route, 'routename');
+ $this->assertEquals('routename', $name);
+
+ $route = $this->createMock(\Slim\Routing\Route::class);
+ $route->expects($this->once())
+ ->method('setName')
+ ->with('class::method');
+
+ $name = $loader->call($route, ['class', 'method']);
+ $this->assertEquals('class::method', $name);
+
+ $route = $this->createMock(\Slim\Routing\Route::class);
+ $route->expects($this->never())
+ ->method('setName');
+
+ $name = $loader->call($route, fn () => '');
+ $this->assertEquals(null, $name);
+ }
+}
diff --git a/lib/tests/router/apidocs_test.php b/lib/tests/router/apidocs_test.php
new file mode 100644
index 00000000000..b36324feeee
--- /dev/null
+++ b/lib/tests/router/apidocs_test.php
@@ -0,0 +1,40 @@
+.
+
+namespace core\router;
+
+use GuzzleHttp\Psr7\Response;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Tests for user preference API handler.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\apidocs
+ */
+final class apidocs_test extends \advanced_testcase {
+ public function test_openapi_docs(): void {
+ $apidocs = new apidocs();
+
+ $result = $apidocs->openapi_docs(new Response());
+ $this->assertInstanceOf(ResponseInterface::class, $result);
+
+ $this->assertTrue($result->hasHeader('Content-Type'));
+ $this->assertEquals(['application/json'], $result->getHeader('Content-Type'));
+ }
+}
diff --git a/lib/tests/router/callable_resolver_test.php b/lib/tests/router/callable_resolver_test.php
new file mode 100644
index 00000000000..137e6cec477
--- /dev/null
+++ b/lib/tests/router/callable_resolver_test.php
@@ -0,0 +1,80 @@
+.
+
+namespace core\router;
+
+use core\di;
+use core\router\middleware\cors_middleware;
+use Invoker\Exception\NotCallableException;
+
+/**
+ * Tests for callable resolver.
+ *
+ * @package core
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\callable_resolver
+ */
+final class callable_resolver_test extends \advanced_testcase {
+ public function test_can_resolve_slim_notation(): void {
+ $resolver = di::get(callable_resolver::class);
+
+ $result = $resolver->resolve('core\router\apidocs:openapi_docs');
+ $this->assertEquals([new apidocs(), 'openapi_docs'], $result);
+ }
+ public function test_can_resolve_array(): void {
+ $resolver = di::get(callable_resolver::class);
+
+ $result = $resolver->resolve([\core\router\apidocs::class, 'openapi_docs']);
+ $this->assertEquals([new apidocs(), 'openapi_docs'], $result);
+ }
+
+ public function test_can_resolve_di_notation(): void {
+ $resolver = di::get(callable_resolver::class);
+
+ $result = $resolver->resolve('core\router\apidocs::openapi_docs');
+ $this->assertEquals([new apidocs(), 'openapi_docs'], $result);
+ }
+
+ public function test_resolve_middleware(): void {
+ $resolver = di::get(callable_resolver::class);
+
+ $result = $resolver->resolveMiddleware(\core\router\middleware\cors_middleware::class);
+ $this->assertEquals([new cors_middleware(), 'process'], $result);
+ }
+
+ public function test_resolve_middleware_not_middleware(): void {
+ $resolver = di::get(callable_resolver::class);
+
+ $this->expectException(NotCallableException::class);
+ $resolver->resolveMiddleware('core\router\apidocs');
+ }
+
+ public function test_resolve_route(): void {
+ self::load_fixture('core', 'router/route_implementing_request_handler_interface.php');
+ $resolver = di::get(callable_resolver::class);
+
+ $result = $resolver->resolveRoute(\core\router\route_implementing_request_handler_interface::class);
+ $this->assertEquals([new route_implementing_request_handler_interface(), 'handle'], $result);
+ }
+
+ public function test_resolve_route_not_route(): void {
+ $resolver = di::get(callable_resolver::class);
+
+ $this->expectException(NotCallableException::class);
+ $resolver->resolveRoute(\core\router\apidocs::class);
+ }
+}
diff --git a/lib/tests/router/controller_invoker_test.php b/lib/tests/router/controller_invoker_test.php
new file mode 100644
index 00000000000..330a92fb93c
--- /dev/null
+++ b/lib/tests/router/controller_invoker_test.php
@@ -0,0 +1,138 @@
+.
+
+namespace core\router;
+
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\Response;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Tests for the controller invoker, and related bridge.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\controller_invoker
+ * @covers \core\router\bridge
+ * @covers \core\router\response_handler
+ */
+final class controller_invoker_test extends route_testcase {
+ /**
+ * Configure an instance of Slim and fetch the Invoker.
+ *
+ * @return \Slim\Interfaces\InvocationStrategyInterface
+ */
+ protected function get_invocation_strategy(): \Slim\Interfaces\InvocationStrategyInterface {
+ $container = \core\di::get_container();
+ bridge::create($container);
+ $app = $container->get(\Slim\App::class);
+ return $app->getRouteCollector()->getDefaultInvocationStrategy();
+ }
+
+ /**
+ * Test that setup of the invoker using the router\bridge sets the correct invoker strategy.
+ * @covers \core\router\bridge
+ * @covers \core\router\controller_invoker
+ */
+ public function test_setup_of_invoker(): void {
+ $strategy = $this->get_invocation_strategy();
+ $this->assertInstanceOf(controller_invoker::class, $strategy);
+ }
+
+ public function test_invocation_with_arguments(): void {
+ $strategy = $this->get_invocation_strategy();
+ $testcase = $this;
+
+ // Providing a callable with no args will mean that none are provided.
+ $callable = function () use ($testcase): Response {
+ $testcase->assertCount(0, func_get_args());
+ return new Response();
+ };
+
+ $request = $this->create_request('GET', '/example');
+ $response = new Response();
+ $strategy($callable, $request, $response, []);
+
+ // Requesting one Response will get us the Response.
+ $originalresponse = new Response();
+ $callable = function (Response $response) use ($testcase, $originalresponse): Response {
+ $testcase->assertNotNull($response);
+ $testcase->assertEquals($originalresponse, $response);
+ // phpcs:ignore PHPCompatibility.FunctionUse.ArgumentFunctionsReportCurrentValue
+ $testcase->assertCount(1, func_get_args());
+ return $response;
+ };
+
+ $request = $this->create_request('GET', '/example');
+
+ $strategy($callable, $request, $originalresponse, []);
+
+ // Requesting the Request will get us the Request.
+ $serverrequest = $this->create_request('GET', '/example');
+ $callable = function (ServerRequestInterface $request) use ($testcase, $serverrequest): Response {
+ $this->assertEquals($serverrequest, $request);
+ // phpcs:ignore PHPCompatibility.FunctionUse.ArgumentFunctionsReportCurrentValue
+ $testcase->assertCount(1, func_get_args());
+
+ return new Response();
+ };
+
+ $strategy($callable, $serverrequest, $response, []);
+
+ // Attributes on the request will be passed through if requested.
+ $serverrequest = $this->create_request('GET', '/example')
+ ->withAttribute('example', 'This is an examplar attribute!');
+ $callable = function (
+ string $example,
+ ) use ($testcase): Response {
+ $testcase->assertCount(1, func_get_args());
+ $testcase->assertEquals('This is an examplar attribute!', $example);
+ return new Response();
+ };
+
+ $strategy($callable, $serverrequest, $response, []);
+
+ // Route arguments to request will be passed through if requested.
+ $serverrequest = $this->create_request('GET', '/example');
+ $callable = function (
+ string $example,
+ ) use ($testcase): Response {
+ $testcase->assertCount(1, func_get_args());
+ $testcase->assertEquals('This is an examplar attribute!', $example);
+ return new Response();
+ };
+
+ $strategy($callable, $serverrequest, $response, [
+ 'example' => 'This is an examplar attribute!',
+ ]);
+
+ // Attributes will be overridden by Route arguments.
+ $serverrequest = $this->create_request('GET', '/example')
+ ->withAttribute('example', 'This is an examplar attribute!');
+ $callable = function (
+ string $example,
+ ) use ($testcase): Response {
+ $testcase->assertCount(1, func_get_args());
+ $testcase->assertEquals('This is a different examplar attribute!', $example);
+ return new Response();
+ };
+
+ $strategy($callable, $serverrequest, $response, [
+ 'example' => 'This is a different examplar attribute!',
+ ]);
+ }
+}
diff --git a/lib/tests/router/middleware/cors_middleware_test.php b/lib/tests/router/middleware/cors_middleware_test.php
new file mode 100644
index 00000000000..eced3647c97
--- /dev/null
+++ b/lib/tests/router/middleware/cors_middleware_test.php
@@ -0,0 +1,84 @@
+.
+
+namespace core\router\middleware;
+
+use core\di;
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\ServerRequest;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Tests for the CORS middleware.
+ *
+ * @package core
+ * @category test
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\middleware\cors_middleware
+ */
+final class cors_middleware_test extends route_testcase {
+ /**
+ * Standard CORS headers are added.
+ */
+ public function test_cors_headers(): void {
+ $app = $this->get_simple_app();
+ $app->add(di::get(cors_middleware::class));
+ $app->addRoutingMiddleware();
+
+ $app->map(['GET'], '/test', function ($request, $response) {
+ return $response;
+ });
+
+ // Handle the request.
+ $request = new ServerRequest('GET', '/test');
+ $returns = $app->handle($request);
+ $this->assertInstanceOf(ResponseInterface::class, $returns);
+
+ // Assert the relevant CORS headers.
+ $this->assertEquals('*', $returns->getHeaderLine('Access-Control-Allow-Origin'));
+ $this->assertEquals('GET', $returns->getHeaderLine('Access-Control-Allow-Methods'));
+
+ // Check the allowed headers.
+ $allowedheaders = $returns->getHeaderLine('Access-Control-Allow-Headers');
+ $this->assertStringContainsString('Content-Type', $allowedheaders);
+ $this->assertStringContainsString('api_key', $allowedheaders);
+ $this->assertStringContainsString('Authorization', $allowedheaders);
+ }
+
+ /**
+ * CORS methods are added for multiple routes matching the same path.
+ */
+ public function test_cors_multiple_methods_headers(): void {
+ $app = $this->get_simple_app();
+ $app->add(di::get(cors_middleware::class));
+ $app->addRoutingMiddleware();
+
+ $app->map(['GET'], '/test', fn ($request, $response) => $response);
+ $app->map(['POST'], '/test', fn ($request, $response) => $response);
+ $app->map(['PUT', 'PATCH'], '/test', fn ($request, $response) => $response);
+ $app->map(['DELETE'], '/test', fn ($request, $response) => $response);
+
+ // Handle the request.
+ $request = new ServerRequest('GET', '/test');
+ $returns = $app->handle($request);
+ $this->assertInstanceOf(ResponseInterface::class, $returns);
+
+ // Assert the relevant CORS headers.
+ $this->assertEquals('*', $returns->getHeaderLine('Access-Control-Allow-Origin'));
+ $this->assertEquals('GET,POST,PUT,PATCH,DELETE', $returns->getHeaderLine('Access-Control-Allow-Methods'));
+ }
+}
diff --git a/lib/tests/router/middleware/error_handling_middleware_test.php b/lib/tests/router/middleware/error_handling_middleware_test.php
new file mode 100644
index 00000000000..ff9f298e886
--- /dev/null
+++ b/lib/tests/router/middleware/error_handling_middleware_test.php
@@ -0,0 +1,80 @@
+.
+
+namespace core\router\middleware;
+
+use core\di;
+use core\router\response_handler;
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\ServerRequest;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Tests for the CORS middleware.
+ *
+ * @package core
+ * @category test
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\middleware\error_handling_middleware
+ */
+final class error_handling_middleware_test extends route_testcase {
+ /**
+ * When no errors, the error handle is not called.
+ */
+ public function test_no_errors(): void {
+ $responsehandler = $this->getMockBuilder(response_handler::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $responsehandler->expects($this->never())->method('get_response_from_exception');
+
+ di::set(response_handler::class, $responsehandler);
+
+ $app = $this->get_simple_app();
+ $app->add(di::get(error_handling_middleware::class));
+
+ $app->map(['GET'], '/test', fn ($request, $response) => $response);
+
+ // Handle the request.
+ $request = new ServerRequest('GET', '/test');
+ $returns = $app->handle($request);
+ $this->assertInstanceOf(ResponseInterface::class, $returns);
+ }
+
+ /**
+ * When no errors, the error handle is not called.
+ */
+ public function test_error_handling(): void {
+ $responsehandler = $this->getMockBuilder(response_handler::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+ $responsehandler->expects($this->once())->method('get_response_from_exception');
+
+ di::set(response_handler::class, $responsehandler);
+
+ // Configure the app with one middleware that throws an exception.
+ $app = $this->get_simple_app();
+ $app->add(fn ($request, $handler) => throw new \Exception('Test'));
+ $app->add(di::get(error_handling_middleware::class));
+
+ $app->map(['GET'], '/test', fn ($request, $response) => $response);
+
+ // Handle the request.
+ $request = new ServerRequest('GET', '/test');
+ $returns = $app->handle($request);
+ $this->assertInstanceOf(ResponseInterface::class, $returns);
+ }
+}
diff --git a/lib/tests/router/middleware/moodle_bootstrap_middleware_test.php b/lib/tests/router/middleware/moodle_bootstrap_middleware_test.php
new file mode 100644
index 00000000000..078a5a295ba
--- /dev/null
+++ b/lib/tests/router/middleware/moodle_bootstrap_middleware_test.php
@@ -0,0 +1,50 @@
+.
+
+namespace core\router\middleware;
+
+use core\di;
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\ServerRequest;
+
+/**
+ * Tests for the Moodle Bootstrap middleware.
+ *
+ * @package core
+ * @category test
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\middleware\moodle_bootstrap_middleware
+ */
+final class moodle_bootstrap_middleware_test extends route_testcase {
+ public function test_set_page_to_uri(): void {
+ global $PAGE;
+ $app = $this->get_simple_app();
+ $app->add(di::get(moodle_bootstrap_middleware::class));
+ $app->addRoutingMiddleware();
+
+ $app->map(['GET'], '/example', function ($request, $response) {
+ return $response;
+ });
+
+ // Handle the request.
+ $request = new ServerRequest('GET', '/example');
+ $app->handle($request);
+
+ $expect = new \moodle_url('/example');
+ $this->assertEquals($expect->out(), $PAGE->url->out());
+ }
+}
diff --git a/lib/tests/router/middleware/moodle_route_attribute_middleware_test.php b/lib/tests/router/middleware/moodle_route_attribute_middleware_test.php
new file mode 100644
index 00000000000..3a37fc05402
--- /dev/null
+++ b/lib/tests/router/middleware/moodle_route_attribute_middleware_test.php
@@ -0,0 +1,103 @@
+.
+
+namespace core\router\middleware;
+
+use core\di;
+use core\router\route_loader_interface;
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\ServerRequest;
+
+/**
+ * Tests for the Moodle route attribute middleware.
+ *
+ * @package core
+ * @category test
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\middleware\moodle_route_attribute_middleware
+ */
+final class moodle_route_attribute_middleware_test extends route_testcase {
+ /**
+ * Test the Moodle route will be set on a request which has a Moodle route attached.
+ */
+ public function test_has_route(): void {
+ self::load_fixture('core', '/router/route_on_method_only.php');
+ self::load_fixture('core', '/router/route_on_class.php');
+
+ $this->add_route_to_route_loader(
+ \core\fixtures\route_on_method_only::class,
+ 'method_with_route',
+ grouppath: '',
+ );
+
+ $testcase = $this;
+
+ $app = $this->get_simple_app();
+
+ $app->add(function ($request, $handler) use ($testcase) {
+ $route = $request->getAttribute(\core\router\route::class);
+ $testcase->assertInstanceOf(\core\router\route::class, $route);
+
+ return $handler->handle($request);
+ });
+
+ $app->add(di::get(moodle_route_attribute_middleware::class));
+ $app->addRoutingMiddleware();
+ $app->add(function ($request, $handler) use ($testcase) {
+ $testcase->assertNull($request->getAttribute(\core\router\route::class));
+
+ return $handler->handle($request);
+ });
+
+ di::get(route_loader_interface::class)->configure_routes($app);
+
+ $request = new ServerRequest('GET', '/method/path');
+ $app->handle($request);
+ }
+
+ /**
+ * Test that no error occurs when no Moodle route is found.
+ */
+ public function test_has_no_route(): void {
+ self::load_fixture('core', '/router/route_on_method_only.php');
+ self::load_fixture('core', '/router/route_on_class.php');
+
+ $testcase = $this;
+
+ $app = $this->get_simple_app();
+ $app->map(['GET'], '/method/path', fn ($request, $response) => $response);
+
+ $app->add(function ($request, $handler) use ($testcase) {
+ $testcase->assertNull($request->getAttribute(\core\router\route::class));
+
+ return $handler->handle($request);
+ });
+
+ $app->add(di::get(moodle_route_attribute_middleware::class));
+ $app->addRoutingMiddleware();
+ $app->add(function ($request, $handler) use ($testcase) {
+ $testcase->assertNull($request->getAttribute(\core\router\route::class));
+
+ return $handler->handle($request);
+ });
+
+ di::get(route_loader_interface::class)->configure_routes($app);
+
+ $request = new ServerRequest('GET', '/method/path');
+ $app->handle($request);
+ }
+}
diff --git a/lib/tests/router/middleware/uri_normalisation_middleware_test.php b/lib/tests/router/middleware/uri_normalisation_middleware_test.php
new file mode 100644
index 00000000000..c6fa1d806ea
--- /dev/null
+++ b/lib/tests/router/middleware/uri_normalisation_middleware_test.php
@@ -0,0 +1,86 @@
+.
+
+namespace core\router\middleware;
+use GuzzleHttp\Psr7\ServerRequest;
+use Psr\Http\Server\RequestHandlerInterface;
+
+/**
+ * Tests for uri_normalisation_middleware.
+ *
+ * @package core
+ * @category test
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\middleware\uri_normalisation_middleware
+ */
+final class uri_normalisation_middleware_test extends \advanced_testcase {
+ /**
+ * Test the normalisation of URIs.
+ *
+ * @dataProvider data_provider
+ * @param string $input The input URI.
+ * @param string $expected The expected output URI.
+ */
+ public function test_normalisation(
+ string $input,
+ string $expected,
+ ): void {
+ $request = new ServerRequest('GET', $input);
+ $handler = new class () implements \Psr\Http\Server\RequestHandlerInterface {
+ #[\Override]
+ public function handle(\Psr\Http\Message\ServerRequestInterface $request): \Psr\Http\Message\ResponseInterface {
+ return new \GuzzleHttp\Psr7\Response();
+ }
+ };
+
+ $handler = $this->getMockBuilder(RequestHandlerInterface::class)->getMock();
+ $handler->expects($this->once())
+ ->method('handle')
+ ->with(
+ $this->callback(function ($request) use ($expected) {
+ return $request->getUri()->getPath() === $expected;
+ }),
+ );
+
+ $middleware = \core\di::get(uri_normalisation_middleware::class);
+ $middleware->process($request, $handler);
+ }
+
+ /**
+ * Data provider for test_normalisation.
+ */
+ public static function data_provider(): array {
+ return [
+ 'Empty URI' => [
+ '',
+ '/',
+ ],
+ 'Duplicate slashes' => [
+ '/test//path',
+ '/test/path',
+ ],
+ 'Trailing slash' => [
+ '/test/path/',
+ '/test/path',
+ ],
+ 'Multiple duplicate slashes' => [
+ '/test///path//with//more//than//one',
+ '/test/path/with/more/than/one',
+ ],
+ ];
+ }
+}
diff --git a/lib/tests/router/middleware/validation_middleware_test.php b/lib/tests/router/middleware/validation_middleware_test.php
new file mode 100644
index 00000000000..07cdf9ef805
--- /dev/null
+++ b/lib/tests/router/middleware/validation_middleware_test.php
@@ -0,0 +1,140 @@
+.
+
+namespace core\router\middleware;
+
+use core\di;
+use core\router\request_validator;
+use core\router\response_validator;
+use GuzzleHttp\Psr7\Response;
+use GuzzleHttp\Psr7\ServerRequest;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+
+/**
+ * Tests for the validation middleware.
+ *
+ * @package core
+ * @category test
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\middleware\validation_middleware
+ */
+final class validation_middleware_test extends \advanced_testcase {
+ /**
+ * If a request fails request validation, the next middleware will not be called.
+ */
+ public function test_process_fails_request_validation(): void {
+ $request = new ServerRequest('GET', '/test');
+
+ // Mock the request validator to throw an exception.
+ $requestvalidator = $this->getMockBuilder(request_validator::class)->getMock();
+ $requestvalidator->expects($this->once())
+ ->method('validate_request')
+ ->with($request)
+ ->willThrowException(new \Exception('Invalid request'));
+
+ // If the request fails validation, it will not be passed to next Middleware.
+ $handler = $this->getMockBuilder(RequestHandlerInterface::class)->getMock();
+ $handler->expects($this->never())->method('handle');
+
+ // It will never get a response.
+ $responsevalidator = $this->getMockBuilder(response_validator::class)->getMock();
+ $responsevalidator->expects($this->never())->method('validate_response');
+
+ di::set('core\router\request_validator', $requestvalidator);
+ di::set('core\router\response_validator', $responsevalidator);
+
+ // Execute the middleware.
+ $middleware = di::get(validation_middleware::class);
+ $returns = $middleware->process($request, $handler);
+ $this->assertInstanceOf(ResponseInterface::class, $returns);
+ }
+
+ /**
+ * If a request passes request validation, but fails response validation.
+ */
+ public function test_process_passes_request_validation_fails_response_validation(): void {
+ $request = new ServerRequest('GET', '/test');
+ $response = new Response();
+
+ // Mock the request validator to throw an exception.
+ $requestvalidator = $this->getMockBuilder(request_validator::class)->getMock();
+ $requestvalidator->expects($this->once())
+ ->method('validate_request')
+ ->with($request)
+ ->willReturnArgument(0);
+
+ // If the request fails validation, it will not be passed to next Middleware.
+ $handler = $this->getMockBuilder(RequestHandlerInterface::class)->getMock();
+ $handler
+ ->method('handle')
+ ->willReturn($response);
+
+ // It will never get a response.
+ $responsevalidator = $this->getMockBuilder(response_validator::class)->getMock();
+ $responsevalidator
+ ->expects($this->once())
+ ->method('validate_response')
+ ->with($request, $response)
+ ->willThrowException(new \Exception('Invalid response'));
+
+ di::set('core\router\request_validator', $requestvalidator);
+ di::set('core\router\response_validator', $responsevalidator);
+
+ // Execute the middleware.
+ $middleware = di::get(validation_middleware::class);
+ $returns = $middleware->process($request, $handler);
+ $this->assertInstanceOf(ResponseInterface::class, $returns);
+ $this->assertNotEquals($response, $returns);
+ }
+
+ /**
+ * If a request passes request validation, the response middleware will be called.
+ */
+ public function test_process_passes_request_validation(): void {
+ $request = new ServerRequest('GET', '/test');
+ $response = new Response();
+
+ // Mock the request validator to throw an exception.
+ $requestvalidator = $this->getMockBuilder(request_validator::class)->getMock();
+ $requestvalidator->expects($this->once())
+ ->method('validate_request')
+ ->with($request)
+ ->willReturnArgument(0);
+
+ // If the request fails validation, it will not be passed to next Middleware.
+ $handler = $this->getMockBuilder(RequestHandlerInterface::class)->getMock();
+ $handler->expects($this->once())
+ ->method('handle')
+ ->with($request)
+ ->willReturn($response);
+
+ // It will never get a response.
+ $responsevalidator = $this->getMockBuilder(response_validator::class)->getMock();
+ $responsevalidator
+ ->expects($this->once())
+ ->method('validate_response')
+ ->with($request, $response);
+
+ di::set('core\router\request_validator', $requestvalidator);
+ di::set('core\router\response_validator', $responsevalidator);
+
+ // Execute the middleware.
+ $middleware = di::get(validation_middleware::class);
+ $this->assertEquals($response, $middleware->process($request, $handler));
+ }
+}
diff --git a/lib/tests/router/parameters/header_language_test.php b/lib/tests/router/parameters/header_language_test.php
new file mode 100644
index 00000000000..a9dfaf653e5
--- /dev/null
+++ b/lib/tests/router/parameters/header_language_test.php
@@ -0,0 +1,113 @@
+.
+
+namespace core\router\parameters;
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\ServerRequest;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Tests for the language header.
+ *
+ * @package core
+ * @category test
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\parameters\header_language
+ */
+final class header_language_test extends route_testcase {
+ /**
+ * Test that the parameter is valid when the component is not specified.
+ */
+ public function test_component_not_specified(): void {
+ $param = new header_language();
+
+ $request = new ServerRequest('GET', '/example');
+
+ $this->assertInstanceOf(
+ ServerRequestInterface::class,
+ $param->validate($request),
+ );
+ }
+
+ /**
+ * Test that the parameter name is respected
+ */
+ public function test_name(): void {
+ $param = new header_language(name: 'not_the_default_name');
+ $this->assertEquals('not_the_default_name', $param->get_name());
+ }
+
+ /**
+ * Test valid components.
+ *
+ * @param string $component
+ * @dataProvider valid_values
+ */
+ public function test_valid_value(string $component): void {
+ $param = new header_language();
+
+ /** @var ServerRequestInterface $request */ // phpcs:ignore moodle.Commenting.InlineComment.DocBlock
+ $request = (new ServerRequest('GET', '/example'))
+ ->withAddedHeader('Language', $component);
+
+ $this->assertInstanceOf(
+ ServerRequestInterface::class,
+ $param->validate($request),
+ );
+ }
+
+ /**
+ * Test invalid components.
+ *
+ * @param string $component
+ * @dataProvider invalid_values
+ */
+ public function test_invalid_value(string $component): void {
+ $param = new header_language();
+
+ /** @var ServerRequestInterface */ // phpcs:ignore moodle.Commenting.InlineComment.DocBlock
+ $request = (new ServerRequest('GET', '/example'))
+ ->withAddedHeader('Language', $component);
+
+ $this->expectException(\core\exception\invalid_parameter_exception::class);
+ $param->validate($request);
+ }
+
+ /**
+ * Data provider containing seemingly-valid components.
+ *
+ * @return array
+ */
+ public static function valid_values(): array {
+ return [
+ [''],
+ ['en'],
+ ];
+ }
+
+ /**
+ * Data provider containing invalid components.
+ *
+ * @return array
+ */
+ public static function invalid_values(): array {
+ return [
+ ['de'],
+ ['Something wrong!!!'],
+ ];
+ }
+}
diff --git a/lib/tests/router/parameters/path_component_test.php b/lib/tests/router/parameters/path_component_test.php
new file mode 100644
index 00000000000..58e550216e2
--- /dev/null
+++ b/lib/tests/router/parameters/path_component_test.php
@@ -0,0 +1,136 @@
+.
+
+namespace core\router\parameters;
+
+use core\tests\route_testcase;
+use invalid_parameter_exception;
+use GuzzleHttp\Psr7\Response;
+use GuzzleHttp\Psr7\ServerRequest;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Routing\RouteContext;
+
+/**
+ * Tests for the Component path parameter.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\parameters\path_component
+ * @covers \core\router\schema\parameters\path_parameter
+ */
+final class path_component_test extends route_testcase {
+ /**
+ * Test that the parameter is valid when the component is not specified.
+ */
+ public function test_component_not_specified(): void {
+ $param = new path_component();
+
+ $app = $this->get_simple_app();
+ $app->get('/example', fn () => new Response());
+
+ $request = $this->route_request($app, new ServerRequest('GET', '/example'));
+
+ $context = RouteContext::fromRequest($request);
+ $route = $context->getRoute();
+
+ $this->assertInstanceOf(
+ ServerRequestInterface::class,
+ $param->validate($request, $route),
+ );
+ }
+
+ /**
+ * Test that the parameter name is respected
+ */
+ public function test_name(): void {
+ $param = new path_component(name: 'not_the_default_name');
+ $this->assertEquals('not_the_default_name', $param->get_name());
+ }
+
+ /**
+ * Test valid components.
+ *
+ * @param string $component
+ * @dataProvider valid_components
+ */
+ public function test_valid_value(string $component): void {
+ $param = new path_component();
+
+ $app = $this->get_simple_app();
+ $app->get('/example/{component}', fn () => new Response());
+
+ $request = $this->route_request($app, new ServerRequest('GET', "/example/{$component}"));
+
+ $context = RouteContext::fromRequest($request);
+ $route = $context->getRoute();
+
+ $this->assertInstanceOf(
+ ServerRequestInterface::class,
+ $param->validate($request, $route),
+ );
+ }
+
+ /**
+ * Test invalid components.
+ *
+ * @param string $component
+ * @dataProvider invalid_components
+ */
+ public function test_invalid_value(string $component): void {
+ $this->resetAfterTest();
+ $param = new path_component();
+
+ $app = $this->get_simple_app();
+ $app->get('/example/{component}', fn () => new Response());
+
+ $request = $this->route_request($app, new ServerRequest('GET', "/example/{$component}"));
+
+ $context = RouteContext::fromRequest($request);
+ $route = $context->getRoute();
+
+ $this->expectException(invalid_parameter_exception::class);
+ $param->validate($request, $route);
+ }
+
+ /**
+ * Data provider containing seemingly-valid components.
+ *
+ * @return array
+ */
+ public static function valid_components(): array {
+ return [
+ ['core'],
+ ['core_message'],
+ ['mod_forum'],
+ ['assignsubmission_file'],
+ // Note: This is handled with a regex, not an actual lookup.
+ ['blueberry_jam'],
+ ];
+ }
+
+ /**
+ * Data provider containing invalid components.
+ *
+ * @return array
+ */
+ public static function invalid_components(): array {
+ return [
+ ['4things_todo'],
+ ['EASY_AS'],
+ ];
+ }
+}
diff --git a/lib/tests/router/parameters/path_course_test.php b/lib/tests/router/parameters/path_course_test.php
new file mode 100644
index 00000000000..51bd4a23d31
--- /dev/null
+++ b/lib/tests/router/parameters/path_course_test.php
@@ -0,0 +1,170 @@
+.
+
+namespace core\router\parameters;
+
+use core\exception\not_found_exception;
+use core\router\schema\referenced_object;
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\ServerRequest;
+use stdClass;
+
+/**
+ * Tests for the Course Path paraemter.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\parameter
+ * @covers \core\router\schema\parameters\path_parameter
+ * @covers \core\router\parameters\path_course
+ */
+final class path_course_test extends route_testcase {
+ public function test_course_id(): void {
+ $this->resetAfterTest();
+
+ $course = $this->getDataGenerator()->create_course();
+ $coursecontext = \core\context\course::instance($course->id);
+
+ $param = new path_course();
+ $request = new ServerRequest('GET', '/course/view');
+ $newrequest = $param->add_attributes_for_parameter_value($request, $course->id);
+
+ $this->assertInstanceOf(stdClass::class, $newrequest->getAttribute('course'));
+ $this->assertInstanceOf(\core\context\course::class, $newrequest->getAttribute('coursecontext'));
+
+ $this->assertEquals($course->id, $newrequest->getAttribute('course')->id);
+ $this->assertEquals($coursecontext->id, $newrequest->getAttribute('coursecontext')->id);
+ }
+
+ public function test_course_idnumber(): void {
+ $this->resetAfterTest();
+
+ $course = $this->getDataGenerator()->create_course((object) [
+ 'idnumber' => '000117-physics-101-1',
+ ]);
+ $coursecontext = \core\context\course::instance($course->id);
+
+ $param = new path_course();
+ $request = new ServerRequest('GET', '/course/view');
+ $newrequest = $param->add_attributes_for_parameter_value($request, "idnumber:{$course->idnumber}");
+
+ $this->assertInstanceOf(stdClass::class, $newrequest->getAttribute('course'));
+ $this->assertInstanceOf(\core\context\course::class, $newrequest->getAttribute('coursecontext'));
+
+ $this->assertEquals($course->id, $newrequest->getAttribute('course')->id);
+ $this->assertEquals($coursecontext->id, $newrequest->getAttribute('coursecontext')->id);
+ }
+
+ public function test_course_name(): void {
+ $this->resetAfterTest();
+
+ $course = $this->getDataGenerator()->create_course();
+ $coursecontext = \core\context\course::instance($course->id);
+
+ $param = new path_course();
+ $request = new ServerRequest('GET', '/course/view');
+ $newrequest = $param->add_attributes_for_parameter_value($request, "name:{$course->shortname}");
+
+ $this->assertInstanceOf(stdClass::class, $newrequest->getAttribute('course'));
+ $this->assertInstanceOf(\core\context\course::class, $newrequest->getAttribute('coursecontext'));
+
+ $this->assertEquals($course->id, $newrequest->getAttribute('course')->id);
+ $this->assertEquals($coursecontext->id, $newrequest->getAttribute('coursecontext')->id);
+ }
+
+ public function test_validation(): void {
+ $this->resetAfterTest();
+
+ $course = $this->getDataGenerator()->create_course();
+ $coursecontext = \core\context\course::instance($course->id);
+
+ $request = $this->create_route(
+ '/course/view/{course}',
+ "/course/view/name:{$course->shortname}",
+ );
+ $route = $this->get_slim_route_from_request($request);
+
+ $param = new path_course();
+ $newrequest = $param->validate($request, $route);
+
+ $this->assertInstanceOf(stdClass::class, $newrequest->getAttribute('course'));
+ $this->assertInstanceOf(\core\context\course::class, $newrequest->getAttribute('coursecontext'));
+
+ $this->assertEquals($course->id, $newrequest->getAttribute('course')->id);
+ $this->assertEquals($coursecontext->id, $newrequest->getAttribute('coursecontext')->id);
+ }
+
+ /**
+ * Tests for when a course was not found.
+ *
+ * @dataProvider invalid_course_provider
+ * @param string $searchkey
+ */
+ public function test_course_not_found(string $searchkey): void {
+ $param = new path_course();
+ $request = new ServerRequest('GET', '/course/view');
+
+ $this->expectException(not_found_exception::class);
+ $param->add_attributes_for_parameter_value($request, $searchkey);
+ }
+
+ /**
+ * Data provider for test_course_not_found.
+ */
+ public static function invalid_course_provider(): array {
+ return [
+ 'id' => ['999999'],
+ 'idnumber' => ['idnumber:999999'],
+ 'name' => ['name:999999'],
+ 'random string' => ['ksdjflajsdfkjaf:jkladjg9pomadbs902po3'],
+ ];
+ }
+
+ public function test_schema(): void {
+ $param = new path_course();
+ $api = new \core\router\schema\specification();
+ $api->add_component($param);
+ $result = $param->get_openapi_schema($api);
+
+ // Should be a reference.
+ $this->assertInstanceOf(referenced_object::class, $param);
+
+ $schema = $this->get_api_component_schema($api, $param);
+ $this->assertIsObject($schema);
+ $this->assertIsObject($schema->schema);
+ $this->assertObjectHasProperty('pattern', $schema->schema);
+
+ // We do provide some examples here. Make sure they're valid per the regexp.
+ $this->assertIsArray($schema->examples);
+ foreach ($schema->examples as $example) {
+ $this->assertMatchesRegularExpression("/{$schema->schema->pattern}/", $example->value);
+ }
+
+ // Some deliberately invalid ones.
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'id');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'id:1');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'id;1');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'idnumber');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'idnumber:');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'idnumber;12344');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'name');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'name:');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'name;12345');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'shortname');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'shortname:12345');
+ }
+}
diff --git a/lib/tests/router/parameters/path_themename_test.php b/lib/tests/router/parameters/path_themename_test.php
new file mode 100644
index 00000000000..fb155f0fc3b
--- /dev/null
+++ b/lib/tests/router/parameters/path_themename_test.php
@@ -0,0 +1,136 @@
+.
+
+namespace core\router\parameters;
+
+use core\tests\route_testcase;
+use invalid_parameter_exception;
+use GuzzleHttp\Psr7\Response;
+use GuzzleHttp\Psr7\ServerRequest;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Routing\RouteContext;
+
+/**
+ * Tests for the Theme name path parameter.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\parameters\path_themename
+ * @covers \core\router\schema\parameters\path_parameter
+ */
+final class path_themename_test extends route_testcase {
+ /**
+ * Test that the parameter is valid when the themename is not specified.
+ */
+ public function test_themename_not_specified(): void {
+ $param = new path_themename();
+
+ $app = $this->get_simple_app();
+ $app->get('/example', fn () => new Response());
+
+ $request = $this->route_request($app, new ServerRequest('GET', '/example'));
+
+ $context = RouteContext::fromRequest($request);
+ $route = $context->getRoute();
+
+ $this->assertInstanceOf(
+ ServerRequestInterface::class,
+ $param->validate($request, $route),
+ );
+ }
+
+ /**
+ * Test that the parameter name is respected
+ */
+ public function test_name(): void {
+ $param = new path_themename(name: 'not_the_default_name');
+ $this->assertEquals('not_the_default_name', $param->get_name());
+ }
+
+ /**
+ * Test valid themenames.
+ *
+ * @param string $themename
+ * @dataProvider valid_themenames
+ */
+ public function test_valid_value(string $themename): void {
+ $param = new path_themename();
+
+ $app = $this->get_simple_app();
+ $app->get('/example/{themename}', fn () => new Response());
+
+ $request = $this->route_request($app, new ServerRequest('GET', "/example/{$themename}"));
+
+ $context = RouteContext::fromRequest($request);
+ $route = $context->getRoute();
+
+ $this->assertInstanceOf(
+ ServerRequestInterface::class,
+ $param->validate($request, $route),
+ );
+ }
+
+ /**
+ * Test invalid themenames.
+ *
+ * @param string $themename
+ * @dataProvider invalid_themenames
+ */
+ public function test_invalid_value(string $themename): void {
+ $this->resetAfterTest();
+ $param = new path_themename();
+
+ $app = $this->get_simple_app();
+ $app->get('/example/{themename}', fn () => new Response());
+
+ $request = $this->route_request($app, new ServerRequest('GET', "/example/{$themename}"));
+ $context = RouteContext::fromRequest($request);
+ $route = $context->getRoute();
+
+ $this->expectException(invalid_parameter_exception::class);
+ $param->validate($request, $route);
+ }
+
+ /**
+ * Data provider containing seemingly-valid themenames.
+ *
+ * @return array
+ */
+ public static function valid_themenames(): array {
+ return [
+ // Note: This is handled with a regex, not an actual lookup.
+ ['boost'],
+ ['classic'],
+ ['blueberry_jam'],
+ ['abc-def'],
+ ['1theme'],
+ ['UPPERCASE'],
+
+ ];
+ }
+
+ /**
+ * Data provider containing invalid themenames.
+ *
+ * @return array
+ */
+ public static function invalid_themenames(): array {
+ return [
+ ['r|r'],
+ ];
+ }
+}
diff --git a/lib/tests/router/parameters/path_user_test.php b/lib/tests/router/parameters/path_user_test.php
new file mode 100644
index 00000000000..6045d415989
--- /dev/null
+++ b/lib/tests/router/parameters/path_user_test.php
@@ -0,0 +1,194 @@
+.
+
+namespace core\router\parameters;
+
+use core\exception\not_found_exception;
+use core\router\schema\referenced_object;
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\ServerRequest;
+use stdClass;
+
+/**
+ * Tests for the User Path parameter.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\parameters\path_user
+ */
+final class path_user_test extends route_testcase {
+ public function test_current_user(): void {
+ $this->resetAfterTest();
+
+ $user = $this->getDataGenerator()->create_user();
+ $this->setUser($user);
+ $context = \core\context\user::instance($user->id);
+
+ $param = new path_user();
+ $newrequest = $param->add_attributes_for_parameter_value(
+ new ServerRequest('GET', '/user'),
+ 'current',
+ );
+
+ $this->assertInstanceOf(stdClass::class, $newrequest->getAttribute('user'));
+ $this->assertInstanceOf(\core\context\user::class, $newrequest->getAttribute('usercontext'));
+
+ $this->assertEquals($user->id, $newrequest->getAttribute('user')->id);
+ $this->assertEquals($context->id, $newrequest->getAttribute('usercontext')->id);
+ }
+
+ public function test_user_id(): void {
+ $this->resetAfterTest();
+
+ $user = $this->getDataGenerator()->create_user();
+ $context = \core\context\user::instance($user->id);
+
+ $param = new path_user();
+ $newrequest = $param->add_attributes_for_parameter_value(
+ new ServerRequest('GET', '/user'),
+ $user->id,
+ );
+
+ $this->assertInstanceOf(stdClass::class, $newrequest->getAttribute('user'));
+ $this->assertInstanceOf(\core\context\user::class, $newrequest->getAttribute('usercontext'));
+
+ $this->assertEquals($user->id, $newrequest->getAttribute('user')->id);
+ $this->assertEquals($context->id, $newrequest->getAttribute('usercontext')->id);
+ }
+
+ public function test_user_idnumber(): void {
+ $this->resetAfterTest();
+
+ $user = $this->getDataGenerator()->create_user((object) [
+ 'idnumber' => '000117-user',
+ ]);
+ $context = \core\context\user::instance($user->id);
+
+ $param = new path_user();
+ $newrequest = $param->add_attributes_for_parameter_value(
+ new ServerRequest('GET', '/user'),
+ "idnumber:{$user->idnumber}",
+ );
+
+ $this->assertInstanceOf(stdClass::class, $newrequest->getAttribute('user'));
+ $this->assertInstanceOf(\core\context\user::class, $newrequest->getAttribute('usercontext'));
+
+ $this->assertEquals($user->id, $newrequest->getAttribute('user')->id);
+ $this->assertEquals($context->id, $newrequest->getAttribute('usercontext')->id);
+ }
+
+ public function test_username(): void {
+ $this->resetAfterTest();
+
+ $user = $this->getDataGenerator()->create_user();
+ $context = \core\context\user::instance($user->id);
+
+ $param = new path_user();
+ $newrequest = $param->add_attributes_for_parameter_value(
+ new ServerRequest('GET', '/user'),
+ "username:{$user->username}",
+ );
+
+ $this->assertInstanceOf(stdClass::class, $newrequest->getAttribute('user'));
+ $this->assertInstanceOf(\core\context\user::class, $newrequest->getAttribute('usercontext'));
+
+ $this->assertEquals($user->id, $newrequest->getAttribute('user')->id);
+ $this->assertEquals($context->id, $newrequest->getAttribute('usercontext')->id);
+ }
+
+ public function test_validation(): void {
+ $this->resetAfterTest();
+
+ $user = $this->getDataGenerator()->create_user();
+ $context = \core\context\user::instance($user->id);
+
+ $request = $this->create_route(
+ '/user/{user}',
+ "/user/username:{$user->username}",
+ );
+ $route = $this->get_slim_route_from_request($request);
+
+ $param = new path_user();
+ $newrequest = $param->validate($request, $route);
+
+ $this->assertInstanceOf(stdClass::class, $newrequest->getAttribute('user'));
+ $this->assertInstanceOf(\core\context\user::class, $newrequest->getAttribute('usercontext'));
+
+ $this->assertEquals($user->id, $newrequest->getAttribute('user')->id);
+ $this->assertEquals($context->id, $newrequest->getAttribute('usercontext')->id);
+ }
+
+ /**
+ * Tests for when a course was not found.
+ *
+ * @dataProvider invalid_course_provider
+ * @param string $searchkey
+ */
+ public function test_course_not_found(string $searchkey): void {
+ $param = new path_user();
+ $request = new ServerRequest('GET', '/user');
+
+ $this->expectException(not_found_exception::class);
+ $param->add_attributes_for_parameter_value($request, $searchkey);
+ }
+
+ /**
+ * Data provider for test_course_not_found.
+ */
+ public static function invalid_course_provider(): array {
+ return [
+ 'id' => ['999999'],
+ 'idnumber' => ['idnumber:999999'],
+ 'name' => ['username:999999'],
+ 'random string' => ['ksdjflajsdfkjaf:jkladjg9pomadbs902po3'],
+ ];
+ }
+
+ public function test_schema(): void {
+ $param = new path_user();
+ $api = new \core\router\schema\specification();
+ $api->add_component($param);
+ $result = $param->get_openapi_schema($api);
+
+ // Should be a reference.
+ $this->assertInstanceOf(referenced_object::class, $param);
+
+ $schema = $this->get_api_component_schema($api, $param);
+ $this->assertIsObject($schema);
+ $this->assertIsObject($schema->schema);
+ $this->assertObjectHasProperty('pattern', $schema->schema);
+
+ // We do provide some examples here. Make sure they're valid per the regexp.
+ $this->assertIsArray($schema->examples);
+ foreach ($schema->examples as $example) {
+ $this->assertMatchesRegularExpression("/{$schema->schema->pattern}/", $example->value);
+ }
+
+ // Some deliberately invalid ones.
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'id');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'id:1');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'id;1');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'idnumber');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'idnumber:');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'idnumber;12344');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'name');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'name:');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'name;12345');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'shortname');
+ $this->assertDoesNotMatchRegularExpression("/{$schema->schema->pattern}/", 'shortname:12345');
+ }
+}
diff --git a/lib/tests/router/request_validator_test.php b/lib/tests/router/request_validator_test.php
new file mode 100644
index 00000000000..6ecdb1f88bf
--- /dev/null
+++ b/lib/tests/router/request_validator_test.php
@@ -0,0 +1,401 @@
+.
+
+namespace core\router;
+
+use core\param;
+use core\router\schema\parameters\path_parameter;
+use core\router\schema\parameters\query_parameter;
+use core\router\schema\request_body;
+use core\router\schema\response\content\payload_response_type;
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\ServerRequest;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Exception\HttpNotFoundException;
+
+/**
+ * Tests for the request validator.
+ *
+ * @package core
+ * @category test
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\request_validator
+ */
+final class request_validator_test extends route_testcase {
+ /**
+ * Request validation on a route which does not have a matching Moodle route attribute.
+ */
+ public function test_validate_request_not_moodle_route(): void {
+ $request = new ServerRequest('GET', '/example');
+ $validator = \core\di::get(request_validator::class);
+
+ $this->assertInstanceOf(
+ ServerRequestInterface::class,
+ $validator->validate_request($request),
+ );
+ }
+
+ /**
+ * A basic test of request validation.
+ */
+ public function test_validate_request(): void {
+ // The route being tested.
+ $route = new route(
+ path: '/example/{required}',
+ pathtypes: [
+ new path_parameter(
+ name: 'required',
+ type: param::INT,
+ ),
+ ],
+ );
+ $request = $this->get_request_for_routed_route($route, '/example/123');
+ $validator = \core\di::get(request_validator::class);
+
+ $this->assertInstanceOf(
+ ServerRequestInterface::class,
+ $validator->validate_request($request),
+ );
+ }
+
+ /**
+ * A basic test of request validation.
+ */
+ public function test_validate_request_missing_pathtype(): void {
+ // A route with a parameter defined in the path, but no pathtype for it.
+ $route = new route(
+ path: '/example/{required}',
+ );
+
+ $request = $this->get_request_for_routed_route($route, '/example/123');
+
+ $validator = \core\di::get(request_validator::class);
+ $this->assertInstanceOf(
+ ServerRequestInterface::class,
+ $validator->validate_request($request),
+ );
+ }
+
+ /**
+ * When a defined pathtype is missing from the path.
+ */
+ public function test_validate_request_missing_path_component(): void {
+ // A route with a parameter defined in the path, but no pathtype for it.
+ $route = new route(
+ path: '/example/123',
+ pathtypes: [
+ new path_parameter(
+ name: 'required',
+ type: param::INT,
+ ),
+ ],
+ );
+
+ $request = $this->get_request_for_routed_route($route, '/example/123');
+
+ $validator = \core\di::get(request_validator::class);
+ $this->expectException(\coding_exception::class);
+ $this->expectExceptionMessageMatches('/Route.*has 0 arguments.* 1 pathtypes./');
+ $result = $validator->validate_request($request);
+
+ $this->assertInstanceOf(
+ ServerRequestInterface::class,
+ $result,
+ );
+ }
+
+ /**
+ * When a pathtype fails to validate, it will result in an HttpNotFoundException.
+ */
+ public function test_validate_request_invalid_path_component(): void {
+ // Most of the path types are converted to regexes and will lead to a 404 before they get this far.
+ $type = param::INT;
+ $this->assertEmpty(
+ $type->get_clientside_expression(),
+ 'This test requires a type with no clientside expression. Please update the test.',
+ );
+
+ $route = new route(
+ path: '/example/{required}',
+ pathtypes: [
+ new path_parameter(
+ name: 'required',
+ type: $type,
+ ),
+ ],
+ );
+
+ $request = $this->get_request_for_routed_route($route, '/example/abc');
+
+ $validator = \core\di::get(request_validator::class);
+ $this->expectException(HttpNotFoundException::class);
+ $validator->validate_request($request);
+ }
+
+ /**
+ * When a pathtype fails to validate, it will result in an HttpNotFoundException.
+ */
+ public function test_validate_request_invalid_path_component_native(): void {
+ // Most of the path types are converted to regexes and will lead to a 404 before they get this far.
+ $type = param::ALPHA;
+ $this->assertNotEmpty(
+ $type->get_clientside_expression(),
+ 'This test requires a type with clientside expression. Please update the test.',
+ );
+
+ $route = new route(
+ path: '/example/{required}',
+ pathtypes: [
+ new path_parameter(
+ name: 'required',
+ type: $type,
+ ),
+ ],
+ );
+
+ // A value which does not meet the param validation.
+ $request = $this->get_request_for_routed_route($route, '/example/123');
+
+ $validator = \core\di::get(request_validator::class);
+ $this->expectException(HttpNotFoundException::class);
+ $validator->validate_request($request);
+ }
+
+ /**
+ * Query parameter validation.
+ */
+ public function test_validate_request_query_parameter_valid(): void {
+ $type = param::INT;
+ $value = 123;
+
+ $route = new route(
+ path: '/example',
+ queryparams: [
+ new query_parameter(
+ name: 'required',
+ type: $type,
+ ),
+ ],
+ );
+
+ $request = $this->get_request_for_routed_route($route, "/example?required={$value}");
+ $this->assertEquals($value, $request->getQueryParams()['required']);
+
+ // Validate the request.
+ $validator = \core\di::get(request_validator::class);
+ $validatedrequest = $validator->validate_request($request);
+ $this->assertInstanceOf(ServerRequestInterface::class, $validatedrequest);
+ $this->assertEquals($value, $validatedrequest->getQueryParams()['required']);
+ }
+
+ /**
+ * Query parameter validation failure.
+ */
+ public function test_validate_request_query_parameter_invalid(): void {
+ $type = param::INT;
+ $value = 'abc';
+
+ $route = new route(
+ path: '/example',
+ queryparams: [
+ new query_parameter(
+ name: 'required',
+ type: $type,
+ ),
+ ],
+ );
+
+ $request = $this->get_request_for_routed_route($route, "/example?required={$value}");
+ $this->assertEquals($value, $request->getQueryParams()['required']);
+
+ // Validate the request.
+ $validator = \core\di::get(request_validator::class);
+ $this->expectException(\invalid_parameter_exception::class);
+ $validator->validate_request($request);
+ }
+
+ /**
+ * Validate a request body which is expected.
+ */
+ public function test_validate_request_body_valid(): void {
+ $route = new route(
+ path: '/example',
+ requestbody: new request_body(
+ content: new payload_response_type(
+ schema: new \core\router\schema\objects\schema_object(
+ content: [
+ 'preferences' => new \core\router\schema\objects\array_of_strings(
+ keyparamtype: param::TEXT,
+ valueparamtype: param::INT,
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ $request = $this->get_request_for_routed_route($route, "/example");
+ $request = $request->withParsedBody([
+ 'preferences' => [
+ 'key' => 42,
+ ],
+ ]);
+
+ // Validate the request.
+ $validator = \core\di::get(request_validator::class);
+ $result = $validator->validate_request($request);
+ }
+
+ /**
+ * Validate a request body which is expected.
+ */
+ public function test_validate_request_body_invalid(): void {
+ $route = new route(
+ path: '/example',
+ requestbody: new request_body(
+ content: new payload_response_type(
+ schema: new \core\router\schema\objects\schema_object(
+ content: [
+ 'preferences' => new \core\router\schema\objects\array_of_strings(
+ keyparamtype: param::TEXT,
+ valueparamtype: param::INT,
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ $request = $this->get_request_for_routed_route($route, "/example");
+ $request = $request->withParsedBody([
+ 'preferences' => [
+ 'key' => 'value',
+ ],
+ ]);
+
+ // Validate the request.
+ $validator = \core\di::get(request_validator::class);
+ $this->expectException(\invalid_parameter_exception::class);
+ $validator->validate_request($request);
+ }
+
+ /**
+ * Validate a request body which is optional.
+ */
+ public function test_validate_request_body_missing_optional(): void {
+ $route = new route(
+ path: '/example',
+ requestbody: new request_body(
+ content: new payload_response_type(
+ schema: new \core\router\schema\objects\schema_object(
+ content: [
+ 'preferences' => new \core\router\schema\objects\array_of_strings(
+ keyparamtype: param::TEXT,
+ valueparamtype: param::INT,
+ ),
+ ],
+ required: false,
+ ),
+ ),
+ ),
+ );
+
+ $request = $this->get_request_for_routed_route($route, "/example");
+
+ // Validate the request.
+ $validator = \core\di::get(request_validator::class);
+ $result = $validator->validate_request($request);
+ $this->assertInstanceOf(ServerRequestInterface::class, $result);
+ $this->assertInstanceOf(
+ ServerRequestInterface::class,
+ $result,
+ );
+ }
+
+ /**
+ * Validate a request body which is expected.
+ */
+ public function test_validate_request_body_missing_required(): void {
+ $route = new route(
+ path: '/example',
+ requestbody: new request_body(
+ content: new payload_response_type(
+ schema: new \core\router\schema\objects\schema_object(
+ content: [
+ 'preferences' => new \core\router\schema\objects\array_of_strings(
+ keyparamtype: param::TEXT,
+ valueparamtype: param::INT,
+ ),
+ ],
+ ),
+ ),
+ required: true,
+ ),
+ );
+
+ $request = $this->get_request_for_routed_route($route, "/example");
+
+ // Validate the request.
+ $validator = \core\di::get(request_validator::class);
+ $this->expectException(\invalid_parameter_exception::class);
+ $validator->validate_request($request);
+ }
+
+ /**
+ * Validate a request header is appropriately handled.
+ */
+ public function test_validate_request_header_valid(): void {
+ $route = new route(
+ path: '/example',
+ headerparams: [
+ new \core\router\schema\parameters\header_object(
+ name: 'Accept',
+ description: 'The media type of the response',
+ type: param::TEXT,
+ ),
+ new \core\router\schema\parameters\header_object(
+ name: 'X-Multiple',
+ description: 'A header with multiple values',
+ type: param::TEXT,
+ multiple: true,
+ ),
+ ],
+ );
+
+ $request = $this->get_request_for_routed_route($route, "/example")
+ // A known header.
+ ->withHeader('Accept', 'application/json')
+ // An unknown header is kept.
+ ->withHeader('X-Example', 'example')
+ // A known header with multiple values.
+ ->withAddedHeader('X-Multiple', 'value1')
+ ->withAddedHeader('X-Multiple', 'value2')
+ // An unknown header with multiple values.
+ ->withAddedHeader('X-Unknown', 'value1')
+ ->withAddedHeader('X-Unknown', 'value2');
+
+ // Validate the request.
+ $validator = \core\di::get(request_validator::class);
+ $result = $validator->validate_request($request);
+ $this->assertInstanceOf(ServerRequestInterface::class, $result);
+
+ $this->assertEquals('application/json', $result->getHeaderLine('Accept'));
+ $this->assertEquals('example', $result->getHeaderLine('X-Example'));
+ $this->assertEquals(['value1', 'value2'], $result->getHeader('X-Multiple'));
+ $this->assertEquals(['value1', 'value2'], $result->getHeader('X-Unknown'));
+ }
+}
diff --git a/lib/tests/router/response/access_denied_response_test.php b/lib/tests/router/response/access_denied_response_test.php
new file mode 100644
index 00000000000..958a0d96035
--- /dev/null
+++ b/lib/tests/router/response/access_denied_response_test.php
@@ -0,0 +1,70 @@
+.
+
+namespace core\router\response;
+
+use core\exception\access_denied_exception;
+use core\router\schema\response\payload_response;
+use core\router\schema\specification;
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\ServerRequest;
+
+/**
+ * Tests for the access denied response.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\response\exception_response
+ * @covers \core\router\response\access_denied_response
+ */
+final class access_denied_response_test extends route_testcase {
+ public function test_basics(): void {
+ $this->assertIsInt(access_denied_response::get_exception_status_code());
+ $this->assertEquals(403, access_denied_response::get_exception_status_code());
+ }
+
+ public function test_get_response(): void {
+ $exception = new access_denied_exception('the thing', 'theidentifier');
+ $request = new ServerRequest('GET', '/example');
+
+ $payload = access_denied_response::get_response($request, $exception);
+ $this->assertInstanceOf(payload_response::class, $payload);
+
+ $response = $payload->get_response($this->get_router()->get_response_factory());
+ $this->assertInstanceOf(\Psr\Http\Message\ResponseInterface::class, $response);
+ $this->assertEquals(403, $response->getStatusCode());
+ $content = (string) $response->getBody();
+
+ $this->assertStringContainsString(
+ 'the thing',
+ $content,
+ );
+ $this->assertStringContainsString(
+ 'theidentifier',
+ $content,
+ );
+ }
+
+ public function test_openapi_description(): void {
+ $response = new access_denied_response();
+ $openapi = $response->get_openapi_description(new specification());
+
+ // The OpenAPI description should be present.
+ // Note: We do not need to test the value of it. Doing so just reduces maintainability.
+ $this->assertIsString($openapi->description);
+ }
+}
diff --git a/lib/tests/router/response/empty_response_test.php b/lib/tests/router/response/empty_response_test.php
new file mode 100644
index 00000000000..28738f52709
--- /dev/null
+++ b/lib/tests/router/response/empty_response_test.php
@@ -0,0 +1,48 @@
+.
+
+namespace core\router\response;
+
+use core\exception\access_denied_exception;
+use core\router\schema\response\payload_response;
+use core\router\schema\specification;
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\ServerRequest;
+
+/**
+ * Tests for the access denied response.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\response\empty_response
+ */
+final class empty_response_test extends route_testcase {
+ public function test_basics(): void {
+ $response = new empty_response();
+ $this->assertIsInt($response->get_status_code());
+ $this->assertEquals(204, $response->get_status_code());
+ }
+
+ public function test_openapi_description(): void {
+ $response = new empty_response();
+ $openapi = $response->get_openapi_description(new specification());
+
+ // The OpenAPI description should be present.
+ // Note: We do not need to test the value of it. Doing so just reduces maintainability.
+ $this->assertIsString($openapi->description);
+ }
+}
diff --git a/lib/tests/router/response/exception_response_test.php b/lib/tests/router/response/exception_response_test.php
new file mode 100644
index 00000000000..17abbab27cd
--- /dev/null
+++ b/lib/tests/router/response/exception_response_test.php
@@ -0,0 +1,44 @@
+.
+
+namespace core\router\response;
+
+use core\tests\route_testcase;
+
+/**
+ * Tests for the access denied response.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\response\exception_response
+ */
+final class exception_response_test extends route_testcase {
+ public function test_basics(): void {
+ $instance = new class extends exception_response { // phpcs:ignore
+ #[\Override]
+ protected static function get_response_description(): string {
+ return 'Access was denied to the resource.';
+ }
+ };
+
+ $rc = new \ReflectionClass($instance);
+ $rcm = new \ReflectionMethod($instance, 'get_exception_status_code');
+
+ $this->assertIsInt($rcm->invoke(null));
+ $this->assertEquals(500, $rcm->invoke(null));
+ }
+}
diff --git a/lib/tests/router/response/invalid_parameter_response_test.php b/lib/tests/router/response/invalid_parameter_response_test.php
new file mode 100644
index 00000000000..848d0d483be
--- /dev/null
+++ b/lib/tests/router/response/invalid_parameter_response_test.php
@@ -0,0 +1,65 @@
+.
+
+namespace core\router\response;
+
+use core\router\schema\response\payload_response;
+use core\router\schema\specification;
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\ServerRequest;
+use invalid_parameter_exception;
+
+/**
+ * Tests for the path parameter.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\response\exception_response
+ * @covers \core\router\response\invalid_parameter_response
+ */
+final class invalid_parameter_response_test extends route_testcase {
+ public function test_basics(): void {
+ $this->assertIsInt(invalid_parameter_response::get_exception_status_code());
+ $this->assertEquals(400, invalid_parameter_response::get_exception_status_code());
+ }
+
+ public function test_get_response(): void {
+ $exception = new invalid_parameter_exception('Someone made a booboo');
+ $request = new ServerRequest('GET', '/example');
+
+ $payload = invalid_parameter_response::get_response($request, $exception);
+ $this->assertInstanceOf(payload_response::class, $payload);
+
+ $response = $payload->get_response($this->get_router()->get_response_factory());
+ $this->assertInstanceOf(\Psr\Http\Message\ResponseInterface::class, $response);
+ $this->assertEquals(400, $response->getStatusCode());
+
+ $this->assertStringContainsString(
+ 'Someone made a booboo',
+ (string) $response->getBody(),
+ );
+ }
+
+ public function test_openapi_description(): void {
+ $response = new invalid_parameter_response();
+ $openapi = $response->get_openapi_description(new specification());
+
+ // The OpenAPI description should be present.
+ // Note: We do not need to test the value of it. Doing so just reduces maintainability.
+ $this->assertIsString($openapi->description);
+ }
+}
diff --git a/lib/tests/router/response/not_found_response_test.php b/lib/tests/router/response/not_found_response_test.php
new file mode 100644
index 00000000000..5726e41fd3f
--- /dev/null
+++ b/lib/tests/router/response/not_found_response_test.php
@@ -0,0 +1,70 @@
+.
+
+namespace core\router\response;
+
+use core\exception\not_found_exception;
+use core\router\schema\response\payload_response;
+use core\router\schema\specification;
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\ServerRequest;
+
+/**
+ * Tests for the path parameter.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\response\exception_response
+ * @covers \core\router\response\not_found_response
+ */
+final class not_found_response_test extends route_testcase {
+ public function test_basics(): void {
+ $this->assertIsInt(not_found_response::get_exception_status_code());
+ $this->assertEquals(404, not_found_response::get_exception_status_code());
+ }
+
+ public function test_get_response(): void {
+ $exception = new not_found_exception('the thing', 'theidentifier');
+ $request = new ServerRequest('GET', '/example');
+
+ $payload = not_found_response::get_response($request, $exception);
+ $this->assertInstanceOf(payload_response::class, $payload);
+
+ $response = $payload->get_response($this->get_router()->get_response_factory());
+ $this->assertInstanceOf(\Psr\Http\Message\ResponseInterface::class, $response);
+ $this->assertEquals(404, $response->getStatusCode());
+ $content = (string) $response->getBody();
+
+ $this->assertStringContainsString(
+ 'the thing',
+ $content,
+ );
+ $this->assertStringContainsString(
+ 'theidentifier',
+ $content,
+ );
+ }
+
+ public function test_openapi_description(): void {
+ $response = new not_found_response();
+ $openapi = $response->get_openapi_description(new specification());
+
+ // The OpenAPI description should be present.
+ // Note: We do not need to test the value of it. Doing so just reduces maintainability.
+ $this->assertIsString($openapi->description);
+ }
+}
diff --git a/lib/tests/router/response_handler_test.php b/lib/tests/router/response_handler_test.php
new file mode 100644
index 00000000000..1e300428302
--- /dev/null
+++ b/lib/tests/router/response_handler_test.php
@@ -0,0 +1,195 @@
+.
+
+namespace core\router;
+
+use core\di;
+use core\exception\access_denied_exception;
+use core\exception\response_aware_exception;
+use core\router\schema\response\payload_response;
+use core\router\schema\response\view_response;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\Psr7\Response;
+use GuzzleHttp\Psr7\ServerRequest;
+
+/**
+ * Tests for \core\router\response_handler.
+ *
+ * @package core
+ * @category test
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\response_handler
+ */
+final class response_handler_test extends \advanced_testcase {
+ public function test_standardise_response_from_response(): void {
+ $response = new Response();
+
+ $handler = di::get(response_handler::class);
+
+ $result = $handler->standardise_response($response);
+ $this->assertEquals($response, $result);
+ }
+
+ public function test_standardise_response_from_payload_response(): void {
+ $request = new ServerRequest('GET', 'http://example.com');
+ $payload = new payload_response(['key' => 'value'], $request);
+
+ $handler = di::get(response_handler::class);
+
+ $result = $handler->standardise_response($payload);
+ $this->assertInstanceOf(Response::class, $result);
+
+ // The body should be json and contain the same data.
+ $value = json_decode($result->getBody());
+ $this->assertSame(
+ ['key' => 'value'],
+ (array) $value,
+ );
+
+ // The content type should be application/json.
+ $this->assertStringContainsString('application/json', $result->getHeaderLine('Content-Type'));
+
+ // The status code should be 200.
+ $this->assertEquals(200, $result->getStatusCode());
+ }
+
+ public function test_standardise_response_from_payload_response_and_response(): void {
+ $request = new ServerRequest('GET', 'http://example.com');
+ $response = new Response();
+
+ // Add some test headers.
+ $response = $response->withAddedHeader('Content-Type', 'text/plain')
+ ->withAddedHeader('X-Example', 'example-value');
+
+ $payload = new payload_response(['key' => 'value'], $request, $response);
+
+ $handler = di::get(response_handler::class);
+
+ $result = $handler->standardise_response($payload);
+ $this->assertInstanceOf(Response::class, $result);
+
+ // The body should be json and contain the same data.
+ $value = json_decode($result->getBody());
+ $this->assertSame(
+ ['key' => 'value'],
+ (array) $value,
+ );
+
+ // The content type should be application/json and the text/plain header should have been replaced.
+ $this->assertStringContainsString('application/json', $result->getHeaderLine('Content-Type'));
+
+ // The status code should be 200.
+ $this->assertEquals(200, $result->getStatusCode());
+
+ // The X-Example header should be present.
+ $this->assertEquals('example-value', $result->getHeaderLine('X-Example'));
+ }
+
+ public function test_standardise_response_from_view_response(): void {
+ global $OUTPUT;
+
+ $request = new ServerRequest('GET', 'http://example.com');
+
+ // Add some test headers.
+ $initialresponse = new Response();
+ $initialresponse = $initialresponse->withAddedHeader('Content-Type', 'application/json')
+ ->withAddedHeader('X-Example', 'example-value');
+
+ $response = new view_response(
+ template: 'core/welcome',
+ parameters: [
+ 'welcomemessage' => 'Hello, everybody!',
+ ],
+ request: $request,
+ response: $initialresponse,
+ );
+
+ $handler = di::get(response_handler::class);
+
+ $result = $handler->standardise_response($response);
+ $this->assertInstanceOf(Response::class, $result);
+
+ // The content type should be application/json and the text/plain header should have been replaced.
+ $this->assertStringContainsString('text/html', $result->getHeaderLine('Content-Type'));
+
+ // The status code should be 200.
+ $this->assertEquals(200, $result->getStatusCode());
+
+ // The X-Example header should be present.
+ $this->assertEquals('example-value', $result->getHeaderLine('X-Example'));
+
+ $body = (string) $result->getBody();
+ $this->assertStringContainsString('Hello, everybody!', $body);
+ $this->assertEquals(
+ $OUTPUT->render_from_template('core/welcome', ['welcomemessage' => 'Hello, everybody!']),
+ $body,
+ );
+ }
+
+ /**
+ * Test that the response handler can get a response from an exception.
+ */
+ public function test_get_response_from_exception(): void {
+ $request = new ServerRequest('GET', 'http://example.com');
+ $exception = new \Exception('Test exception');
+
+ $handler = di::get(response_handler::class);
+
+ $result = $handler->get_response_from_exception($request, $exception);
+ $this->assertInstanceOf(Response::class, $result);
+
+ // The body should be json and contain the exception message.
+ $value = json_decode($result->getBody(), true);
+ $this->assertArrayHasKey('message', (array) $value);
+ $this->assertArrayHasKey('stacktrace', (array) $value);
+
+ $this->assertEquals(
+ 'Test exception',
+ $value['message'],
+ );
+
+ // The content type should be application/json.
+ $this->assertStringContainsString('application/json', $result->getHeaderLine('Content-Type'));
+
+ // The status code should be 500.
+ $this->assertEquals(500, $result->getStatusCode());
+ }
+
+ /**
+ * Test that the response handler can get a response from an exception.
+ */
+ public function test_get_response_from_response_aware_exception(): void {
+ $request = new ServerRequest('GET', 'http://example.com');
+
+ $exception = new access_denied_exception('Test exception');
+ $handler = di::get(response_handler::class);
+
+ $result = $handler->get_response_from_exception($request, $exception);
+ $this->assertInstanceOf(Response::class, $result);
+
+ // The body should be json and contain the exception message.
+ $value = json_decode($result->getBody(), true);
+ $this->assertArrayHasKey('message', (array) $value);
+ $this->assertArrayHasKey('stacktrace', (array) $value);
+
+ // The content type should be application/json.
+ $this->assertStringContainsString('application/json', $result->getHeaderLine('Content-Type'));
+
+ // The status code should be 500.
+ $this->assertEquals(403, $result->getStatusCode());
+ }
+}
diff --git a/lib/tests/router/response_validator_test.php b/lib/tests/router/response_validator_test.php
new file mode 100644
index 00000000000..0570fb4552f
--- /dev/null
+++ b/lib/tests/router/response_validator_test.php
@@ -0,0 +1,80 @@
+.
+
+namespace core\router;
+
+use core\router\schema\response\response;
+use GuzzleHttp\Psr7\ServerRequest;
+use Psr\Http\Message\ResponseInterface;
+
+/**
+ * Tests for the response_validator.
+ *
+ * @package core
+ * @category test
+ * @copyright 2024 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\response_validator
+ */
+final class response_validator_test extends \advanced_testcase {
+ public function test_validate_response_without_moodle_route(): void {
+ $validator = new response_validator();
+
+ $request = new ServerRequest('GET', 'http://example.com');
+ $response = new \GuzzleHttp\Psr7\Response();
+ $this->assertNull($validator->validate_response($request, $response));
+ }
+
+ public function test_validate_response_with_route_no_responses(): void {
+ $validator = new response_validator();
+
+ $route = new route(path: '/test');
+ $request = (new ServerRequest('GET', 'http://example.com/test'))
+ ->withAttribute(route::class, $route);
+ $response = new \GuzzleHttp\Psr7\Response();
+ $this->assertNull($validator->validate_response($request, $response));
+ }
+
+ public function test_validate_response_with_route_and_response(): void {
+ $validator = new response_validator();
+
+ $routeresponse = new class extends response {
+ // phpcs:ignore
+ public function __construct() {
+ parent::__construct(
+ statuscode: 200,
+ description: 'Test response',
+ );
+ }
+
+ // phpcs:ignore
+ public function validate(
+ ResponseInterface $response,
+ ): void {
+ throw new \Exception('Test exception');
+ }
+ };
+
+ $route = new route(path: '/test', responses: [200 => $routeresponse]);
+ $request = (new ServerRequest('GET', 'http://example.com/test'))
+ ->withAttribute(route::class, $route);
+ $response = new \GuzzleHttp\Psr7\Response();
+
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('Test exception');
+ $validator->validate_response($request, $response);
+ }
+}
diff --git a/lib/tests/router/route_controller_test.php b/lib/tests/router/route_controller_test.php
new file mode 100644
index 00000000000..5654ba47935
--- /dev/null
+++ b/lib/tests/router/route_controller_test.php
@@ -0,0 +1,151 @@
+.
+
+namespace core\router;
+
+use core\router;
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\Response;
+use GuzzleHttp\Psr7\ServerRequest;
+use GuzzleHttp\Psr7\Uri;
+use Psr\Http\Message\ResponseInterface;
+use Slim\Exception\HttpNotFoundException;
+
+/**
+ * Tests for the route_controller trait.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\route_controller
+ */
+final class route_controller_test extends route_testcase {
+ /**
+ * Test that the redirect method works as expected.
+ *
+ * @covers ::redirect
+ */
+ public function test_redirect(): void {
+ $helper = new class (\core\di::get_container()) {
+ use route_controller;
+
+ // phpcs:ignore moodle.Commenting.MissingDocblock.MissingTestcaseMethodDescription
+ public function test(
+ ResponseInterface $response,
+ $url,
+ ) {
+ return $this->redirect($response, $url);
+ }
+ };
+
+ $response = $helper->test(new Response(), '/test');
+ $this->assertEquals(302, $response->getStatusCode());
+ $this->assertEquals('/test', $response->getHeaderLine('Location'));
+ }
+
+ public function test_page_not_found(): void {
+ $request = new ServerRequest('GET', '/test');
+ $response = new Response();
+
+ $helper = new class (\core\di::get_container()) {
+ use route_controller;
+ };
+
+ $rc = new \ReflectionClass($helper);
+ $rcm = $rc->getMethod('page_not_found');
+
+ $this->expectException(HttpNotFoundException::class);
+ $rcm->invokeArgs($helper, [$request, $response]);
+ }
+
+ /**
+ * Test that get_param works as expected.
+ *
+ * @covers \core\router\route_controller::get_param
+ */
+ public function test_get_param(): void {
+ $request = (new \GuzzleHttp\Psr7\ServerRequest('GET', '/test'))
+ ->withQueryParams(['test' => 'value']);
+
+ $helper = new class (\core\di::get_container()) {
+ use route_controller;
+ };
+
+ $rc = new \ReflectionClass($helper);
+ $rcm = $rc->getMethod('get_param');
+
+ // Test a value that exists.
+ $result = $rcm->invokeArgs($helper, [$request, 'test', null]);
+ $this->assertEquals('value', $result);
+
+ $result = $rcm->invokeArgs($helper, [$request, 'test', 'Unused default']);
+ $this->assertEquals('value', $result);
+
+ // Test a value that does not existexists.
+ $result = $rcm->invokeArgs($helper, [$request, 'fake', null]);
+ $this->assertEquals(null, $result);
+ $this->assertdebuggingcalledcount(1);
+
+ $result = $rcm->invokeArgs($helper, [$request, 'fake', 'Used default']);
+ $this->assertEquals('Used default', $result);
+ $this->assertdebuggingcalledcount(1);
+ }
+
+ /**
+ * Test that it is possible to redirect to a callable.
+ *
+ * @covers \core\router\route_controller::redirect_to_callable
+ */
+ public function test_redirect_to_callable(): void {
+ self::load_fixture('core', '/router/route_on_class.php');
+
+ $rc = new \ReflectionClass(\core\fixtures\route_on_class::class);
+ $rcm = $rc->getMethod('method_with_route');
+ $route = $rcm->getAttributes(route::class)[0]->newInstance();
+ $router = $this->get_router();
+ $app = $router->get_app();
+ \core\di::get_container()->set(router::class, $router);
+
+ $app
+ ->get(
+ $route->get_path(),
+ ['core\fixtures\route_on_class', 'method_with_route'],
+ )
+ ->setName('core\fixtures\route_on_class::method_with_route');
+
+ $helper = new class (\core\di::get_container()) {
+ use route_controller;
+ };
+ $rc = new \ReflectionClass($helper);
+ $rcm = $rc->getMethod('redirect_to_callable');
+
+ $response = $rcm->invokeArgs(
+ $helper,
+ [
+ new ServerRequest('GET', '/test'),
+ new Response(),
+ 'core\fixtures\route_on_class::method_with_route',
+ ],
+ );
+ $this->assertInstanceOf(ResponseInterface::class, $response);
+ $this->assertEquals(302, $response->getStatusCode());
+ $this->assertTrue($response->hasHeader('Location'));
+
+ $uri = new Uri($response->getHeader('Location')[0]);
+
+ $this->assertEmpty($uri->getQuery());
+ }
+}
diff --git a/lib/tests/router/route_loader_test.php b/lib/tests/router/route_loader_test.php
new file mode 100644
index 00000000000..ad9a565c282
--- /dev/null
+++ b/lib/tests/router/route_loader_test.php
@@ -0,0 +1,91 @@
+.
+
+namespace core\router;
+
+use core\tests\route_testcase;
+use Slim\Routing\RoutingResults;
+
+/**
+ * Tests for the standard route loader.
+ *
+ * @package core
+ * @category test
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\route_loader
+ */
+final class route_loader_test extends route_testcase {
+ /**
+ * Ensure that the abstract loader implements the interface.
+ */
+ public function test_class_implements(): void {
+ $reflection = new \ReflectionClass(route_loader::class);
+ $this->assertTrue($reflection->implementsInterface(route_loader_interface::class));
+ }
+
+ /**
+ * Test that we are able to fetch all routes.
+ */
+ public function test_configure_api_routes(): void {
+ $loader = new route_loader();
+
+ $app = $this->get_simple_app();
+ $routegroups = $loader->configure_routes($app);
+
+ $this->assertIsArray($routegroups);
+ $this->assertGreaterThanOrEqual(1, count($routegroups));
+ foreach ($routegroups as $group) {
+ // Each of the returned groups shoudl be a RouteGroupInterface.
+ if (is_array($group)) {
+ foreach ($group as $thisgroup) {
+ $this->assertInstanceOf(\Slim\Interfaces\RouteInterface::class, $thisgroup);
+ }
+ } else {
+ $this->assertInstanceOf(\Slim\Interfaces\RouteGroupInterface::class, $group);
+ }
+ }
+
+ // Note: It is not possible to test the actual routes that are added to
+ // the group as they are added to the App which we cannot inspect.
+ // We can, however, test that a known route is resolved.
+
+ $collector = $app->getRouteCollector();
+ $allroutes = $collector->getRoutes();
+ foreach ($allroutes as $route) {
+ $thisroutegroups = $route->getGroups();
+ $this->assertGreaterThanOrEqual(1, $thisroutegroups);
+ foreach ($thisroutegroups as $thisroutegroup) {
+ $this->assertContains($thisroutegroup, $routegroups);
+ }
+ }
+
+ // Resolve the OpenAPI route.
+ $path = route_loader_interface::ROUTE_GROUP_API . '/openapi.json';
+ $result = $app->getRouteResolver()->computeRoutingResults($path, 'GET');
+ $this->assertNotNull($result);
+ $this->assertInstanceOf(\Slim\Routing\RoutingResults::class, $result);
+
+ // The result should be found.
+ $this->assertEquals(RoutingResults::FOUND, $result->getRouteStatus());
+
+ // It should have an identifier which resolves to a Route.
+ $identifier = $result->getRouteIdentifier();
+ $this->assertIsString($identifier);
+ $route = $app->getRouteResolver()->resolveRoute($identifier);
+ $this->assertInstanceOf(\Slim\Interfaces\RouteInterface::class, $route);
+ }
+}
diff --git a/lib/tests/router/route_test.php b/lib/tests/router/route_test.php
new file mode 100644
index 00000000000..072d0f35deb
--- /dev/null
+++ b/lib/tests/router/route_test.php
@@ -0,0 +1,561 @@
+.
+
+namespace core\router;
+
+use core\param;
+use core\router\response\empty_response;
+use core\router\route;
+use core\router\schema\parameters\header_object;
+use core\router\schema\parameters\path_parameter;
+use core\router\schema\parameters\query_parameter;
+use core\router\schema\request_body;
+use core\router\schema\response\response;
+use core\tests\route_testcase;
+
+/**
+ * Tests for user preference API handler.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\route
+ */
+final class route_test extends route_testcase {
+ /**
+ * Test that the Attribute is configured correctly.
+ */
+ public function test_attributes(): void {
+ $route = new \ReflectionClass(route::class);
+ $this->assertNotEmpty($route->getAttributes());
+
+ $this->assertNotEmpty($route->getAttributes(\Attribute::class));
+ $attributes = $route->getAttributes(\Attribute::class);
+ $this->assertCount(1, $attributes);
+ $attribute = reset($attributes);
+ $flags = $attribute->getArguments()[0];
+
+ // This can only be set on class, and method.
+ $this->assertEquals(\Attribute::TARGET_CLASS, $flags & \Attribute::TARGET_CLASS);
+ $this->assertEquals(\Attribute::TARGET_METHOD, $flags & \Attribute::TARGET_METHOD);
+
+ // Only one per method or class allowed.
+ $this->assertEquals(0, \Attribute::IS_REPEATABLE & $flags);
+
+ // Yes, this is a poor test, but if someone wants to extend this attribute in future,
+ // they need to write appropriate tests for it.
+ $this->assertEquals(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD, $flags);
+ }
+
+ /**
+ * Test that path setting and getting works as expected.
+ */
+ public function test_get_path(): void {
+ $route = new route(
+ path: '/example',
+ );
+
+ $this->assertEquals('/example', $route->get_path());
+
+ // And with a parent.
+ $child = new route(
+ path: '/child/path',
+ );
+ $child->set_parent($route);
+ $this->assertEquals('/example/child/path', $child->get_path());
+
+ // But the parent is not changed in any way.
+ $this->assertEquals('/example', $route->get_path());
+ }
+
+
+ /**
+ * Test the default method.
+ */
+ public function test_get_methods(): void {
+ // No method specified.
+ $route = new route();
+ $this->assertNull($route->get_methods());
+
+ // With a method.
+ $route = new route(
+ method: 'POST',
+ );
+ $this->assertEquals(['POST'], $route->get_methods());
+
+ // An array of methods.
+ $route = new route(
+ method: ['POST', 'PUT'],
+ );
+ $this->assertEquals(['POST', 'PUT'], $route->get_methods());
+
+ // A route which inherits its method from its parent.
+ $child = new route();
+ $child->set_parent($route);
+ $this->assertEquals(['POST', 'PUT'], $child->get_methods());
+
+ // A child route will merge its own methods with its parents.
+ $child = new route(
+ method: 'GET',
+ );
+ $child->set_parent($route);
+ $this->assertEquals(['GET', 'POST', 'PUT'], $child->get_methods());
+
+ // A child route which shares some will not duplicate.
+ $child = new route(
+ method: ['GET', 'PUT'],
+ );
+ $child->set_parent($route);
+ $this->assertEquals(['GET', 'POST', 'PUT'], $child->get_methods());
+ }
+
+ /**
+ * Ensure that pathtypes and queryparams accept query parameters correctly.
+ */
+ public function test_params_are_params(): void {
+ $route = new route(
+ pathtypes: [
+ new path_parameter(
+ name: 'example',
+ type: param::RAW,
+ ),
+ new path_parameter(
+ name: 'another',
+ type: param::INT,
+ ),
+ ],
+ queryparams: [
+ new query_parameter(
+ name: 'example',
+ type: param::RAW,
+ ),
+ new query_parameter(
+ name: 'another',
+ type: param::INT,
+ ),
+ ],
+ );
+ $this->assertInstanceOf(route::class, $route);
+ }
+
+ /**
+ * Ensure that pathtypes and queryparams do not accept the wrong type of parameter.
+ *
+ * @dataProvider invalid_constructor_param_types
+ * @param array $args
+ */
+ public function test_params_not_params(array $args): void {
+ $this->expectException(\coding_exception::class);
+ new route(...$args);
+ }
+
+ /**
+ * Data provider for test_params_not_params.
+ *
+ * @return array
+ */
+ public static function invalid_constructor_param_types(): array {
+ return [
+ 'not a param at all in queryparams' => [
+ 'args' => [
+ 'queryparams' => [
+ new query_parameter(
+ name: 'another',
+ type: param::INT,
+ ),
+ new \stdClass(),
+ ],
+ ],
+ ],
+ 'not a param at all in pathtypes' => [
+ 'args' => [
+ 'pathtypes' => [
+ new path_parameter(
+ name: 'another',
+ type: param::INT,
+ ),
+ new \stdClass(),
+ ],
+ ],
+ ],
+ 'not a param at all in headerparams' => [
+ 'args' => [
+ 'headerparams' => [
+ new header_object(
+ name: 'another',
+ type: param::INT,
+ ),
+ new \stdClass(),
+ ],
+ ],
+ ],
+ 'path_parameter in queryparams' => [
+ 'args' => [
+ 'queryparams' => [
+ new path_parameter(
+ name: 'example',
+ type: param::RAW,
+ ),
+ new query_parameter(
+ name: 'another',
+ type: param::INT,
+ ),
+ ],
+ ],
+ ],
+ 'query_parameter in pathtype' => [
+ 'args' => [
+ 'pathtypes' => [
+ new path_parameter(
+ name: 'example',
+ type: param::RAW,
+ ),
+ new query_parameter(
+ name: 'another',
+ type: param::INT,
+ ),
+ ],
+ ],
+ ],
+ 'query_parameter in header' => [
+ 'args' => [
+ 'headerparams' => [
+ new path_parameter(
+ name: 'example',
+ type: param::RAW,
+ ),
+ new query_parameter(
+ name: 'another',
+ type: param::INT,
+ ),
+ ],
+ ],
+ ],
+ ];
+ }
+
+ public function test_get_path_parameters(): void {
+ // No parameters at all.
+ $route = new route();
+ $this->assertEmpty($route->get_path_parameters());
+
+ $child = new route();
+ $child->set_parent($route);
+ $this->assertEmpty($child->get_path_parameters());
+
+ // A route with a single parameter.
+ $route = new route(
+ pathtypes: [
+ new path_parameter(
+ name: 'example',
+ type: param::SAFEPATH,
+ ),
+ ],
+ );
+ $params = $route->get_path_parameters();
+ $this->assertCount(1, $params);
+ $this->assertArrayHasKey('example', $params);
+ $this->assertInstanceOf(path_parameter::class, $params['example']);
+ $this->assertEquals(param::SAFEPATH, $params['example']->get_type());
+
+ // A route with a multiple parameters.
+ $route = new route(
+ pathtypes: [
+ new path_parameter(
+ name: 'example',
+ type: param::SAFEPATH,
+ ),
+ new path_parameter(
+ name: 'another',
+ type: param::INT,
+ ),
+ ],
+ );
+ $params = $route->get_path_parameters();
+ $this->assertCount(2, $params);
+ $this->assertArrayHasKey('example', $params);
+ $this->assertArrayHasKey('another', $params);
+ $this->assertInstanceOf(path_parameter::class, $params['example']);
+ $this->assertEquals(param::SAFEPATH, $params['example']->get_type());
+ $this->assertInstanceOf(path_parameter::class, $params['another']);
+ $this->assertEquals(param::INT, $params['another']->get_type());
+
+ // A child will also inhereit any params from the parent.
+ $child = new route(
+ pathtypes: [
+ new path_parameter(
+ name: 'childparam',
+ type: param::COMPONENT,
+ ),
+ ],
+ );
+ $child->set_parent($route);
+ $params = $child->get_path_parameters();
+ $this->assertCount(3, $params);
+ $this->assertArrayHasKey('example', $params);
+ $this->assertArrayHasKey('another', $params);
+ $this->assertArrayHasKey('childparam', $params);
+ $this->assertInstanceOf(path_parameter::class, $params['example']);
+ $this->assertEquals(param::SAFEPATH, $params['example']->get_type());
+ $this->assertInstanceOf(path_parameter::class, $params['another']);
+ $this->assertEquals(param::INT, $params['another']->get_type());
+ $this->assertInstanceOf(path_parameter::class, $params['childparam']);
+ $this->assertEquals(param::COMPONENT, $params['childparam']->get_type());
+ }
+
+ public function test_get_header_parameters(): void {
+ // No parameters at all.
+ $route = new route();
+ $this->assertEmpty($route->get_header_parameters());
+
+ $child = new route();
+ $child->set_parent($route);
+ $this->assertEmpty($child->get_header_parameters());
+
+ // A route with a single parameter.
+ $route = new route(
+ headerparams: [
+ new header_object(
+ name: 'example',
+ type: param::SAFEPATH,
+ ),
+ ],
+ );
+ $params = $route->get_header_parameters();
+ $this->assertCount(1, $params);
+ $this->assertArrayHasKey('example', $params);
+ $this->assertInstanceOf(header_object::class, $params['example']);
+ $this->assertEquals(param::SAFEPATH, $params['example']->get_type());
+
+ // A route with a multiple parameters.
+ $route = new route(
+ headerparams: [
+ new header_object(
+ name: 'example',
+ type: param::SAFEPATH,
+ ),
+ new header_object(
+ name: 'another',
+ type: param::INT,
+ ),
+ ],
+ );
+ $params = $route->get_header_parameters();
+ $this->assertCount(2, $params);
+ $this->assertArrayHasKey('example', $params);
+ $this->assertArrayHasKey('another', $params);
+ $this->assertInstanceOf(header_object::class, $params['example']);
+ $this->assertEquals(param::SAFEPATH, $params['example']->get_type());
+ $this->assertInstanceOf(header_object::class, $params['another']);
+ $this->assertEquals(param::INT, $params['another']->get_type());
+
+ // A child will also inhereit any params from the parent.
+ $child = new route(
+ headerparams: [
+ new header_object(
+ name: 'childparam',
+ type: param::COMPONENT,
+ ),
+ ],
+ );
+ $child->set_parent($route);
+ $params = $child->get_header_parameters();
+ $this->assertCount(3, $params);
+ $this->assertArrayHasKey('example', $params);
+ $this->assertArrayHasKey('another', $params);
+ $this->assertArrayHasKey('childparam', $params);
+ $this->assertInstanceOf(header_object::class, $params['example']);
+ $this->assertEquals(param::SAFEPATH, $params['example']->get_type());
+ $this->assertInstanceOf(header_object::class, $params['another']);
+ $this->assertEquals(param::INT, $params['another']->get_type());
+ $this->assertInstanceOf(header_object::class, $params['childparam']);
+ $this->assertEquals(param::COMPONENT, $params['childparam']->get_type());
+ }
+
+ public function test_get_query_parameters(): void {
+ // No parameters at all.
+ $route = new route();
+ $this->assertEmpty($route->get_query_parameters());
+
+ $child = new route();
+ $child->set_parent($route);
+ $this->assertEmpty($child->get_query_parameters());
+
+ // A route with a single parameter.
+ $route = new route(
+ queryparams: [
+ new query_parameter(
+ name: 'example',
+ type: param::SAFEPATH,
+ ),
+ ],
+ );
+ $params = $route->get_query_parameters();
+ $this->assertCount(1, $params);
+ $this->assertArrayHasKey('example', $params);
+ $this->assertInstanceOf(query_parameter::class, $params['example']);
+ $this->assertEquals(param::SAFEPATH, $params['example']->get_type());
+
+ // A route with a multiple parameters.
+ $route = new route(
+ queryparams: [
+ new query_parameter(
+ name: 'example',
+ type: param::SAFEPATH,
+ ),
+ new query_parameter(
+ name: 'another',
+ type: param::INT,
+ ),
+ ],
+ );
+ $params = $route->get_query_parameters();
+ $this->assertCount(2, $params);
+ $this->assertArrayHasKey('example', $params);
+ $this->assertArrayHasKey('another', $params);
+ $this->assertInstanceOf(query_parameter::class, $params['example']);
+ $this->assertEquals(param::SAFEPATH, $params['example']->get_type());
+ $this->assertInstanceOf(query_parameter::class, $params['another']);
+ $this->assertEquals(param::INT, $params['another']->get_type());
+
+ // A child will also inhereit any params from the parent.
+ $child = new route(
+ queryparams: [
+ new query_parameter(
+ name: 'childparam',
+ type: param::COMPONENT,
+ ),
+ ],
+ );
+ $child->set_parent($route);
+ $params = $child->get_query_parameters();
+ $this->assertCount(3, $params);
+ $this->assertArrayHasKey('example', $params);
+ $this->assertArrayHasKey('another', $params);
+ $this->assertArrayHasKey('childparam', $params);
+ $this->assertInstanceOf(query_parameter::class, $params['example']);
+ $this->assertEquals(param::SAFEPATH, $params['example']->get_type());
+ $this->assertInstanceOf(query_parameter::class, $params['another']);
+ $this->assertEquals(param::INT, $params['another']->get_type());
+ $this->assertInstanceOf(query_parameter::class, $params['childparam']);
+ $this->assertEquals(param::COMPONENT, $params['childparam']->get_type());
+ }
+
+ /**
+ * Test that has_request_body works as expected.
+ */
+ public function test_has_request_body(): void {
+ $route = new route();
+ $this->assertFalse($route->has_request_body());
+
+ $route = new route(
+ requestbody: new request_body(),
+ );
+ $this->assertTrue($route->has_request_body());
+ }
+
+ public function test_get_request_body(): void {
+ $route = new route();
+ $this->assertNull($route->get_request_body());
+
+ $body = new request_body();
+ $route = new route(
+ requestbody: $body,
+ );
+ $this->assertEquals($body, $route->get_request_body());
+
+ }
+
+ /**
+ * Ensure that has_any_validatable_parameter checks the param types.
+ */
+ public function test_has_any_validatable_parameter(): void {
+ // No validatable params.
+ $route = new route();
+ $this->assertFalse($route->has_any_validatable_parameter());
+
+ // A pathtype is a validatable param.
+ $route = new route(
+ pathtypes: [
+ new path_parameter(
+ name: 'example',
+ type: param::INT,
+ ),
+ ],
+ );
+ $this->assertTrue($route->has_any_validatable_parameter());
+
+ // A pathtype is a validatable param.
+ $route = new route(
+ queryparams: [
+ new query_parameter(
+ name: 'example',
+ type: param::INT,
+ ),
+ ],
+ );
+ $this->assertTrue($route->has_any_validatable_parameter());
+
+ // A request body is a validatable param.
+ $route = new route(
+ requestbody: new request_body(),
+ );
+ $this->assertTrue($route->has_any_validatable_parameter());
+ }
+
+ /**
+ * Test cookie control.
+ */
+ public function test_cookies(): void {
+ // By default we allow cookie access.
+ $route = new route();
+ $this->assertTrue($route->cookies);
+ }
+
+ /**
+ * Test abort_after_config control.
+ */
+ public function test_abort_after_config(): void {
+ // By default we do not abort after config.
+ $route = new route();
+ $this->assertFalse($route->abortafterconfig);
+ }
+
+ public function test_get_responses(): void {
+ $route = new route(
+ responses: [
+ new response(
+ statuscode: 201,
+ description: 'Example response',
+ ),
+ new empty_response(),
+ ],
+ );
+
+ $this->assertCount(2, $route->get_responses());
+
+ $this->assertNull($route->get_response_with_status_code(200));
+ $this->assertInstanceOf(
+ empty_response::class,
+ $route->get_response_with_status_code(204),
+ );
+ $this->assertInstanceOf(
+ response::class,
+ $route->get_response_with_status_code(201),
+ );
+ $this->assertEquals('Example response', $route->get_response_with_status_code(201)->description);
+ }
+}
diff --git a/lib/tests/router/schema/example_test.php b/lib/tests/router/schema/example_test.php
new file mode 100644
index 00000000000..ee74e2b72fb
--- /dev/null
+++ b/lib/tests/router/schema/example_test.php
@@ -0,0 +1,95 @@
+.
+
+namespace core\router\schema;
+use core\tests\route_testcase;
+
+/**
+ * Tests for examples.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\example
+ * @covers \core\router\schema\openapi_base
+ */
+final class example_test extends route_testcase {
+ public function test_value(): void {
+ $example = new example(
+ name: 'First example',
+ value: 'This is a value',
+ );
+ $schema = $example->get_openapi_schema(new specification());
+ $this->assertEquals(
+ 'This is a value',
+ $schema->value,
+ );
+ $this->assertObjectNotHasProperty('externalValue', $schema);
+ }
+
+ public function test_externalvalue(): void {
+ $example = new example(
+ name: 'First example',
+ externalvalue: 'https://example.com/example/value'
+ );
+ $schema = $example->get_openapi_schema(new specification());
+ $this->assertObjectNotHasProperty('value', $schema);
+ $this->assertEquals(
+ 'https://example.com/example/value',
+ $schema->externalValue,
+ );
+ }
+
+ public function test_value_or_externalvalue(): void {
+ $this->expectException(\coding_exception::class);
+ new example(
+ name: 'First example',
+ value: 'This is a value',
+ externalvalue: 'This is an external value',
+ );
+ }
+
+ public function test_basics(): void {
+ $example = new example(
+ name: 'First example',
+ summary: 'This is a summary',
+ description: 'This is a description',
+ value: 'This is a value',
+ );
+
+ $schema = $example->get_openapi_schema(new specification());
+
+ $this->assertEquals('First example', $example->get_name());
+ $this->assertEquals('This is a summary', $schema->summary);
+ $this->assertEquals('This is a description', $schema->description);
+ }
+
+ public function test_referenced_object(): void {
+ $object = new class (
+ name: 'example',
+ value: 'Some value',
+ ) extends example implements referenced_object {
+ };
+
+ $schema = $object->get_openapi_description(new specification());
+ $this->assertObjectNotHasProperty('$ref', $schema);
+ $this->assertObjectHasProperty('value', $schema);
+
+ $reference = $object->get_openapi_schema(new specification());
+ $this->assertObjectNotHasProperty('value', $reference);
+ $this->assertObjectHasProperty('$ref', $reference);
+ }
+}
diff --git a/lib/tests/router/schema/header_object_test.php b/lib/tests/router/schema/header_object_test.php
new file mode 100644
index 00000000000..ccf9780c59b
--- /dev/null
+++ b/lib/tests/router/schema/header_object_test.php
@@ -0,0 +1,85 @@
+.
+
+namespace core\router\schema;
+
+use core\param;
+use core\tests\route_testcase;
+
+/**
+ * Tests for header objects.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\header_object
+ * @covers \core\router\schema\parameters\header_object
+ * @covers \core\router\schema\openapi_base
+ */
+final class header_object_test extends route_testcase {
+ public function test_in_path(): void {
+ $param = new header_object(
+ name: 'example',
+ type: param::INT,
+ );
+ $this->assertEquals('header', $param->get_in());
+ $this->assertEquals('example', $param->get_name());
+
+ // Fetch the description directly.
+ $schema = $param->get_openapi_description(new specification());
+ $this->assertObjectNotHasProperty('$ref', $schema);
+ $this->assertObjectHasProperty('schema', $schema);
+ $this->assertObjectHasProperty('type', $schema->schema);
+ $this->assertEquals('integer', $schema->schema->type);
+ $this->assertObjectNotHasProperty('in', $schema);
+ $this->assertObjectNotHasProperty('name', $schema);
+
+ // Should have the same response a via the get_openapi_schema method.
+ $schema = $param->get_openapi_schema(new specification());
+ $this->assertObjectNotHasProperty('$ref', $schema);
+ $this->assertObjectHasProperty('schema', $schema);
+ $this->assertObjectHasProperty('type', $schema->schema);
+ $this->assertEquals('integer', $schema->schema->type);
+ $this->assertObjectNotHasProperty('in', $schema);
+ $this->assertObjectNotHasProperty('name', $schema);
+ }
+
+ public function test_referenced_object(): void {
+ $object = new class extends header_object implements referenced_object {
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ parent::__construct(
+ name: 'example',
+ type: param::INT,
+ );
+ }
+ };
+
+ $schema = $object->get_openapi_description(new specification());
+ $this->assertObjectNotHasProperty('$ref', $schema);
+ $this->assertObjectHasProperty('schema', $schema);
+ $this->assertObjectHasProperty('type', $schema->schema);
+ $this->assertEquals('integer', $schema->schema->type);
+ $this->assertObjectNotHasProperty('in', $schema);
+ $this->assertObjectNotHasProperty('name', $schema);
+
+ $reference = $object->get_openapi_schema(new specification());
+ $this->assertObjectNotHasProperty('schema', $reference);
+ $this->assertObjectHasProperty('$ref', $reference);
+ }
+}
diff --git a/lib/tests/router/schema/objects/array_of_strings_test.php b/lib/tests/router/schema/objects/array_of_strings_test.php
new file mode 100644
index 00000000000..8c3dffa66d1
--- /dev/null
+++ b/lib/tests/router/schema/objects/array_of_strings_test.php
@@ -0,0 +1,108 @@
+.
+
+namespace core\router\schema\objects;
+
+use core\param;
+use core\router\schema\specification;
+use core\tests\route_testcase;
+
+/**
+ * Tests for the an array of other objects.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\objects\array_of_strings
+ * @covers \core\router\schema\objects\type_base
+ * @covers \core\router\schema\openapi_base
+ */
+final class array_of_strings_test extends route_testcase {
+ public function test_referenced_object(): void {
+ $object = new array_of_strings();
+
+ $schema = $object->get_openapi_description(new specification());
+ $this->assertEquals((object) [
+ 'type' => 'object',
+ 'additionalProperties' => [
+ 'type' => 'string',
+ ],
+ ], $schema);
+
+ $reference = $object->get_openapi_schema(new specification());
+ $this->assertObjectNotHasProperty('type', $reference);
+ $this->assertObjectHasProperty('$ref', $reference);
+ }
+
+ public function test_validation(): void {
+ $object = new array_of_strings(
+ keyparamtype: param::ALPHANUM,
+ valueparamtype: param::INT,
+ );
+
+ $data = $object->validate_data([
+ 'example' => 123,
+ 'other' => 321,
+ ]);
+
+ $this->assertCount(2, $data);
+ $this->assertEquals([
+ 'example' => '123',
+ 'other' => '321',
+ ], $data);
+ }
+
+ /**
+ * Test tha the validate_data method throws an exception when the data is invalid.
+ *
+ * @dataProvider failed_validation_provider
+ * @param param $keyparamtype
+ * @param param $valueparamtype
+ * @param array $data
+ */
+ public function test_validation_failures(
+ param $keyparamtype,
+ param $valueparamtype,
+ array $data,
+ ): void {
+ $object = new array_of_strings(
+ keyparamtype: $keyparamtype,
+ valueparamtype: $valueparamtype,
+ );
+
+ $this->expectException(\invalid_parameter_exception::class);
+
+ $object->validate_data($data);
+ }
+
+ /**
+ * Data provider for test_validation_failures.
+ *
+ * @return array
+ */
+ public static function failed_validation_provider(): array {
+ return [
+ [
+ param::ALPHANUM,
+ param::INT,
+ [
+ 'example' => 123,
+ 'other' => 'threetwoone',
+ ],
+ ],
+ ];
+ }
+}
diff --git a/lib/tests/router/schema/objects/array_of_things_test.php b/lib/tests/router/schema/objects/array_of_things_test.php
new file mode 100644
index 00000000000..c17be7e8463
--- /dev/null
+++ b/lib/tests/router/schema/objects/array_of_things_test.php
@@ -0,0 +1,201 @@
+.
+
+namespace core\router\schema\objects;
+
+use core\param;
+use core\router\schema\referenced_object;
+use core\router\schema\specification;
+use core\tests\route_testcase;
+
+/**
+ * Tests for the an array of other objects.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\objects\array_of_things
+ * @covers \core\router\schema\objects\type_base
+ * @covers \core\router\schema\openapi_base
+ */
+final class array_of_things_test extends route_testcase {
+ public function test_referenced_object(): void {
+ $object = new class ( // phpcs:ignore
+ thingtype: 'integer',
+ content: [
+ 'example' => new schema_object(content: []),
+ ],
+ ) extends array_of_things implements referenced_object {
+ };
+
+ $schema = $object->get_openapi_description(new specification());
+ $this->assertObjectNotHasProperty('$ref', $schema);
+ $this->assertObjectHasProperty('type', $schema);
+ $this->assertEquals('object', $schema->type);
+ $this->assertObjectNotHasProperty('properties', $schema);
+ $this->assertObjectHasProperty('additionalProperties', $schema);
+ $this->assertArrayHasKey('type', $schema->additionalProperties);
+ $this->assertEquals('integer', $schema->additionalProperties['type']);
+
+ $reference = $object->get_openapi_schema(new specification());
+ $this->assertObjectNotHasProperty('type', $reference);
+ $this->assertObjectHasProperty('$ref', $reference);
+ }
+
+ public function test_referenced_object_typebase(): void {
+ $object = new class ( // phpcs:ignore
+ thingtype: new scalar_type(param::INT),
+ ) extends array_of_things implements referenced_object {
+ };
+
+ $schema = $object->get_openapi_description(new specification());
+ $this->assertArrayHasKey('$ref', $schema->additionalProperties);
+
+ $reference = $object->get_openapi_schema(new specification());
+ $this->assertObjectNotHasProperty('type', $reference);
+ $this->assertObjectHasProperty('$ref', $reference);
+ }
+
+ public function test_basics(): void {
+ $object = new array_of_things(
+ thingtype: 'integer',
+ );
+
+ $schema = $object->get_openapi_description(new specification());
+ $this->assertEquals((object) [
+ 'type' => 'object',
+ 'additionalProperties' => [
+ 'type' => 'integer',
+ ],
+ ], $schema);
+ }
+
+
+ /**
+ * Test tha the validate_data method successfully validates content.
+ *
+ * @dataProvider successful_validation_provider
+ * @param param|string|type_base|null $valueparamtype
+ * @param mixed $data
+ */
+ public function test_validation_success(
+ param|string|type_base|null $valueparamtype,
+ mixed $data,
+ ): void {
+ $object = new array_of_things(
+ $valueparamtype,
+ );
+
+ $result = $object->validate_data($data);
+ $this->assertEquals($data, $result);
+ }
+
+ /**
+ * Data provider for test_validation_success.
+ *
+ * @return array
+ */
+ public static function successful_validation_provider(): array {
+ return [
+ [
+ param::INT,
+ [
+ 'example' => 123,
+ 'other' => 321,
+ 'more' => 5634543456543456,
+ ],
+ ],
+ [
+ 'int',
+ [
+ 'example' => 123,
+ 'other' => 321,
+ 'more' => 5634543456543456,
+ ],
+ ],
+ [
+ new scalar_type(param::INT),
+ [
+ 'example' => 123,
+ 'other' => 321,
+ 'more' => 5634543456543456,
+ ],
+ ],
+ [
+ null,
+ [
+ 'example' => 123,
+ 'other' => 321,
+ 'more' => 'This is a string',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Test tha the validate_data method throws an exception when the data is invalid.
+ *
+ * @dataProvider failed_validation_provider
+ * @param param|string|type_base|null $valueparamtype
+ * @param mixed $data
+ */
+ public function test_validation_failures(
+ param|string|type_base|null $valueparamtype,
+ mixed $data,
+ ): void {
+ $object = new array_of_things(
+ $valueparamtype,
+ );
+
+ $this->expectException(\invalid_parameter_exception::class);
+ $object->validate_data($data);
+ }
+
+ /**
+ * Data provider for test_validation_failures.
+ *
+ * @return array
+ */
+ public static function failed_validation_provider(): array {
+ return [
+ [
+ 'int',
+ [
+ 'example' => 123,
+ 'other' => 'threetwoone',
+ ],
+ ],
+ [
+ new scalar_type(param::INT),
+ [
+ 'example' => 123,
+ 'other' => 'threetwoone',
+ ],
+ ],
+ [
+ param::INT,
+ [
+ 'example' => 123,
+ 'other' => 'threetwoone',
+ ],
+ ],
+ [
+ param::INT,
+ 'string',
+ ],
+ ];
+ }
+}
diff --git a/lib/tests/router/schema/objects/scalar_type_test.php b/lib/tests/router/schema/objects/scalar_type_test.php
new file mode 100644
index 00000000000..6105907688d
--- /dev/null
+++ b/lib/tests/router/schema/objects/scalar_type_test.php
@@ -0,0 +1,80 @@
+.
+
+namespace core\router\schema\objects;
+
+use core\param;
+use core\router\schema\referenced_object;
+use core\router\schema\specification;
+use core\tests\route_testcase;
+use invalid_parameter_exception;
+
+/**
+ * Tests for a scalar type.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\objects\scalar_type
+ */
+final class scalar_type_test extends route_testcase {
+ public function test_referenced_object(): void {
+ $object = new class (
+ type: param::ALPHANUM,
+ ) extends scalar_type implements referenced_object {
+ };
+
+ $schema = $object->get_openapi_description(new specification());
+ $this->assertObjectNotHasProperty('$ref', $schema);
+ $this->assertObjectHasProperty('type', $schema);
+
+ $reference = $object->get_openapi_schema(new specification());
+ $this->assertObjectNotHasProperty('type', $reference);
+ $this->assertObjectHasProperty('$ref', $reference);
+ }
+
+ public function test_validation(): void {
+ $object = new scalar_type(
+ type: param::ALPHA,
+ );
+
+ // This should return whatever was input.
+ $data = $object->validate_data('alpha');
+ $this->assertEquals('alpha', $data);
+
+ // By default parameters are optional.
+ $this->assertNull($object->validate_data(null));
+ }
+
+ public function test_validation_nullable(): void {
+ $object = new scalar_type(
+ type: param::ALPHA,
+ required: false,
+ );
+
+ $this->assertNull($object->validate_data(null));
+ }
+
+ public function test_validation_not_nullable(): void {
+ $object = new scalar_type(
+ type: param::ALPHA,
+ required: true,
+ );
+
+ $this->expectException(invalid_parameter_exception::class);
+ $object->validate_data(null);
+ }
+}
diff --git a/lib/tests/router/schema/objects/schema_object_test.php b/lib/tests/router/schema/objects/schema_object_test.php
new file mode 100644
index 00000000000..8d9b7c2f5cc
--- /dev/null
+++ b/lib/tests/router/schema/objects/schema_object_test.php
@@ -0,0 +1,103 @@
+.
+
+namespace core\router\schema\objects;
+
+use core\router\schema\referenced_object;
+use core\router\schema\specification;
+use core\tests\route_testcase;
+
+/**
+ * Tests for the schema_object.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\objects\schema_object
+ * @covers \core\router\schema\openapi_base
+ */
+final class schema_object_test extends route_testcase {
+ public function test_referenced_object(): void {
+ $object = new class (
+ content: [
+ 'example' => new schema_object(content: []),
+ ],
+ ) extends schema_object implements referenced_object {
+ };
+
+ $schema = $object->get_openapi_description(new specification());
+ $this->assertObjectNotHasProperty('$ref', $schema);
+ $this->assertObjectHasProperty('type', $schema);
+ $this->assertObjectHasProperty('properties', $schema);
+ $this->assertObjectHasProperty('example', $schema->properties);
+
+ $reference = $object->get_openapi_schema(new specification());
+ $this->assertObjectNotHasProperty('type', $reference);
+ $this->assertObjectHasProperty('$ref', $reference);
+ }
+
+ public function test_basics(): void {
+ $object = new schema_object(
+ content: [
+ 'example' => new schema_object(content: []),
+ ],
+ );
+
+ $this->assertFalse($object->has('missing'));
+ $this->assertTrue($object->has('example'));
+
+ $this->assertInstanceOf(schema_object::class, $object->get('example'));
+ }
+
+ public function test_invalid_content(): void {
+ $this->expectException(\coding_exception::class);
+
+ new schema_object(content: ['invalid']);
+ }
+
+ public function test_validation(): void {
+ $object = new schema_object(
+ content: [
+ 'example' => new schema_object(content: [
+ 'somekey' => new schema_object(content: []),
+ ]),
+ ],
+ );
+
+ // Nothing in, nothing out.
+ $this->assertEmpty($object->validate_data([]));
+
+ // Surplus data is ignored.
+ $this->assertEmpty($object->validate_data(['surplus' => []]));
+
+ // Valid data is included.
+ $data = $object->validate_data(['example' => []]);
+ $this->assertArrayHasKey('example', $data);
+ $this->assertIsArray($data['example']);
+
+ // Valid data is included and surplus data is removed.
+ $data = $object->validate_data(['example' => [], 'surplus' => []]);
+ $this->assertArrayHasKey('example', $data);
+ $this->assertIsArray($data['example']);
+ $this->assertArrayNotHasKey('surplus', $data);
+
+ // Applied to nested values.
+ $data = $object->validate_data(['example' => ['somekey' => []]]);
+ $this->assertArrayHasKey('example', $data);
+ $this->assertIsArray($data['example']);
+ $this->assertArrayHasKey('somekey', $data['example']);
+ }
+}
diff --git a/lib/tests/router/schema/objects/stacktrace_test.php b/lib/tests/router/schema/objects/stacktrace_test.php
new file mode 100644
index 00000000000..3ddcb13e912
--- /dev/null
+++ b/lib/tests/router/schema/objects/stacktrace_test.php
@@ -0,0 +1,62 @@
+.
+
+namespace core\router\schema\objects;
+
+use core\router\schema\specification;
+use core\tests\route_testcase;
+
+/**
+ * Tests for a stacktrace.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\objects\stacktrace
+ * @covers \core\router\schema\objects\type_base
+ */
+final class stacktrace_test extends route_testcase {
+ public function test_referenced_object(): void {
+ $object = new stacktrace();
+
+ $schema = $object->get_openapi_description(new specification());
+ $this->assertObjectNotHasProperty('$ref', $schema);
+ $this->assertObjectHasProperty('type', $schema);
+ $this->assertObjectHasProperty('items', $schema);
+ $this->assertObjectHasProperty('examples', $schema);
+ $this->assertEquals('array', $schema->type);
+
+ $reference = $object->get_openapi_schema(new specification());
+ $this->assertObjectNotHasProperty('type', $reference);
+ $this->assertObjectHasProperty('$ref', $reference);
+ }
+
+ public function test_validation(): void {
+ $object = new stacktrace();
+
+ // This should return whatever was input.
+ $data = $object->validate_data([
+ 'example' => 123,
+ 'other' => 321,
+ ]);
+
+ $this->assertCount(2, $data);
+ $this->assertEquals([
+ 'example' => 123,
+ 'other' => 321,
+ ], $data);
+ }
+}
diff --git a/lib/tests/router/schema/parameter_test.php b/lib/tests/router/schema/parameter_test.php
new file mode 100644
index 00000000000..a4a867e79ad
--- /dev/null
+++ b/lib/tests/router/schema/parameter_test.php
@@ -0,0 +1,192 @@
+.
+
+namespace core\router\schema;
+
+use core\param;
+use core\router\route;
+use core\router\schema\objects\array_of_strings;
+use core\router\schema\objects\schema_object;
+use core\router\schema\specification;
+use core\tests\route_testcase;
+
+/**
+ * Tests for parameters.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\parameter
+ * @covers \core\router\schema\openapi_base
+ */
+final class parameter_test extends route_testcase {
+ public function test_basics(): void {
+ $param = new parameter(
+ name: 'examplename',
+ in: 'examplein',
+ );
+ $this->assertEquals('examplename', $param->get_name());
+ $this->assertEquals('examplein', $param->get_in());
+
+ // The default behaviour is for a parameter to not be required if it was not explicitly specified.
+ $this->assertFalse($param->is_required(new route()));
+ }
+
+ /**
+ * Test for required default.
+ *
+ * @dataProvider required_default_provider
+ * @param array $params
+ */
+ public function test_required_default(array $params): void {
+ $this->expectException(\coding_exception::class);
+ new parameter(...$params);
+ }
+
+ /**
+ * Data provider for required default tests.
+ *
+ * @return array
+ */
+ public static function required_default_provider(): array {
+ return [
+ [
+ [
+ 'name' => 'example',
+ 'in' => parameter::IN_PATH,
+ 'required' => true,
+ 'default' => 0,
+ ],
+ [
+ 'name' => 'example',
+ 'in' => parameter::IN_PATH,
+ 'required' => true,
+ 'default' => false,
+ ],
+ [
+ 'name' => 'example',
+ 'in' => parameter::IN_PATH,
+ 'required' => true,
+ 'default' => "",
+ ],
+ ],
+ ];
+ }
+
+ public function test_get_type(): void {
+ $param = new parameter(
+ name: 'examplename',
+ in: 'examplein',
+ type: param::ALPHANUMEXT,
+ );
+ $this->assertEquals(param::ALPHANUMEXT, $param->get_type());
+ }
+
+ /**
+ * Test for is_required.
+ *
+ * @dataProvider is_required_provider
+ * @param null|bool $required
+ * @param bool $expected
+ */
+ public function test_is_required(?bool $required, bool $expected): void {
+ $param = new parameter(
+ name: 'example',
+ in: parameter::IN_HEADER,
+ required: $required,
+ );
+
+ $this->assertSame($expected, $param->is_required(new route()));
+ }
+
+ /**
+ * Data provider for is_required tests.
+ *
+ * @return array
+ */
+ public static function is_required_provider(): array {
+ return [
+ [true, true],
+ [false, false],
+ [null, false],
+ ];
+ }
+
+ public function test_example(): void {
+ $example = new example('examplevalue');
+ $param = new parameter(
+ name: 'example',
+ in: 'header',
+ type: param::INT,
+ example: $example,
+ );
+ $description = $param->get_openapi_description(new specification());
+ $this->assertArrayHasKey('examplevalue', $description->examples);
+ }
+
+ public function test_examples(): void {
+ $example = new example('examplevalue');
+ $param = new parameter(
+ name: 'example',
+ in: 'header',
+ type: param::INT,
+ examples: [$example],
+ );
+ $description = $param->get_openapi_description(new specification());
+ $this->assertArrayHasKey('examplevalue', $description->examples);
+ }
+
+ public function test_example_and_examples(): void {
+ $example = new example('examplevalue');
+ $this->expectException(\coding_exception::class);
+ new parameter(
+ name: 'example',
+ in: 'header',
+ type: param::INT,
+ example: $example,
+ examples: [$example],
+ );
+ }
+
+ public function test_schema(): void {
+ $schema = new schema_object(
+ content: [
+ new array_of_strings(),
+ ],
+ );
+ $param = new parameter(
+ name: 'example',
+ in: 'header',
+ type: param::INT,
+ schema: $schema,
+ );
+ $description = $param->get_openapi_description(new specification());
+ $this->assertNotNull($description->schema);
+ $this->assertEquals('object', $description->schema->type);
+ }
+
+ public function test_schema_includes_clientside_pattern(): void {
+ $param = new parameter(
+ name: 'example',
+ in: 'header',
+ type: param::ALPHANUM,
+ );
+ $description = $param->get_openapi_description(new specification());
+ $this->assertNotNull($description->schema);
+ $this->assertEquals('string', $description->schema->type);
+ $this->assertIsString($description->schema->pattern);
+ }
+}
diff --git a/lib/tests/router/schema/parameters/header_object_test.php b/lib/tests/router/schema/parameters/header_object_test.php
new file mode 100644
index 00000000000..627219f5b5d
--- /dev/null
+++ b/lib/tests/router/schema/parameters/header_object_test.php
@@ -0,0 +1,171 @@
+.
+
+namespace core\router\schema\parameters;
+
+use core\param;
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\ServerRequest;
+use invalid_parameter_exception;
+use Psr\Http\Message\ServerRequestInterface;
+use ValueError;
+
+/**
+ * Tests for header objects.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\parameters\header_object
+ */
+final class header_object_test extends route_testcase {
+ public function test_validate(): void {
+ $param = new header_object(
+ name: 'example',
+ type: param::TEXT,
+ );
+
+ /** @var ServerRequestInterface $request */ // phpcs:ignore moodle.Commenting.InlineComment.DocBlock
+ $request = (new ServerRequest('GET', 'http://example.com'))
+ // A known header.
+ ->withHeader('Accept', 'application/json')
+ // An unknown header is kept.
+ ->withHeader('X-Example', 'example')
+ // A known header with multiple values.
+ ->withAddedHeader('X-Multiple', 'value1')
+ ->withAddedHeader('X-Multiple', 'value2')
+ // An unknown header with multiple values.
+ ->withAddedHeader('X-Unknown', 'value1')
+ ->withAddedHeader('X-Unknown', 'value2');
+
+ $result = $param->validate($request);
+ $this->assertInstanceOf(ServerRequestInterface::class, $result);
+
+ $this->assertEquals('application/json', $result->getHeaderLine('Accept'));
+ $this->assertEquals('example', $result->getHeaderLine('X-Example'));
+ $this->assertEquals(['value1', 'value2'], $result->getHeader('X-Multiple'));
+ $this->assertEquals(['value1', 'value2'], $result->getHeader('X-Unknown'));
+ }
+
+ public function test_required_missing(): void {
+ $request = new ServerRequest('GET', 'http://example.com');
+ $param = new header_object(
+ name: 'example',
+ type: param::TEXT,
+ required: true,
+ );
+
+ $this->expectException(invalid_parameter_exception::class);
+ $param->validate($request);
+ }
+
+ public function test_optional_default(): void {
+ $request = new ServerRequest('GET', 'http://example.com');
+ $param = new header_object(
+ name: 'example',
+ type: param::TEXT,
+ required: false,
+ default: 'default',
+ );
+
+ $request = $param->validate($request);
+ $this->assertEquals('default', $request->getHeaderLine('example'));
+ }
+
+ public function test_optional_without_default(): void {
+ $request = new ServerRequest('GET', 'http://example.com');
+ $param = new header_object(
+ name: 'example',
+ type: param::TEXT,
+ required: false,
+ );
+
+ $request = $param->validate($request);
+ $this->assertEquals(null, $request->getHeaderLine('example'));
+ }
+
+ public function test_multiple_allowed(): void {
+ $request = new ServerRequest('GET', 'http://example.com');
+ $request = $request
+ ->withAddedHeader('example', 'value1')
+ ->withAddedHeader('example', 'value2');
+ $param = new header_object(
+ name: 'example',
+ type: param::TEXT,
+ multiple: true,
+ );
+
+ $request = $param->validate($request);
+ $this->assertEquals(['value1', 'value2'], $request->getHeader('example'));
+ }
+
+ public function test_multiple_not_allowed(): void {
+ $request = new ServerRequest('GET', 'http://example.com');
+ $request = $request
+ ->withAddedHeader('example', 'value1')
+ ->withAddedHeader('example', 'value2');
+ $param = new header_object(
+ name: 'example',
+ type: param::TEXT,
+ multiple: false,
+ );
+
+ $this->expectException(invalid_parameter_exception::class);
+ $param->validate($request);
+ }
+
+ public function test_boolean_param(): void {
+ $request = (new ServerRequest('GET', 'http://example.com'))
+ ->withHeader('example', 'true');
+ $param = new header_object(
+ name: 'example',
+ type: param::BOOL,
+ );
+
+ $request = $param->validate($request);
+ $this->assertEquals([true], $request->getHeader('example'));
+ }
+
+ public function test_multiple_boolean_param(): void {
+ $request = (new ServerRequest('GET', 'http://example.com'))
+ ->withAddedHeader('example', 'true')
+ ->withAddedHeader('example', 'false')
+ ->withAddedHeader('example', 'true');
+ $param = new header_object(
+ name: 'example',
+ type: param::BOOL,
+ multiple: true,
+ );
+
+ $request = $param->validate($request);
+ $this->assertEquals([true, false, true], $request->getHeader('example'));
+ }
+
+ public function test_multiple_boolean_param_invalid(): void {
+ $request = (new ServerRequest('GET', 'http://example.com'))
+ ->withAddedHeader('example', 'true')
+ ->withAddedHeader('example', '0')
+ ->withAddedHeader('example', 'true');
+ $param = new header_object(
+ name: 'example',
+ type: param::BOOL,
+ multiple: true,
+ );
+
+ $this->expectException(ValueError::class);
+ $param->validate($request);
+ }
+}
diff --git a/lib/tests/router/schema/parameters/path_parameter_test.php b/lib/tests/router/schema/parameters/path_parameter_test.php
new file mode 100644
index 00000000000..b78b65eb01a
--- /dev/null
+++ b/lib/tests/router/schema/parameters/path_parameter_test.php
@@ -0,0 +1,165 @@
+.
+
+namespace core\router\schema\parameters;
+
+use core\param;
+use core\router\route;
+use core\router\schema\referenced_object;
+use core\router\schema\specification;
+use core\tests\route_testcase;
+use invalid_parameter_exception;
+use Psr\Http\Message\ServerRequestInterface;
+use Slim\Routing\RouteContext;
+
+/**
+ * Tests for the path parameter.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\parameter
+ * @covers \core\router\schema\parameters\path_parameter
+ * @covers \core\router\schema\openapi_base
+ */
+final class path_parameter_test extends route_testcase {
+ public function test_in_path(): void {
+ $param = new path_parameter(name: 'example');
+ $this->assertEquals('path', $param->get_in());
+ $this->assertEquals('example', $param->get_name());
+ }
+
+ /**
+ * Test the is_required method.
+ *
+ * @dataProvider is_required_provider
+ * @param string $path
+ * @param bool $expected
+ */
+ public function test_is_required(string $path, bool $expected): void {
+ $route = new route(
+ path: $path,
+ );
+ $param = new path_parameter(name: 'value');
+ $this->assertEquals($expected, $param->is_required($route));
+ }
+
+ /**
+ * Data provider for the is_required method.
+ *
+ * @return array
+ */
+ public static function is_required_provider(): array {
+ return [
+ ['/is/required/{value}', true],
+ ['/is/optional/[{value}]', false],
+ ['/is/[optional/[{value}]]', false],
+ ];
+ }
+
+ /**
+ * Test fo the OPenAPI description in different configurations.
+ *
+ * @dataProvider openapi_required_values_provider
+ * @param string $path If the a value is a required part of the path
+ * @param bool $required If the value is expected to be required
+ */
+ public function test_get_openapi_description_required_values(
+ string $path,
+ bool $required,
+ ): void {
+ $param = new path_parameter(
+ name: 'value',
+ type: param::INT,
+ );
+
+ $api = new specification();
+ $result = $param->get_openapi_description($api, $path);
+ if ($required) {
+ $this->assertNotNull($result);
+ $this->assertTrue($result->required);
+ $this->assertEquals('value', $result->name);
+ } else {
+ $this->assertNull($result);
+ }
+ }
+
+ /**
+ * Data provider for OpenAPI Required values.
+ *
+ * @return array
+ */
+ public static function openapi_required_values_provider(): array {
+ return [
+ ['/is/required/{value}/with/children', true],
+ ['/is/required/{value}', true],
+ ['/is/optional', false],
+ ];
+ }
+
+ /**
+ * Ensure that a validation failure results in an invalid_parameter_exception.
+ */
+ public function test_validation_failure(): void {
+ $param = new path_parameter(
+ name: 'example',
+ type: param::INT,
+ );
+ $value = "example";
+
+ $request = $this->create_route(
+ '/example/{example}',
+ "/example/{$value}",
+ );
+ $route = $request->getAttribute(RouteContext::ROUTE);
+
+ $this->expectException(invalid_parameter_exception::class);
+ $param->validate($request, $route);
+ }
+
+ public function test_validation_success(): void {
+ $param = new path_parameter(
+ name: 'example',
+ type: param::INT,
+ );
+ $value = 12345;
+
+ $request = $this->create_route(
+ '/example/{example}',
+ "/example/{$value}",
+ );
+ $route = $request->getAttribute(RouteContext::ROUTE);
+
+ $validatedresult = $param->validate($request, $route);
+ $this->assertInstanceOf(ServerRequestInterface::class, $validatedresult);
+ }
+
+ public function test_referenced_object(): void {
+ $object = new class (
+ name: 'example',
+ type: param::INT,
+ ) extends path_parameter implements referenced_object {
+ };
+
+ $schema = $object->get_openapi_description(new specification());
+ $this->assertObjectNotHasProperty('$ref', $schema);
+ $this->assertObjectHasProperty('schema', $schema);
+
+ $reference = $object->get_openapi_schema(new specification());
+ $this->assertObjectNotHasProperty('schema', $reference);
+ $this->assertObjectHasProperty('$ref', $reference);
+ }
+}
diff --git a/lib/tests/router/schema/parameters/query_parameter_test.php b/lib/tests/router/schema/parameters/query_parameter_test.php
new file mode 100644
index 00000000000..dbd66d5d052
--- /dev/null
+++ b/lib/tests/router/schema/parameters/query_parameter_test.php
@@ -0,0 +1,316 @@
+.
+
+namespace core\router\schema\parameters;
+
+use core\param;
+use core\router\route;
+use core\router\schema\referenced_object;
+use core\router\schema\specification;
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\ServerRequest;
+
+/**
+ * Tests for the query parameters.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\parameter
+ * @covers \core\router\schema\parameters\query_parameter
+ * @covers \core\router\schema\openapi_base
+ */
+final class query_parameter_test extends route_testcase {
+ public function test_in_path(): void {
+ $param = new query_parameter(name: 'example');
+ $this->assertEquals('query', $param->get_in());
+ $this->assertEquals('example', $param->get_name());
+ }
+
+ /**
+ * Test the is_required method.
+ *
+ * @dataProvider is_required_provider
+ * @param bool|null $required
+ * @param bool $expected
+ */
+ public function test_is_required(?bool $required, bool $expected): void {
+ $param = new query_parameter(
+ name: 'value',
+ required: $required,
+ );
+ $this->assertEquals($expected, $param->is_required(new route()));
+ }
+
+ /**
+ * Data provider for the is_required method.
+ *
+ * @return array
+ */
+ public static function is_required_provider(): array {
+ return [
+ [true, true],
+ [false, false],
+ [null, false],
+ ];
+ }
+
+ public function test_referenced_object(): void {
+ $object = new class (
+ name: 'example',
+ type: param::INT,
+ ) extends query_parameter implements referenced_object {
+ };
+
+ $schema = $object->get_openapi_description(new specification());
+ $this->assertObjectNotHasProperty('$ref', $schema);
+ $this->assertObjectHasProperty('schema', $schema);
+
+ $reference = $object->get_openapi_schema(new specification());
+ $this->assertObjectNotHasProperty('schema', $reference);
+ $this->assertObjectHasProperty('$ref', $reference);
+ }
+
+ /**
+ * Test for the allowReserved property.
+ *
+ * @dataProvider allow_reserved_provider
+ * @param bool|null $allowreserved
+ * @param bool $expected
+ */
+ public function test_allow_reserved(
+ ?bool $allowreserved,
+ bool $expected,
+ ): void {
+ $param = new query_parameter(
+ name: 'example',
+ type: param::INT,
+ allowreserved: $allowreserved,
+ );
+
+ $schema = $param->get_openapi_description(new specification());
+ if ($expected) {
+ $this->assertObjectHasProperty('allowReserved', $schema);
+ $this->assertTrue($schema->allowReserved);
+ } else {
+ $this->assertObjectNotHasProperty('allowReserved', $schema);
+ }
+ }
+
+ /**
+ * Data provider ofr testing whether reserved characters are allowed.
+ *
+ * @return array
+ */
+ public static function allow_reserved_provider(): array {
+ return [
+ [true, true],
+ [false, false],
+ [null, false],
+ ];
+ }
+
+ /**
+ * Tests of the param validation.
+ *
+ * @dataProvider validation_provider
+ * @param array $properties
+ * @param array $params
+ * @param array $expected
+ */
+ public function test_validation(
+ array $properties,
+ array $params,
+ array $expected,
+ ): void {
+ $param = new query_parameter(...$properties);
+
+ $request = new ServerRequest('GET', '/example');
+ $request = $request->withQueryParams(array_merge(
+ $request->getQueryParams(),
+ $params,
+ ));
+
+ $newrequest = $param->validate($request, $request->getQueryParams());
+ $this->assertEquals($expected, $newrequest->getQueryParams());
+ }
+
+ /**
+ * Validation data provider.
+ *
+ * @return array
+ */
+ public static function validation_provider(): array {
+ return [
+ 'Basic required param' => [
+ [
+ 'name' => 'example',
+ 'type' => param::INT,
+ 'required' => true,
+ ],
+ [
+ 'example' => 12345,
+ 'otherfield' => 'abcde',
+ ],
+ [
+ 'example' => 12345,
+ 'otherfield' => 'abcde',
+ ],
+ ],
+ 'Basic optional param' => [
+ [
+ 'name' => 'example',
+ 'type' => param::INT,
+ ],
+ [
+ 'example' => 12345,
+ 'otherfield' => 'abcde',
+ ],
+ [
+ 'example' => 12345,
+ 'otherfield' => 'abcde',
+ ],
+ ],
+ 'Basic optional param not provided' => [
+ [
+ 'name' => 'example',
+ 'type' => param::INT,
+ ],
+ [
+ 'otherfield' => 'abcde',
+ ],
+ [
+ 'otherfield' => 'abcde',
+ 'example' => null,
+ ],
+ ],
+ 'Basic optional param not provided with defaults' => [
+ [
+ 'name' => 'example',
+ 'type' => param::INT,
+ 'default' => 999,
+ ],
+ [
+ 'otherfield' => 'abcde',
+ ],
+ [
+ 'otherfield' => 'abcde',
+ 'example' => 999,
+ ],
+ ],
+ 'Special handling for a bool (true)' => [
+ [
+ 'name' => 'example',
+ 'type' => param::BOOL,
+ ],
+ [
+ 'otherfield' => 'abcde',
+ 'example' => 'true',
+ ],
+ [
+ 'otherfield' => 'abcde',
+ 'example' => true,
+ ],
+ ],
+ 'Special handling for a bool (false)' => [
+ [
+ 'name' => 'example',
+ 'type' => param::BOOL,
+ ],
+ [
+ 'otherfield' => 'abcde',
+ 'example' => 'false',
+ ],
+ [
+ 'otherfield' => 'abcde',
+ 'example' => false,
+ ],
+ ],
+ 'Special handling for a bool - not specified' => [
+ [
+ 'name' => 'example',
+ 'type' => param::BOOL,
+ ],
+ [
+ 'otherfield' => 'abcde',
+ ],
+ [
+ 'otherfield' => 'abcde',
+ 'example' => null,
+ ],
+ ],
+ 'Special handling for a bool - default true' => [
+ [
+ 'name' => 'example',
+ 'type' => param::BOOL,
+ 'default' => true,
+ ],
+ [
+ 'otherfield' => 'abcde',
+ ],
+ [
+ 'otherfield' => 'abcde',
+ 'example' => true,
+ ],
+ ],
+ 'Special handling for a bool - default false' => [
+ [
+ 'name' => 'example',
+ 'type' => param::BOOL,
+ 'default' => false,
+ ],
+ [
+ 'otherfield' => 'abcde',
+ ],
+ [
+ 'otherfield' => 'abcde',
+ 'example' => false,
+ ],
+ ],
+ ];
+ }
+
+ public function test_validation_boolean_failure(): void {
+ $param = new query_parameter(
+ name: 'example',
+ type: param::BOOL,
+ );
+
+ $request = new ServerRequest('GET', '/example');
+ $request = $request->withQueryParams(array_merge(
+ $request->getQueryParams(),
+ [
+ 'example' => 'notaboolean',
+ ],
+ ));
+
+ $this->expectException(\ValueError::class);
+ $param->validate($request, $request->getQueryParams());
+ }
+
+ public function test_validation_required_not_set(): void {
+ $param = new query_parameter(
+ name: 'example',
+ type: param::BOOL,
+ required: true,
+ );
+
+ $request = new ServerRequest('GET', '/example');
+
+ $this->expectException(\coding_exception::class);
+ $param->validate($request, $request->getQueryParams());
+ }
+}
diff --git a/lib/tests/router/schema/request_body_test.php b/lib/tests/router/schema/request_body_test.php
new file mode 100644
index 00000000000..32dd14933d9
--- /dev/null
+++ b/lib/tests/router/schema/request_body_test.php
@@ -0,0 +1,136 @@
+.
+
+namespace core\router\schema;
+
+use core\router\schema\objects\schema_object;
+use core\router\schema\response\content\json_media_type;
+use core\router\schema\response\content\payload_response_type;
+use core\router\schema\specification;
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\ServerRequest;
+
+/**
+ * Tests for the request_body object.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\request_body
+ * @covers \core\router\schema\openapi_base
+ */
+final class request_body_test extends route_testcase {
+ public function test_basics(): void {
+ $object = new request_body();
+
+ $schema = $object->get_openapi_description(new specification());
+ $this->assertEquals((object) [
+ 'description' => '',
+ 'required' => false,
+ 'content' => [],
+ ], $schema);
+ $this->assertFalse($schema->required);
+ $this->assertFalse($object->is_required());
+ }
+
+ public function test_content_wrong_type(): void {
+ $this->expectException(\coding_exception::class);
+ new request_body(
+ content: [new schema_object(content: [])],
+ );
+ }
+
+ public function test_content_array(): void {
+ $object = new request_body(
+ content: [
+ new json_media_type(content: [], required: true),
+ ],
+ );
+
+ $schema = $object->get_openapi_schema(new specification());
+ $this->assertObjectHasProperty('content', $schema);
+ $this->assertObjectHasProperty('application/json', (object) $schema->content);
+
+ $request = new ServerRequest('GET', 'http://example.com', [
+ 'Content-Type' => json_media_type::get_encoding(),
+ ]);
+ $body = $object->get_body_for_request($request);
+ $this->assertInstanceOf(json_media_type::class, $body);
+ $this->assertTrue($body->is_required());
+ }
+
+ public function test_content_not_matching(): void {
+ $object = new request_body(
+ content: [
+ new json_media_type(content: []),
+ ],
+ );
+ $this->expectException(\invalid_parameter_exception::class);
+ $object->get_body_for_request(new ServerRequest('GET', 'http://example.com'));
+ }
+
+ public function test_content_payload_type(): void {
+ $content = new payload_response_type(content: [], required: true);
+ $object = new request_body(
+ content: $content,
+ );
+
+ $schema = $object->get_openapi_schema(new specification());
+ $this->assertObjectHasProperty('content', $schema);
+
+ foreach ($content->get_supported_content_types() as $contenttypeclass) {
+ $encoding = $contenttypeclass::get_encoding();
+ $this->assertObjectHasProperty($encoding, $schema->content);
+ $this->assertObjectNotHasProperty('$ref', $schema->content->{$encoding});
+
+ $request = new ServerRequest('GET', 'http://example.com', [
+ 'Content-Type' => $encoding,
+ ]);
+ $body = $object->get_body_for_request($request);
+ $this->assertInstanceOf($contenttypeclass, $body);
+ }
+ }
+
+ /**
+ * Test object referencing.
+ *
+ * @covers \core\router\schema\openapi_base
+ */
+ public function test_referenced_object(): void {
+ $object = new class extends request_body implements referenced_object {
+ };
+
+ // Note: The status code is not in the OpenAPI schema, but in the parent.
+ $schema = $object->get_openapi_description(new specification());
+ $this->assertObjectNotHasProperty('$ref', $schema);
+ $this->assertObjectHasProperty('description', $schema);
+
+ $reference = $object->get_openapi_schema(new specification());
+ $this->assertObjectNotHasProperty('description', $reference);
+ $this->assertObjectHasProperty('$ref', $reference);
+ }
+
+ public function test_reference_content(): void {
+ $object = new request_body(
+ content: [],
+ );
+ $object = new class extends request_body implements referenced_object {
+ };
+
+ $schema = $object->get_openapi_schema(new specification());
+ $this->assertObjectNotHasProperty('content', $schema);
+ }
+}
diff --git a/lib/tests/router/schema/response/content/json_media_type_test.php b/lib/tests/router/schema/response/content/json_media_type_test.php
new file mode 100644
index 00000000000..07874b55350
--- /dev/null
+++ b/lib/tests/router/schema/response/content/json_media_type_test.php
@@ -0,0 +1,157 @@
+.
+
+namespace core\router\schema\response\content;
+
+use core\router\schema\example;
+use core\router\schema\objects\schema_object;
+use core\router\schema\referenced_object;
+use core\router\schema\specification;
+
+/**
+ * Tests for the abstract media type response content container.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\response\content\json_media_type
+ * @covers \core\router\schema\response\content\media_type
+ */
+final class json_media_type_test extends \advanced_testcase {
+ public function test_basics(): void {
+ $this->assertEquals(
+ 'application/json',
+ json_media_type::get_encoding(),
+ );
+
+ $object = new json_media_type();
+ $this->assertEquals(
+ 'application/json',
+ $object->get_mimetype(),
+ );
+ }
+
+ /**
+ * Tests for the is_required method.
+ *
+ * @dataProvider is_required_provider
+ * @param bool|null $required
+ * @param bool $expected
+ */
+ public function test_is_required(?bool $required, bool $expected): void {
+ // Note: This related to the _body_ being required.
+ $object = new json_media_type(
+ required: $required,
+ );
+
+ $schema = $object->get_openapi_schema(new specification());
+ if ($expected) {
+ $this->assertTrue($object->is_required());
+ $this->assertTrue($schema->required);
+ } else {
+ $this->assertFalse($object->is_required());
+ $this->assertObjectNotHasProperty('required', $schema);
+ }
+ }
+
+ /**
+ * Data provider for the is_required method.
+ *
+ * @return array
+ */
+ public static function is_required_provider(): array {
+ return [
+ [true, true],
+ [false, false],
+ ];
+ }
+
+ public function test_example_or_examples(): void {
+ $this->expectException(\coding_exception::class);
+
+ new json_media_type(
+ example: new example(name: 'example'),
+ examples: [
+ new example(name: 'example2'),
+ ],
+ );
+ }
+
+ public function test_single_example(): void {
+ $example = new example(name: 'examplename');
+ $object = new json_media_type(
+ example: $example,
+ );
+
+ $spec = new specification();
+ $schema = $object->get_openapi_schema($spec);
+
+ // There is no schema specified here, so none in the OpenAPI object.
+ $this->assertObjectNotHasProperty('schema', $schema);
+
+ // The 'example' attribute is going to be deprecated in a future version of the spec.
+ // We normalise a single example into the examples array instead which is the preferred way.
+ $this->assertObjectNotHasProperty('example', $schema);
+ $this->assertObjectHasProperty('examples', $schema);
+
+ // The example will be listed by name.
+ $this->assertArrayHasKey('examplename', $schema->examples);
+ $this->assertEquals(
+ $example->get_openapi_schema($spec),
+ $schema->examples['examplename'],
+ );
+ }
+
+ public function test_schema(): void {
+ $schemaobject = new schema_object(
+ content: [
+ 'example' => new schema_object(content: []),
+ ],
+ );
+
+ $object = new json_media_type(
+ schema: $schemaobject,
+ );
+
+ $this->assertSame($schemaobject, $object->get_schema());
+
+ $spec = new specification();
+ $schema = $object->get_openapi_schema($spec);
+ $this->assertObjectHasProperty('schema', $schema);
+ $this->assertEquals(
+ $schemaobject->get_openapi_description($spec),
+ $schema->schema,
+ );
+ }
+
+ public function test_referenced_schema(): void {
+ $schemaobject = new class (
+ content: [],
+ ) extends schema_object implements referenced_object {
+ };
+
+ $object = new json_media_type(
+ schema: $schemaobject,
+ );
+
+ $this->assertSame($schemaobject, $object->get_schema());
+
+ $spec = new specification();
+ $schema = $object->get_openapi_schema($spec);
+ $this->assertObjectNotHasProperty('schema', $schema->schema);
+ $this->assertObjectHasProperty('$ref', $schema->schema);
+ }
+}
diff --git a/lib/tests/router/schema/response/content/payload_response_type_test.php b/lib/tests/router/schema/response/content/payload_response_type_test.php
new file mode 100644
index 00000000000..d7335e63321
--- /dev/null
+++ b/lib/tests/router/schema/response/content/payload_response_type_test.php
@@ -0,0 +1,111 @@
+.
+
+namespace core\router\schema\response\content;
+
+use core\router\schema\example;
+use core\router\schema\specification;
+use GuzzleHttp\Psr7\ServerRequest;
+
+/**
+ * Tests for the abstract media type response content container.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\response\content\payload_response_type
+ */
+final class payload_response_type_test extends \advanced_testcase {
+ /**
+ * Test supported types.
+ */
+ public function test_supported_types(): void {
+ $object = new payload_response_type();
+ $this->assertIsArray($object->get_supported_content_types());
+ $this->assertContains(json_media_type::class, $object->get_supported_content_types());
+ }
+
+ public function test_get_media_type_instance(): void {
+ $example = new example(name: 'This is my example');
+ $object = new payload_response_type(example: $example);
+
+ // Request by mimetype.
+ $jsoninstance = $object->get_media_type_instance(
+ mimetype: json_media_type::get_encoding(),
+ );
+ $this->assertInstanceOf(json_media_type::class, $jsoninstance);
+
+ $spec = new specification();
+ $this->assertEquals(
+ $example->get_openapi_description($spec),
+ $jsoninstance->get_openapi_description($spec)->examples['This is my example'],
+ );
+
+ // Request by classname.
+ $jsoninstance = $object->get_media_type_instance(
+ classname: json_media_type::class,
+ );
+ $this->assertInstanceOf(json_media_type::class, $jsoninstance);
+
+ $spec = new specification();
+ $this->assertEquals(
+ $example->get_openapi_description($spec),
+ $jsoninstance->get_openapi_description($spec)->examples['This is my example'],
+ );
+ }
+
+ public function test_description(): void {
+ $object = new payload_response_type(
+ description: 'This is a nice description about the content',
+ );
+
+ $schema = $object->get_openapi_schema(new specification());
+ foreach ($object->get_supported_content_types() as $contenttypeclass) {
+ $encoding = $contenttypeclass::get_encoding();
+ $this->assertObjectHasProperty($encoding, $schema);
+ $this->assertObjectNotHasProperty('$ref', $schema->{$encoding});
+ }
+
+ $this->assertObjectNotHasProperty('$ref', $schema);
+ }
+
+ public function test_no_media_type_instances(): void {
+ // Really this should never happen, but it's nice to know that things work if there's a probelm in future.
+ $object = new class () extends payload_response_type { // phpcs:ignore
+ #[\Override]
+ public function get_supported_content_types(): array {
+ return [];
+ }
+ };
+
+ $this->assertNull($object->get_media_type_instance());
+ }
+
+ public function test_required(): void {
+ // Note: The payload required property is a Moodle check, and not represented in OpenAPI.
+ $object = new payload_response_type(
+ required: true,
+ );
+
+ $this->assertTrue($object->is_required());
+
+ $object = new payload_response_type(
+ required: false,
+ );
+
+ $this->assertFalse($object->is_required());
+ }
+}
diff --git a/lib/tests/router/schema/response/payload_response_test.php b/lib/tests/router/schema/response/payload_response_test.php
new file mode 100644
index 00000000000..e121a4d50e5
--- /dev/null
+++ b/lib/tests/router/schema/response/payload_response_test.php
@@ -0,0 +1,81 @@
+.
+
+namespace core\router\schema\response;
+
+use core\router\response_handler;
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\Response;
+use GuzzleHttp\Psr7\ServerRequest;
+
+/**
+ * Tests for the payload response.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\response\payload_response
+ * @covers \core\router\schema\response\abstract_response
+ */
+final class payload_response_test extends route_testcase {
+ public function test_get_payload(): void {
+ $request = new ServerRequest('GET', 'http://example.com/example/endpoint');
+
+ $payload = [
+ 'example' => 'data',
+ 'goes' => 'here',
+ 'count' => 123,
+ ];
+
+ $response = new payload_response($payload, $request);
+ $this->assertEquals($payload, $response->payload);
+ $this->assertEquals($request, $response->get_request());
+ }
+
+ public function test_basics(): void {
+ $request = new ServerRequest('GET', 'http://example.com/example/endpoint');
+ $response = new Response();
+
+ $payloaddata = [
+ 'example' => 'data',
+ 'goes' => 'here',
+ 'count' => 123,
+ ];
+
+ $payload = new payload_response($payloaddata, $request, $response);
+ $this->assertSame($request, $payload->request);
+ $this->assertSame($response, $payload->response);
+ }
+
+ public function test_response_standardisation(): void {
+ $request = new ServerRequest('GET', 'http://example.com/example/endpoint');
+ $response = new Response();
+
+ $payloaddata = [
+ 'example' => 'data',
+ 'goes' => 'here',
+ 'count' => 123,
+ ];
+
+ $payload = new payload_response($payloaddata, $request, $response);
+
+ $handler = new response_handler(\core\di::get_container());
+
+ // Note: The standardisation itself is tested elsewhere.
+ $response = $handler->standardise_response($payload);
+ $this->assertInstanceOf(Response::class, $response);
+ }
+}
diff --git a/lib/tests/router/schema/response/response_test.php b/lib/tests/router/schema/response/response_test.php
new file mode 100644
index 00000000000..a65490b343f
--- /dev/null
+++ b/lib/tests/router/schema/response/response_test.php
@@ -0,0 +1,152 @@
+.
+
+namespace core\router\schema\response;
+
+use core\param;
+use core\router\schema\header_object;
+use core\router\schema\objects\schema_object;
+use core\router\schema\referenced_object;
+use core\router\schema\response\content\json_media_type;
+use core\router\schema\response\content\payload_response_type;
+use core\router\schema\specification;
+use core\tests\route_testcase;
+
+/**
+ * Tests for the response schema definition.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\response\response
+ */
+final class response_test extends route_testcase {
+ public function test_defaults(): void {
+ $response = new response();
+
+ // The default status code is 200.
+ $this->assertSame(200, $response->get_status_code());
+ }
+
+ public function test_basics(): void {
+ $response = new response(
+ statuscode: 450,
+ description: 'This is a nice description about the response',
+ headers: [
+ new header_object(
+ name: 'X-Header',
+ type: param::ALPHA,
+ description: 'This is a nice description about the header',
+ required: true,
+ ),
+ ],
+ content: new payload_response_type(
+ description: 'This is a nice description about the content',
+ schema: new schema_object(
+ content: [
+ 'example' => new schema_object(content: []),
+ ],
+ ),
+ ),
+ );
+
+ // The default status code is 200.
+ $this->assertSame(450, $response->get_status_code());
+ $schema = $response->get_openapi_schema(new specification());
+
+ $this->assertObjectHasProperty('description', $schema);
+ $this->assertEquals('This is a nice description about the response', $schema->description);
+
+ $this->assertObjectHasProperty('headers', $schema);
+ $this->assertArrayHasKey('X-Header', $schema->headers);
+ $this->assertObjectHasProperty('description', $schema->headers['X-Header']);
+ $this->assertEquals('This is a nice description about the header', $schema->headers['X-Header']->description);
+ $this->assertObjectHasProperty('schema', $schema->headers['X-Header']);
+
+ $this->assertObjectHasProperty('content', $schema);
+ }
+
+ public function test_default_200_description(): void {
+ $response = new response(
+ statuscode: 200,
+ );
+
+ // The default status code is 200.
+ $this->assertSame(200, $response->get_status_code());
+ $schema = $response->get_openapi_schema(new specification());
+ $this->assertObjectHasProperty('description', $schema);
+ $this->assertIsString($schema->description);
+ $this->assertEquals('OK', $schema->description);
+ }
+
+ public function test_array_content(): void {
+ $response = new response(
+ statuscode: 450,
+ content: [
+ new json_media_type(
+ schema: new schema_object(
+ content: [
+ 'example' => new schema_object(content: []),
+ ],
+ ),
+ ),
+ ],
+ );
+
+ // The default status code is 200.
+ $this->assertSame(450, $response->get_status_code());
+ $schema = $response->get_openapi_schema(new specification());
+ $this->assertObjectHasProperty('description', $schema);
+ $this->assertIsString($schema->description);
+
+ $this->assertObjectHasProperty('content', $schema);
+ $this->assertArrayHasKey('application/json', $schema->content);
+ }
+
+ public function test_invalid_content(): void {
+ $this->expectException(\coding_exception::class);
+
+ $response = new response(
+ content: [
+ new schema_object(
+ content: [],
+ ),
+ ],
+ );
+ $response->get_openapi_schema(new specification());
+ }
+
+ /**
+ * Tests for object references.
+ *
+ * @covers \core\router\schema\openapi_base
+ */
+ public function test_referenced_object(): void {
+ $object = new class (
+ statuscode: 499,
+ ) extends response implements referenced_object {
+ };
+
+ // Note: The status code is not in the OpenAPI schema, but in the parent.
+ $schema = $object->get_openapi_description(new specification());
+ $this->assertObjectNotHasProperty('$ref', $schema);
+ $this->assertObjectHasProperty('description', $schema);
+
+ $reference = $object->get_openapi_schema(new specification());
+ $this->assertObjectNotHasProperty('description', $reference);
+ $this->assertObjectHasProperty('$ref', $reference);
+ }
+}
diff --git a/lib/tests/router/schema/response/view_response_test.php b/lib/tests/router/schema/response/view_response_test.php
new file mode 100644
index 00000000000..cd15dfd6c6c
--- /dev/null
+++ b/lib/tests/router/schema/response/view_response_test.php
@@ -0,0 +1,63 @@
+.
+
+namespace core\router\schema\response;
+
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\ServerRequest;
+
+/**
+ * Tests for the view_response response type.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\response\view_response
+ */
+final class view_response_test extends route_testcase {
+ public function test_defaults(): void {
+ $response = new response();
+
+ // The default status code is 200.
+ $this->assertSame(200, $response->get_status_code());
+ }
+
+ public function test_get_response(): void {
+ global $OUTPUT;
+
+ $request = new ServerRequest('GET', 'http://example.com');
+
+ $response = new view_response(
+ template: 'core/welcome',
+ parameters: [
+ 'welcomemessage' => 'Hello, everybody!',
+ ],
+ request: $request,
+ );
+
+ $this->assertEquals('core/welcome', $response->get_template_name());
+ $this->assertEquals(
+ ['welcomemessage' => 'Hello, everybody!'],
+ $response->get_parameters(),
+ );
+
+ $this->assertEquals($request, $response->get_request());
+ $this->assertEquals(
+ $OUTPUT->render_from_template('core/welcome', ['welcomemessage' => 'Hello, everybody!']),
+ $response->get_response($this->get_router()->get_response_factory())->getBody(),
+ );
+ }
+}
diff --git a/lib/tests/router/schema/specification_test.php b/lib/tests/router/schema/specification_test.php
new file mode 100644
index 00000000000..0830fb03820
--- /dev/null
+++ b/lib/tests/router/schema/specification_test.php
@@ -0,0 +1,353 @@
+.
+
+namespace core\router\schema;
+
+use core\param;
+use core\router\route;
+use core\router\schema\parameters\path_parameter;
+use core\router\schema\response\content\payload_response_type;
+use core\router\schema\response\response;
+use core\router\schema\specification;
+use core\tests\route_testcase;
+
+/**
+ * Tests for the specification.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\schema\specification
+ */
+final class specification_test extends route_testcase {
+ public function test_basics(): void {
+ global $CFG;
+
+ $spec = new specification();
+ $schema = $spec->get_schema();
+
+ $this->assertIsObject($schema);
+
+ // We comply with OpenAPI 3.1.0.
+ $this->assertObjectHasProperty('openapi', $schema);
+ $this->assertEquals('3.1.0', $schema->openapi);
+
+ // INfo should include our license.
+ $this->assertObjectHasProperty('info', $schema);
+ $this->assertObjectHasProperty('license', $schema->info);
+ $this->assertStringContainsString('GNU GPL v3 or later', $schema->info->license->name);
+ $this->assertObjectHasProperty('url', $schema->info->license);
+
+ // The server list should contain the currenet URI during finalisation.
+ $this->assertObjectHasProperty('servers', $schema);
+ $this->assertIsArray($schema->servers);
+ $this->assertCount(1, $schema->servers);
+ $server = $schema->servers[0];
+ $this->assertStringStartsWith($CFG->wwwroot, $server->url);
+
+ $this->assertObjectHasProperty('paths', $schema);
+ $this->assertObjectHasProperty('components', $schema);
+ $this->assertObjectHasProperty('security', $schema);
+ $this->assertObjectHasProperty('externalDocs', $schema);
+
+ // Calculated parameters should only be set once.
+ $schema = $spec->get_schema();
+ $this->assertCount(1, $schema->servers);
+
+ $this->assertJson(json_encode($spec));
+ }
+
+ /**
+ * Test the add_path method.
+ *
+ * @dataProvider add_path_provider
+ * @param string $component
+ * @param string $path
+ * @param string $expectedpath
+ */
+ public function test_add_path(
+ string $component,
+ string $path,
+ string $expectedpath,
+ ): void {
+ $spec = new specification();
+
+ $spec->add_path(
+ $component,
+ new route(
+ path: $path,
+ ),
+ );
+
+ $schema = $spec->get_schema();
+ $this->assertObjectHasProperty($expectedpath, $schema->paths);
+ }
+
+ /**
+ * Data provider for add_path.
+ *
+ * @return array
+ */
+ public static function add_path_provider(): array {
+ return [
+ 'Core' => [
+ 'core',
+ '/example/path',
+ '/core/example/path',
+ ],
+ 'Core Subsystem' => [
+ 'core_access',
+ '/example/path',
+ '/access/example/path',
+ ],
+ 'An activity' => [
+ 'mod_assign',
+ '/example/path',
+ '/mod_assign/example/path',
+ ],
+ ];
+ }
+
+ public function test_add_path_with_option(): void {
+ $spec = new specification();
+
+ $spec->add_path(
+ 'core',
+ new route(
+ path: '/example/path/with[/{option}]',
+ pathtypes: [
+ new path_parameter(name: 'option', type: param::INT),
+ ],
+ ),
+ );
+
+ $schema = $spec->get_schema();
+ $this->assertObjectHasProperty('/core/example/path/with', $schema->paths);
+ $this->assertObjectHasProperty('/core/example/path/with/{option}', $schema->paths);
+ }
+
+ public function test_add_path_with_options(): void {
+ $spec = new specification();
+
+ $spec->add_path(
+ 'core',
+ new route(
+ path: '/example/path/with[/{optional}][/{extras}]',
+ pathtypes: [
+ new path_parameter(name: 'optional', type: param::INT),
+ new path_parameter(name: 'extras', type: param::INT),
+ ],
+ ),
+ );
+
+ $schema = $spec->get_schema();
+ $this->assertObjectHasProperty('/core/example/path/with', $schema->paths);
+ $this->assertObjectHasProperty('/core/example/path/with/{optional}', $schema->paths);
+ $this->assertObjectHasProperty('/core/example/path/with/{optional}/{extras}', $schema->paths);
+ }
+
+ public function test_add_parameter(): void {
+ $spec = new specification();
+
+ /** @var path_parameter&\PHPUnit\Framework\MockObject\MockObject $child */
+ $child = $this->getMockBuilder(path_parameter::class)
+ ->onlyMethods([])
+ ->setConstructorArgs([
+ 'name' => 'example',
+ 'type' => param::INT,
+ ])
+ ->getMock();
+
+ $this->assertFalse($spec->is_reference_defined($child->get_reference(true)));
+
+ $spec->add_component($child);
+
+ $schema = $spec->get_schema();
+ $this->assertObjectHasProperty($child->get_reference(false), $schema->components->parameters);
+ $this->assertTrue($spec->is_reference_defined($child->get_reference(true)));
+ }
+
+ public function test_add_header(): void {
+ $spec = new specification();
+
+ /** @var header_object&\PHPUnit\Framework\MockObject\MockObject $child */
+ $child = $this->getMockBuilder(header_object::class)
+ ->onlyMethods([])
+ ->setConstructorArgs([
+ 'name' => 'example',
+ 'type' => param::INT,
+ ])
+ ->getMock();
+
+ $this->assertFalse($spec->is_reference_defined($child->get_reference(true)));
+
+ $spec->add_component($child);
+
+ $schema = $spec->get_schema();
+ $this->assertObjectHasProperty($child->get_reference(false), $schema->components->headers);
+ $this->assertTrue($spec->is_reference_defined($child->get_reference(true)));
+ }
+
+ public function test_add_response(): void {
+ $spec = new specification();
+
+ /** @var response&\PHPUnit\Framework\MockObject\MockObject $child */
+ $child = $this->getMockBuilder(response::class)
+ ->onlyMethods([])
+ ->setConstructorArgs([
+ ])
+ ->getMock();
+
+ $this->assertFalse($spec->is_reference_defined($child->get_reference(true)));
+
+ $spec->add_component($child);
+
+ $schema = $spec->get_schema();
+ $this->assertObjectHasProperty($child->get_reference(false), $schema->components->responses);
+ $this->assertTrue($spec->is_reference_defined($child->get_reference(true)));
+ }
+
+ public function test_add_example(): void {
+ $spec = new specification();
+
+ /** @var example&\PHPUnit\Framework\MockObject\MockObject $child */
+ $child = $this->getMockBuilder(example::class)
+ ->onlyMethods([])
+ ->setConstructorArgs([
+ 'name' => 'An excellent example',
+ ])
+ ->getMock();
+ $this->assertFalse($spec->is_reference_defined($child->get_reference(true)));
+
+ $spec->add_component($child);
+
+ $schema = $spec->get_schema();
+ $this->assertObjectHasProperty($child->get_reference(false), $schema->components->examples);
+ $this->assertTrue($spec->is_reference_defined($child->get_reference(true)));
+ }
+
+ public function test_add_request_body(): void {
+ $spec = new specification();
+
+ $child = new request_body(
+ description: 'example',
+ required: true,
+ );
+
+ $this->assertFalse($spec->is_reference_defined($child->get_reference(true)));
+
+ $spec->add_component($child);
+
+ $schema = $spec->get_schema();
+ $this->assertObjectHasProperty($child->get_reference(false), $schema->components->requestBodies);
+
+ $this->assertTrue($spec->is_reference_defined($child->get_reference(true)));
+
+ $requestschema = $spec->get_openapi_schema_for_route(
+ route: new route(
+ path: '/example/path',
+ requestbody: $child,
+ ),
+ component: '',
+ path: '/example/path',
+ );
+
+ $this->assertObjectHasProperty('requestBody', $requestschema->get);
+ $this->assertEquals('example', $requestschema->get->requestBody->description);
+ $this->assertTrue($requestschema->get->requestBody->required);
+ }
+
+ public function test_route_security_in_schema(): void {
+ $spec = new specification();
+
+ $route = new route(
+ path: '/example/path',
+ security: ['example'],
+ );
+
+ $spec->add_path(
+ 'core',
+ $route,
+ );
+
+ $requestschema = $spec->get_openapi_schema_for_route(
+ route: $route,
+ component: '',
+ path: '/example/path',
+ );
+
+ $this->assertObjectHasProperty('security', $requestschema->get);
+ $this->assertEquals(['example'], $requestschema->get->security);
+ }
+
+ public function test_is_reference_defined(): void {
+ $spec = new specification();
+ $this->assertFalse($spec->is_reference_defined('example'));
+ $this->assertFalse($spec->is_reference_defined('#/components/fake/component'));
+ }
+
+ public function test_deprecated_route(): void {
+ $spec = new specification();
+ $route = new route(
+ path: '/example/path',
+ deprecated: true,
+ );
+
+ $spec->add_path(
+ 'core',
+ $route,
+ );
+
+ $requestschema = $spec->get_openapi_schema_for_route(
+ route: $route,
+ component: '',
+ path: '/example/path',
+ );
+
+ $this->assertObjectHasProperty('deprecated', $requestschema->get);
+ $this->assertTrue($requestschema->get->deprecated);
+ }
+
+ public function test_response(): void {
+ $spec = new specification();
+ $route = new route(
+ path: '/example/path',
+ responses: [
+ new response(
+ status: 200,
+ description: 'example',
+ content: new payload_response_type(),
+ ),
+ ],
+ );
+
+ $spec->add_path(
+ 'core',
+ $route,
+ );
+
+ $requestschema = $spec->get_openapi_schema_for_route(
+ route: $route,
+ component: '',
+ path: '/example/path',
+ );
+
+ $this->assertObjectHasProperty('responses', $requestschema->get);
+ $this->assertArrayHasKey('200', $requestschema->get->responses);
+ $this->assertObjectHasProperty('content', $requestschema->get->responses['200']);
+ $this->assertObjectHasProperty('application/json', $requestschema->get->responses['200']->content);
+ }
+}
diff --git a/lib/tests/router/util_test.php b/lib/tests/router/util_test.php
new file mode 100644
index 00000000000..99f3c8c7b15
--- /dev/null
+++ b/lib/tests/router/util_test.php
@@ -0,0 +1,153 @@
+.
+
+namespace core\router;
+
+use core\router\middleware\moodle_route_attribute_middleware;
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\ServerRequest;
+use Slim\Middleware\RoutingMiddleware;
+
+/**
+ * Tests for the route utility class.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router\util
+ */
+final class util_test extends route_testcase {
+ /**
+ * Ensure that no error is thrown when getting a route instance for a callable.
+ */
+ public function test_get_route_instance_for_method_not_array_callable(): void {
+ $this->assertNull(util::get_route_instance_for_method(fn () => null));
+ }
+
+ /**
+ * Test getting the path for a callable.
+ */
+ public function test_get_path_for_callable(): void {
+ self::load_fixture('core', 'router/route_on_class.php');
+
+ $this->add_route_to_route_loader(
+ \core\fixtures\route_on_class::class,
+ 'method_with_route',
+ grouppath: '/example',
+ );
+
+ $url = util::get_path_for_callable(
+ [\core\fixtures\route_on_class::class, 'method_with_route'],
+ [],
+ [],
+ );
+
+ $parsedurl = parse_url($url);
+ $this->assertEquals(
+ (new \moodle_url('/example/class/path/method/path'))->get_path(),
+ $parsedurl['path'],
+ );
+ }
+
+ public function test_get_route_instance_for_method(): void {
+ self::load_fixture('core', 'router/route_on_method_only.php');
+ self::load_fixture('core', 'router/route_on_class.php');
+
+ // The class has no route attribute.
+
+ // Test a method that has no route attribute.
+ $this->assertNull(util::get_route_instance_for_method('core\fixtures\route_on_method_only::method_without_route'));
+ $this->assertNull(util::get_route_instance_for_method(['core\fixtures\route_on_method_only', 'method_without_route']));
+
+ // Test a method that has a route attribute.
+ $this->assert_route_callable_data(
+ 'core\fixtures\route_on_method_only::method_with_route',
+ '/method/path',
+ 'core\fixtures\route_on_method_only::method_with_route',
+ );
+ $this->assert_route_callable_data(
+ ['core\fixtures\route_on_method_only', 'method_with_route'],
+ '/method/path',
+ 'core\fixtures\route_on_method_only::method_with_route',
+ );
+
+ // The class has a route attribute.
+
+ // Test a method that has no route attribute.
+ $this->assertNull(util::get_route_instance_for_method('core\fixtures\route_on_class::method_without_route'));
+ $this->assertNull(util::get_route_instance_for_method(['core\fixtures\route_on_class', 'method_without_route']));
+
+ // Test a method that has a route attribute - it is merged with parent.
+ $this->assert_route_callable_data(
+ 'core\fixtures\route_on_class::method_with_route',
+ '/class/path/method/path',
+ 'core\fixtures\route_on_class::method_with_route',
+ );
+ $this->assert_route_callable_data(
+ ['core\fixtures\route_on_class', 'method_with_route'],
+ '/class/path/method/path',
+ 'core\fixtures\route_on_class::method_with_route',
+ );
+ }
+
+ /**
+ * Assertion helper to asser that a callable is a route and has the expected path and name.
+ *
+ * @param callable $callable The callable to check.
+ * @param string $path The expected path.
+ * @param string $routename The expected route name.
+ */
+ protected function assert_route_callable_data(
+ $callable,
+ string $path,
+ string $routename,
+ ): void {
+ $route = util::get_route_instance_for_method($callable);
+ $this->assertInstanceOf(route::class, $route);
+ $this->assertEquals($path, $route->get_path());
+ $this->assertIsString(util::get_route_name_for_callable($callable));
+ $this->assertEquals($routename, util::get_route_name_for_callable($callable));
+ }
+
+ /**
+ * Test getting the route name for an anonymous callable.
+ */
+ public function test_get_route_for_callable_not_array_callable(): void {
+ $this->expectException(\coding_exception::class);
+ $this->assertNull(util::get_route_name_for_callable(fn () => null));
+ }
+
+ public function test_get_route_instance_for_request(): void {
+ self::load_fixture('core', 'router/route_on_method_only.php');
+
+ $app = $this->get_simple_app();
+ $app->add(moodle_route_attribute_middleware::class);
+ $app->addRoutingMiddleware();
+ $app->get('/method/path', [\core\fixtures\route_on_method_only::class, 'method_with_route']);
+ $app->handle(new ServerRequest('GET', '/method/path'));
+
+ $request = $this->route_request($app, new ServerRequest('GET', '/method/path'));
+
+ $route = util::get_route_instance_for_request($request);
+
+ $this->assertInstanceOf(route::class, $route);
+ $this->assertEquals('/method/path', $route->get_path());
+
+ $secondroute = util::get_route_instance_for_request($request);
+ $this->assertInstanceOf(route::class, $secondroute);
+ $this->assertEquals('/method/path', $secondroute->get_path());
+ }
+}
diff --git a/lib/tests/router_test.php b/lib/tests/router_test.php
new file mode 100644
index 00000000000..f10f19feb60
--- /dev/null
+++ b/lib/tests/router_test.php
@@ -0,0 +1,109 @@
+.
+
+namespace core;
+
+use core\tests\route_testcase;
+use Slim\App;
+
+/**
+ * Tests for the router class.
+ *
+ * @package core
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core\router
+ * @covers \core\router\response_handler
+ */
+final class router_test extends route_testcase {
+ public function test_get_app(): void {
+ $router = $this->get_router('/example');
+ $app = $router->get_app();
+ $this->assertInstanceOf(App::class, $app);
+
+ $this->assertEquals(di::get_container(), $app->getContainer());
+ }
+
+ public function test_request_normalisation(): void {
+ $router = $this->get_router('');
+
+ // Create handlers for the routes.
+ // Note: These must all be created before any are accessed as the data is cached after first use.
+ $app = $router->get_app();
+ $app->get('/test/path', fn ($response) => $response->withStatus(299));
+ $app->get('/', fn ($response) => $response->withStatus(275));
+ $app->get('/test/otherpath', fn ($response) => $response->withStatus(250));
+
+ // Duplicate slashes.
+ $request = $this->create_request('GET', '/test//path', '');
+ $response = $router->handle_request($request);
+ $this->assertEquals(299, $response->getStatusCode());
+
+ // An empty route.
+ $request = $this->create_request('GET', '', '');
+ $response = $router->handle_request($request);
+ $this->assertEquals(275, $response->getStatusCode());
+
+ // A route with a trailing slash.
+ $request = $this->create_request('GET', '/test/otherpath/', '');
+ $response = $router->handle_request($request);
+ $this->assertEquals(250, $response->getStatusCode());
+
+ // A route with a trailing double slash.
+ $request = $this->create_request('GET', '/test/otherpath/////', '');
+ $response = $router->handle_request($request);
+ $this->assertEquals(250, $response->getStatusCode());
+ }
+
+ /**
+ * Test an API route.
+ */
+ public function test_preferences_no_login(): void {
+ $this->add_class_routes_to_route_loader(\core_user\route\api\preferences::class);
+ $response = $this->process_api_request('GET', '/current/preferences');
+
+ $this->assert_valid_response($response);
+ $payload = $this->decode_response($response);
+
+ $this->assertEmpty((array) $payload);
+ }
+
+ public function test_basepath_supplied(): void {
+ $router = $this->get_router(
+ basepath: '/example',
+ );
+ $this->assertEquals('/example', $router->basepath);
+ }
+
+ public function test_basepath_guessed(): void {
+ global $CFG;
+
+ $wwwroot = new \moodle_url($CFG->wwwroot);
+ $router = di::get(router::class);
+
+ $this->assertEquals($wwwroot->get_path(), $router->basepath);
+ }
+
+ public function test_basepath_guessed_rphp(): void {
+ $wwwroot = new \moodle_url('/r.php');
+ $_SERVER['SCRIPT_FILENAME'] = 'r.php';
+ $_SERVER['REQUEST_URI'] = $wwwroot->get_path();
+
+ $router = di::get(router::class);
+
+ $this->assertEquals($wwwroot->get_path(), $router->basepath);
+ }
+}
diff --git a/lib/tests/url_test.php b/lib/tests/url_test.php
index 069dcc4be41..5b4cd2a6e31 100644
--- a/lib/tests/url_test.php
+++ b/lib/tests/url_test.php
@@ -669,4 +669,21 @@ final class url_test extends \advanced_testcase {
],
];
}
+
+ /**
+ * Test that URL routed paths are generated correctly depending on the value of $CFG->routerconfigured.
+ */
+ public function test_routed_path(): void {
+ global $CFG;
+
+ $this->resetAfterTest();
+
+ $CFG->routerconfigured = false;
+ $url = url::routed_path('/example');
+ $this->assertSame('/r.php/example', $url->out_as_local_url(false));
+
+ $CFG->routerconfigured = true;
+ $url = url::routed_path('/example');
+ $this->assertSame('/example', $url->out_as_local_url(false));
+ }
}
diff --git a/lib/thirdpartylibs.xml b/lib/thirdpartylibs.xml
index 51f25ad575d..62fa9f82985 100644
--- a/lib/thirdpartylibs.xml
+++ b/lib/thirdpartylibs.xml
@@ -51,7 +51,7 @@
- phpspreadsheet
+ phpspreadsheet/phpspreadsheet
PhpSpreadsheet
Library to read, write and create spreadsheet documents in PHP.
1.29.0
@@ -644,6 +644,22 @@ All rights reserved.
MIT
https://github.com/php-fig/http-message
+
+ psr/http-server-handler
+ psr/http-server-handler
+ Common interface for HTTP server-side request handler
+ 1.0.2
+ MIT
+ https://github.com/php-fig/http-server-handler
+
+
+ psr/http-server-middleware
+ psr/http-server-middleware
+ Common interface for HTTP server-side middleware
+ 1.0.2
+ MIT
+ https://github.com/php-fig/http-server-middleware
+
psr/event-dispatcher
event-dispatcher
@@ -652,6 +668,22 @@ All rights reserved.
MIT
https://github.com/php-fig/event-dispatcher
+
+ psr/log
+ log
+ Common interface for logging libraries
+ 3.0.0
+ MIT
+ https://github.com/php-fig/log
+
+
+ psr/simple-cache
+ simple-cache
+ Common interface for logging libraries
+ 3.0.0
+ MIT
+ https://github.com/php-fig/simple-cache
+
phpxmlrpc
phpxmlrpc
@@ -761,4 +793,19 @@ All rights reserved.
MIT
https://github.com/php-di/invoker
+
+ slim
+ Slim Framework
+ 4.13.0
+ MIT
+ https://github.com/slimphp/Slim
+
+
+ nikic/fast-route
+ FastRoute
+ 1.3.0
+ BSD
+ 3-Clause
+ https://github.com/nikic/FastRoute
+
diff --git a/lib/weblib.php b/lib/weblib.php
index e1a0bd345af..2872cf6b647 100644
--- a/lib/weblib.php
+++ b/lib/weblib.php
@@ -2081,7 +2081,7 @@ function notice ($message, $link='', $course=null) {
* @param string $messagetype The type of notification to show the message in. See constants on \core\output\notification.
* @throws moodle_exception
*/
-function redirect($url, $message='', $delay=null, $messagetype = \core\output\notification::NOTIFY_INFO) {
+function redirect($url, $message='', $delay=null, $messagetype = \core\output\notification::NOTIFY_INFO): Never {
global $OUTPUT, $PAGE, $CFG;
if (CLI_SCRIPT or AJAX_SCRIPT) {
diff --git a/mod/assign/amd/build/grading_navigation.min.js b/mod/assign/amd/build/grading_navigation.min.js
index 6f8fa093d3f..1f95051dcd1 100644
--- a/mod/assign/amd/build/grading_navigation.min.js
+++ b/mod/assign/amd/build/grading_navigation.min.js
@@ -6,6 +6,6 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 3.1
*/
-define("mod_assign/grading_navigation",["jquery","core/notification","core/str","core/form-autocomplete","core/ajax","core_user/repository","mod_assign/grading_form_change_checker"],(function($,notification,str,autocomplete,ajax,UserRepository,checker){var GradingNavigation=function(selector){this._regionSelector=selector,this._region=$(selector),this._filters=[],this._users=[],this._filteredUsers=[],this._lastXofYUpdate=0,this._firstLoadUsers=!0;let url=new URL(window.location);parseInt(url.searchParams.get("treset"))>0&&(url.searchParams.delete("treset"),window.history.replaceState({},"",url)),this._loadAllUsers(),this._region.find('[data-action="previous-user"]').on("click",this._handlePreviousUser.bind(this)),this._region.find('[data-action="next-user"]').on("click",this._handleNextUser.bind(this)),this._region.find('[data-action="change-user"]').on("change",this._handleChangeUser.bind(this)),this._region.find('[data-region="user-filters"]').on("click",this._toggleExpandFilters.bind(this)),this._region.find('[data-region="user-resettable"]').on("click",this._toggleResetTable.bind()),$(document).on("user-changed",this._refreshSelector.bind(this)),$(document).on("done-saving-show-next",this._handleNextUser.bind(this));var toggleLink=this._region.find('[data-region="user-filters"]');$(document.getElementById(toggleLink.attr("aria-controls"))).on("change","select",this._filterChanged.bind(this));var userid=$('[data-region="grading-navigation-panel"]').data("first-userid");userid&&this._selectUserById(userid),str.get_string("changeuser","mod_assign").done((function(s){autocomplete.enhance("[data-action=change-user]",!1,"mod_assign/participant_selector",s)})).fail(notification.exception),$(document).bind("start-loading-user",function(){this._isLoading=!0}.bind(this)),$(document).bind("finish-loading-user",function(){this._isLoading=!1}.bind(this))};return GradingNavigation.prototype._isLoading=!1,GradingNavigation.prototype._regionSelector=null,GradingNavigation.prototype._filters=null,GradingNavigation.prototype._users=null,GradingNavigation.prototype._region=null,GradingNavigation.prototype._lastFilters="",GradingNavigation.prototype._loadAllUsers=function(){var select=this._region.find("[data-action=change-user]"),assignmentid=select.attr("data-assignmentid"),groupid=select.attr("data-groupid"),filterPanel=this._region.find('[data-region="configure-filters"]'),filter=filterPanel.find('select[name="filter"]').val(),workflowFilter=filterPanel.find('select[name="workflowfilter"]');workflowFilter&&(filter+=","+workflowFilter.val());var markerFilter=filterPanel.find('select[name="markerfilter"]');return markerFilter&&(filter+=","+markerFilter.val()),this._lastFilters!=filter&&(this._lastFilters=filter,ajax.call([{methodname:"mod_assign_list_participants",args:{assignid:assignmentid,groupid:groupid,filter:"",onlyids:!0,tablesort:!0},done:this._usersLoaded.bind(this),fail:notification.exception}]),!0)},GradingNavigation.prototype._usersLoaded=function(users){if(this._firstLoadUsers=!1,this._filteredUsers=this._users=users,this._users.length){var toggleLink=this._region.find('[data-region="user-filters"]');$(document.getElementById(toggleLink.attr("aria-controls"))).find('select[name="filter"]').trigger("change")}else this._selectNoUser();this._triggerNextUserEvent()},GradingNavigation.prototype._checkClickOutsideConfigureFilters=function(event){var configPanel=this._region.find('[data-region="configure-filters"]');if(!configPanel.is(event.target)&&0===configPanel.has(event.target).length){var toggleLink=this._region.find('[data-region="user-filters"]');configPanel.hide(),configPanel.attr("aria-hidden","true"),toggleLink.attr("aria-expanded","false"),$(document).unbind("click.mod_assign_grading_navigation")}},GradingNavigation.prototype._updateFilterPreferences=function(userId,filterList,preferenceNames){var preferences=[],i=0;if(0==filterList.length||this._firstLoadUsers){var deferred=$.Deferred();return deferred.resolve(),deferred}for(i=0;i0&&!isNaN(useridnumber)&&useridnumber>0&&$(document).trigger("user-changed",useridnumber)))},GradingNavigation.prototype._toggleExpandFilters=function(event){event.preventDefault();var toggleLink=$(event.target).closest('[data-region="user-filters"]'),expanded="true"==toggleLink.attr("aria-expanded"),configPanel=$(document.getElementById(toggleLink.attr("aria-controls")));expanded?(configPanel.hide(),configPanel.attr("aria-hidden","true"),toggleLink.attr("aria-expanded","false"),$(document).unbind("click.mod_assign_grading_navigation")):(configPanel.css("display","inline-block"),configPanel.attr("aria-hidden","false"),toggleLink.attr("aria-expanded","true"),event.stopPropagation(),$(document).on("click.mod_assign_grading_navigation",this._checkClickOutsideConfigureFilters.bind(this)))},GradingNavigation.prototype._toggleResetTable=function(){let url=new URL(window.location);url.searchParams.set("treset","1"),window.location.href=url},GradingNavigation.prototype._handlePreviousUser=function(e){e.preventDefault();var currentUserId=this._region.find("[data-action=change-user]").attr("data-selected"),i=0,currentIndex=0;for(i=0;i0&&$(document).trigger("user-changed",userid)}else count&&this._selectUserById(this._filteredUsers[newIndex].id)},GradingNavigation.prototype._setCountString=function(x,y){var updateNumber;this._lastXofYUpdate++,updateNumber=this._lastXofYUpdate;var param={x:x,y:y};str.get_string("xofy","mod_assign",param).done(function(s){updateNumber==this._lastXofYUpdate&&this._region.find('[data-region="user-count-summary"]').text(s)}.bind(this)).fail(notification.exception)},GradingNavigation.prototype._refreshCount=function(){var userid=this._region.find("[data-action=change-user]").attr("data-selected"),i=0,currentIndex=0;if(isNaN(userid)||userid<=0)this._region.find('[data-region="user-count"]').hide();else{for(this._region.find('[data-region="user-count"]').show(),i=0;i0){var url=new URL(window.location);if(parseInt(url.searchParams.get("blindid"))>0){var newid=this._filteredUsers[currentIndex-1].recordid;url.searchParams.set("blindid",newid)}else url.searchParams.set("userid",userid);window.history.replaceState({},"",url)}}},GradingNavigation.prototype._refreshSelector=function(event,userid){var select=this._region.find("[data-action=change-user]");userid=parseInt(userid,10),!isNaN(userid)&&userid>0&&select.attr("data-selected",userid),this._refreshCount()},GradingNavigation.prototype._triggerNextUserEvent=function(){this._filteredUsers.length>1?$(document).trigger("next-user",{nextUserId:null,nextUser:!0}):$(document).trigger("next-user",{nextUser:!1})},GradingNavigation.prototype._handleChangeUser=function(){var select=this._region.find("[data-action=change-user]"),userid=parseInt(select.val(),10);this._isLoading||(checker.checkFormForChanges('[data-region="grade-panel"] .gradeform')?str.get_strings([{key:"unsavedchanges",component:"mod_assign"},{key:"unsavedchangesquestion",component:"mod_assign"},{key:"saveandcontinue",component:"mod_assign"},{key:"cancel",component:"core"}]).done((function(strs){notification.confirm(strs[0],strs[1],strs[2],strs[3],(function(){$(document).trigger("save-changes",userid)}))})):!isNaN(userid)&&userid>0&&(select.attr("data-selected",userid),$(document).trigger("user-changed",userid)))},GradingNavigation}));
+define("mod_assign/grading_navigation",["jquery","core/notification","core/str","core/form-autocomplete","core/ajax","core_user/repository","mod_assign/grading_form_change_checker"],(function($,notification,str,autocomplete,ajax,UserRepository,checker){var GradingNavigation=function(selector){this._regionSelector=selector,this._region=$(selector),this._filters=[],this._users=[],this._filteredUsers=[],this._lastXofYUpdate=0,this._firstLoadUsers=!0;let url=new URL(window.location);parseInt(url.searchParams.get("treset"))>0&&(url.searchParams.delete("treset"),window.history.replaceState({},"",url)),this._loadAllUsers(),this._region.find('[data-action="previous-user"]').on("click",this._handlePreviousUser.bind(this)),this._region.find('[data-action="next-user"]').on("click",this._handleNextUser.bind(this)),this._region.find('[data-action="change-user"]').on("change",this._handleChangeUser.bind(this)),this._region.find('[data-region="user-filters"]').on("click",this._toggleExpandFilters.bind(this)),this._region.find('[data-region="user-resettable"]').on("click",this._toggleResetTable.bind()),$(document).on("user-changed",this._refreshSelector.bind(this)),$(document).on("done-saving-show-next",this._handleNextUser.bind(this));var toggleLink=this._region.find('[data-region="user-filters"]');$(document.getElementById(toggleLink.attr("aria-controls"))).on("change","select",this._filterChanged.bind(this));var userid=$('[data-region="grading-navigation-panel"]').data("first-userid");userid&&this._selectUserById(userid),str.get_string("changeuser","mod_assign").done((function(s){autocomplete.enhance("[data-action=change-user]",!1,"mod_assign/participant_selector",s)})).fail(notification.exception),$(document).bind("start-loading-user",function(){this._isLoading=!0}.bind(this)),$(document).bind("finish-loading-user",function(){this._isLoading=!1}.bind(this))};return GradingNavigation.prototype._isLoading=!1,GradingNavigation.prototype._regionSelector=null,GradingNavigation.prototype._filters=null,GradingNavigation.prototype._users=null,GradingNavigation.prototype._region=null,GradingNavigation.prototype._lastFilters="",GradingNavigation.prototype._loadAllUsers=function(){var select=this._region.find("[data-action=change-user]"),assignmentid=select.attr("data-assignmentid"),groupid=select.attr("data-groupid"),filterPanel=this._region.find('[data-region="configure-filters"]'),filter=filterPanel.find('select[name="filter"]').val(),workflowFilter=filterPanel.find('select[name="workflowfilter"]');workflowFilter&&(filter+=","+workflowFilter.val());var markerFilter=filterPanel.find('select[name="markerfilter"]');return markerFilter&&(filter+=","+markerFilter.val()),this._lastFilters!=filter&&(this._lastFilters=filter,ajax.call([{methodname:"mod_assign_list_participants",args:{assignid:assignmentid,groupid:groupid,filter:"",onlyids:!0,tablesort:!0},done:this._usersLoaded.bind(this),fail:notification.exception}]),!0)},GradingNavigation.prototype._usersLoaded=function(users){if(this._firstLoadUsers=!1,this._filteredUsers=this._users=users,this._users.length){var toggleLink=this._region.find('[data-region="user-filters"]');$(document.getElementById(toggleLink.attr("aria-controls"))).find('select[name="filter"]').trigger("change")}else this._selectNoUser();this._triggerNextUserEvent()},GradingNavigation.prototype._checkClickOutsideConfigureFilters=function(event){var configPanel=this._region.find('[data-region="configure-filters"]');if(!configPanel.is(event.target)&&0===configPanel.has(event.target).length){var toggleLink=this._region.find('[data-region="user-filters"]');configPanel.hide(),configPanel.attr("aria-hidden","true"),toggleLink.attr("aria-expanded","false"),$(document).unbind("click.mod_assign_grading_navigation")}},GradingNavigation.prototype._updateFilterPreferences=function(userId,filterList,preferenceNames){var preferences=[],i=0;if(0==filterList.length||this._firstLoadUsers){var deferred=$.Deferred();return deferred.resolve(),deferred}for(i=0;i0&&!isNaN(useridnumber)&&useridnumber>0&&$(document).trigger("user-changed",useridnumber)))},GradingNavigation.prototype._toggleExpandFilters=function(event){event.preventDefault();var toggleLink=$(event.target).closest('[data-region="user-filters"]'),expanded="true"==toggleLink.attr("aria-expanded"),configPanel=$(document.getElementById(toggleLink.attr("aria-controls")));expanded?(configPanel.hide(),configPanel.attr("aria-hidden","true"),toggleLink.attr("aria-expanded","false"),$(document).unbind("click.mod_assign_grading_navigation")):(configPanel.css("display","inline-block"),configPanel.attr("aria-hidden","false"),toggleLink.attr("aria-expanded","true"),event.stopPropagation(),$(document).on("click.mod_assign_grading_navigation",this._checkClickOutsideConfigureFilters.bind(this)))},GradingNavigation.prototype._toggleResetTable=function(){let url=new URL(window.location);url.searchParams.set("treset","1"),window.location.href=url},GradingNavigation.prototype._handlePreviousUser=function(e){e.preventDefault();var currentUserId=this._region.find("[data-action=change-user]").attr("data-selected"),i=0,currentIndex=0;for(i=0;i0&&$(document).trigger("user-changed",userid)}else count&&this._selectUserById(this._filteredUsers[newIndex].id)},GradingNavigation.prototype._setCountString=function(x,y){var updateNumber;this._lastXofYUpdate++,updateNumber=this._lastXofYUpdate;var param={x:x,y:y};str.get_string("xofy","mod_assign",param).done(function(s){updateNumber==this._lastXofYUpdate&&this._region.find('[data-region="user-count-summary"]').text(s)}.bind(this)).fail(notification.exception)},GradingNavigation.prototype._refreshCount=function(){var userid=this._region.find("[data-action=change-user]").attr("data-selected"),i=0,currentIndex=0;if(isNaN(userid)||userid<=0)this._region.find('[data-region="user-count"]').hide();else{for(this._region.find('[data-region="user-count"]').show(),i=0;i0){var url=new URL(window.location);if(parseInt(url.searchParams.get("blindid"))>0){var newid=this._filteredUsers[currentIndex-1].recordid;url.searchParams.set("blindid",newid)}else url.searchParams.set("userid",userid);window.history.replaceState({},"",url)}}},GradingNavigation.prototype._refreshSelector=function(event,userid){var select=this._region.find("[data-action=change-user]");userid=parseInt(userid,10),!isNaN(userid)&&userid>0&&select.attr("data-selected",userid),this._refreshCount()},GradingNavigation.prototype._triggerNextUserEvent=function(){this._filteredUsers.length>1?$(document).trigger("next-user",{nextUserId:null,nextUser:!0}):$(document).trigger("next-user",{nextUser:!1})},GradingNavigation.prototype._handleChangeUser=function(){var select=this._region.find("[data-action=change-user]"),userid=parseInt(select.val(),10);this._isLoading||(checker.checkFormForChanges('[data-region="grade-panel"] .gradeform')?str.get_strings([{key:"unsavedchanges",component:"mod_assign"},{key:"unsavedchangesquestion",component:"mod_assign"},{key:"saveandcontinue",component:"mod_assign"},{key:"cancel",component:"core"}]).done((function(strs){notification.confirm(strs[0],strs[1],strs[2],strs[3],(function(){$(document).trigger("save-changes",userid)}))})):!isNaN(userid)&&userid>0&&(select.attr("data-selected",userid),$(document).trigger("user-changed",userid)))},GradingNavigation}));
//# sourceMappingURL=grading_navigation.min.js.map
\ No newline at end of file
diff --git a/mod/assign/amd/build/grading_navigation.min.js.map b/mod/assign/amd/build/grading_navigation.min.js.map
index c375784c8bb..7b20b6d3e13 100644
--- a/mod/assign/amd/build/grading_navigation.min.js.map
+++ b/mod/assign/amd/build/grading_navigation.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"grading_navigation.min.js","sources":["../src/grading_navigation.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Javascript to handle changing users via the user selector in the header.\n *\n * @module mod_assign/grading_navigation\n * @copyright 2016 Damyon Wiese \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 3.1\n */\ndefine(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',\n 'core/ajax', 'core_user/repository', 'mod_assign/grading_form_change_checker'],\n function($, notification, str, autocomplete, ajax, UserRepository, checker) {\n\n /**\n * GradingNavigation class.\n *\n * @class mod_assign/grading_navigation\n * @param {String} selector The selector for the page region containing the user navigation.\n */\n var GradingNavigation = function(selector) {\n this._regionSelector = selector;\n this._region = $(selector);\n this._filters = [];\n this._users = [];\n this._filteredUsers = [];\n this._lastXofYUpdate = 0;\n this._firstLoadUsers = true;\n\n let url = new URL(window.location);\n if (parseInt(url.searchParams.get('treset')) > 0) {\n // Remove 'treset' url parameter to make sure that\n // table preferences won't be reset on page refresh.\n url.searchParams.delete('treset');\n window.history.replaceState({}, \"\", url);\n }\n\n // Get the current user list from a webservice.\n this._loadAllUsers();\n\n // We do not allow navigation while ajax requests are pending.\n // Attach listeners to the select and arrow buttons.\n\n this._region.find('[data-action=\"previous-user\"]').on('click', this._handlePreviousUser.bind(this));\n this._region.find('[data-action=\"next-user\"]').on('click', this._handleNextUser.bind(this));\n this._region.find('[data-action=\"change-user\"]').on('change', this._handleChangeUser.bind(this));\n this._region.find('[data-region=\"user-filters\"]').on('click', this._toggleExpandFilters.bind(this));\n this._region.find('[data-region=\"user-resettable\"]').on('click', this._toggleResetTable.bind());\n\n $(document).on('user-changed', this._refreshSelector.bind(this));\n $(document).on('done-saving-show-next', this._handleNextUser.bind(this));\n\n // Position the configure filters panel under the link that expands it.\n var toggleLink = this._region.find('[data-region=\"user-filters\"]');\n var configPanel = $(document.getElementById(toggleLink.attr('aria-controls')));\n\n configPanel.on('change', 'select', this._filterChanged.bind(this));\n\n var userid = $('[data-region=\"grading-navigation-panel\"]').data('first-userid');\n if (userid) {\n this._selectUserById(userid);\n }\n\n str.get_string('changeuser', 'mod_assign').done(function(s) {\n autocomplete.enhance('[data-action=change-user]', false, 'mod_assign/participant_selector', s);\n }\n ).fail(notification.exception);\n\n $(document).bind(\"start-loading-user\", function() {\n this._isLoading = true;\n }.bind(this));\n $(document).bind(\"finish-loading-user\", function() {\n this._isLoading = false;\n }.bind(this));\n };\n\n /** @property {Boolean} Boolean tracking active ajax requests. */\n GradingNavigation.prototype._isLoading = false;\n\n /** @property {String} Selector for the page region containing the user navigation. */\n GradingNavigation.prototype._regionSelector = null;\n\n /** @property {Array} The list of active filter keys */\n GradingNavigation.prototype._filters = null;\n\n /** @property {Array} The list of users */\n GradingNavigation.prototype._users = null;\n\n /** @property {JQuery} JQuery node for the page region containing the user navigation. */\n GradingNavigation.prototype._region = null;\n\n /** @property {String} Last active filters */\n GradingNavigation.prototype._lastFilters = '';\n\n /**\n * Load the list of all users for this assignment.\n *\n * @private\n * @method _loadAllUsers\n * @return {Boolean} True if the user list was fetched.\n */\n GradingNavigation.prototype._loadAllUsers = function() {\n var select = this._region.find('[data-action=change-user]');\n var assignmentid = select.attr('data-assignmentid');\n var groupid = select.attr('data-groupid');\n\n var filterPanel = this._region.find('[data-region=\"configure-filters\"]');\n var filter = filterPanel.find('select[name=\"filter\"]').val();\n var workflowFilter = filterPanel.find('select[name=\"workflowfilter\"]');\n if (workflowFilter) {\n filter += ',' + workflowFilter.val();\n }\n var markerFilter = filterPanel.find('select[name=\"markerfilter\"]');\n if (markerFilter) {\n filter += ',' + markerFilter.val();\n }\n\n if (this._lastFilters == filter) {\n return false;\n }\n this._lastFilters = filter;\n\n ajax.call([{\n methodname: 'mod_assign_list_participants',\n args: {assignid: assignmentid, groupid: groupid, filter: '', onlyids: true, tablesort: true},\n done: this._usersLoaded.bind(this),\n fail: notification.exception\n }]);\n return true;\n };\n\n /**\n * Call back to rebuild the user selector and x of y info when the user list is updated.\n *\n * @private\n * @method _usersLoaded\n * @param {Array} users\n */\n GradingNavigation.prototype._usersLoaded = function(users) {\n this._firstLoadUsers = false;\n this._filteredUsers = this._users = users;\n if (this._users.length) {\n // Position the configure filters panel under the link that expands it.\n var toggleLink = this._region.find('[data-region=\"user-filters\"]');\n var configPanel = $(document.getElementById(toggleLink.attr('aria-controls')));\n\n configPanel.find('select[name=\"filter\"]').trigger('change');\n } else {\n this._selectNoUser();\n }\n this._triggerNextUserEvent();\n };\n\n /**\n * Close the configure filters panel if a click is detected outside of it.\n *\n * @private\n * @method _checkClickOutsideConfigureFilters\n * @param {Event} event\n */\n GradingNavigation.prototype._checkClickOutsideConfigureFilters = function(event) {\n var configPanel = this._region.find('[data-region=\"configure-filters\"]');\n\n if (!configPanel.is(event.target) && configPanel.has(event.target).length === 0) {\n var toggleLink = this._region.find('[data-region=\"user-filters\"]');\n\n configPanel.hide();\n configPanel.attr('aria-hidden', 'true');\n toggleLink.attr('aria-expanded', 'false');\n $(document).unbind('click.mod_assign_grading_navigation');\n }\n };\n\n /**\n * Close the configure filters panel if a click is detected outside of it.\n *\n * @private\n * @method _updateFilterPreference\n * @param {Number} userId The current user id.\n * @param {Array} filterList The list of current filter values.\n * @param {Array} preferenceNames The names of the preferences to update\n * @return {Promise} Resolved when all the preferences are updated.\n */\n GradingNavigation.prototype._updateFilterPreferences = function(userId, filterList, preferenceNames) {\n var preferences = [],\n i = 0;\n\n if (filterList.length == 0 || this._firstLoadUsers) {\n // Nothing to update.\n var deferred = $.Deferred();\n deferred.resolve();\n return deferred;\n }\n // General filter.\n // Set the user preferences to the current filters.\n for (i = 0; i < filterList.length; i++) {\n var newValue = filterList[i];\n if (newValue == 'none') {\n newValue = '';\n }\n\n preferences.push({\n userid: userId,\n name: preferenceNames[i],\n value: newValue\n });\n }\n\n return UserRepository.setUserPreferences(preferences);\n };\n /**\n * Turn a filter on or off.\n *\n * @private\n * @method _filterChanged\n */\n GradingNavigation.prototype._filterChanged = function() {\n // There are 3 types of filter right now.\n var filterPanel = this._region.find('[data-region=\"configure-filters\"]');\n var filters = filterPanel.find('select');\n var preferenceNames = [];\n\n this._filters = [];\n filters.each(function(idx, ele) {\n var element = $(ele);\n this._filters.push(element.val());\n preferenceNames.push('assign_' + element.prop('name'));\n }.bind(this));\n\n // Update the active filter string.\n var filterlist = [];\n filterPanel.find('option:checked').each(function(idx, ele) {\n filterlist[filterlist.length] = $(ele).text();\n });\n if (filterlist.length) {\n this._region.find('[data-region=\"user-filters\"] span').text(filterlist.join(', '));\n } else {\n str.get_string('nofilters', 'mod_assign').done(function(s) {\n this._region.find('[data-region=\"user-filters\"] span').text(s);\n }.bind(this)).fail(notification.exception);\n }\n\n var select = this._region.find('[data-action=change-user]');\n var currentUserID = select.data('currentuserid');\n this._updateFilterPreferences(currentUserID, this._filters, preferenceNames).done(function() {\n // Reload the list of users to apply the new filters.\n if (!this._loadAllUsers()) {\n var userid = parseInt(select.attr('data-selected'));\n let foundIndex = null;\n // Search the returned users for the current selection.\n $.each(this._filteredUsers, function(index, user) {\n if (userid == user.id) {\n foundIndex = index;\n }\n });\n\n if (this._filteredUsers.length && foundIndex !== null) {\n this._selectUserById(this._filteredUsers[foundIndex].id);\n } else {\n this._selectNoUser();\n }\n\n }\n }.bind(this)).fail(notification.exception);\n this._refreshCount();\n };\n\n /**\n * Select no users, because no users match the filters.\n *\n * @private\n * @method _selectNoUser\n */\n GradingNavigation.prototype._selectNoUser = function() {\n // Detect unsaved changes, and offer to save them - otherwise change user right now.\n if (this._isLoading) {\n return;\n }\n if (checker.checkFormForChanges('[data-region=\"grade-panel\"] .gradeform')) {\n // Form has changes, so we need to confirm before switching users.\n str.get_strings([\n {key: 'unsavedchanges', component: 'mod_assign'},\n {key: 'unsavedchangesquestion', component: 'mod_assign'},\n {key: 'saveandcontinue', component: 'mod_assign'},\n {key: 'cancel', component: 'core'},\n ]).done(function(strs) {\n notification.confirm(strs[0], strs[1], strs[2], strs[3], function() {\n $(document).trigger('save-changes', -1);\n });\n });\n } else {\n $(document).trigger('user-changed', -1);\n }\n };\n\n /**\n * Select the specified user by id.\n *\n * @private\n * @method _selectUserById\n * @param {Number} userid\n */\n GradingNavigation.prototype._selectUserById = function(userid) {\n var select = this._region.find('[data-action=change-user]');\n var useridnumber = parseInt(userid, 10);\n\n // Detect unsaved changes, and offer to save them - otherwise change user right now.\n if (this._isLoading) {\n return;\n }\n if (checker.checkFormForChanges('[data-region=\"grade-panel\"] .gradeform')) {\n // Form has changes, so we need to confirm before switching users.\n str.get_strings([\n {key: 'unsavedchanges', component: 'mod_assign'},\n {key: 'unsavedchangesquestion', component: 'mod_assign'},\n {key: 'saveandcontinue', component: 'mod_assign'},\n {key: 'cancel', component: 'core'},\n ]).done(function(strs) {\n notification.confirm(strs[0], strs[1], strs[2], strs[3], function() {\n $(document).trigger('save-changes', useridnumber);\n });\n });\n } else {\n select.attr('data-selected', userid);\n\n // If we have some filtered users, and userid is specified, then trigger change.\n if (this._filteredUsers.length > 0 && !isNaN(useridnumber) && useridnumber > 0) {\n $(document).trigger('user-changed', useridnumber);\n }\n }\n };\n\n /**\n * Expand or collapse the filter config panel.\n *\n * @private\n * @method _toggleExpandFilters\n * @param {Event} event\n */\n GradingNavigation.prototype._toggleExpandFilters = function(event) {\n event.preventDefault();\n var toggleLink = $(event.target).closest('[data-region=\"user-filters\"]');\n var expanded = toggleLink.attr('aria-expanded') == 'true';\n var configPanel = $(document.getElementById(toggleLink.attr('aria-controls')));\n\n if (expanded) {\n configPanel.hide();\n configPanel.attr('aria-hidden', 'true');\n toggleLink.attr('aria-expanded', 'false');\n $(document).unbind('click.mod_assign_grading_navigation');\n } else {\n configPanel.css('display', 'inline-block');\n configPanel.attr('aria-hidden', 'false');\n toggleLink.attr('aria-expanded', 'true');\n event.stopPropagation();\n $(document).on('click.mod_assign_grading_navigation', this._checkClickOutsideConfigureFilters.bind(this));\n }\n };\n\n /**\n * Reset table preferences.\n *\n * @private\n * @method _toggleResetTable\n */\n GradingNavigation.prototype._toggleResetTable = function() {\n let url = new URL(window.location);\n url.searchParams.set('treset', '1');\n window.location.href = url;\n };\n\n /**\n * Change to the previous user in the grading list.\n *\n * @private\n * @method _handlePreviousUser\n * @param {Event} e\n */\n GradingNavigation.prototype._handlePreviousUser = function(e) {\n e.preventDefault();\n var select = this._region.find('[data-action=change-user]');\n var currentUserId = select.attr('data-selected');\n var i = 0;\n var currentIndex = 0;\n\n for (i = 0; i < this._filteredUsers.length; i++) {\n if (this._filteredUsers[i].id == currentUserId) {\n currentIndex = i;\n break;\n }\n }\n\n var count = this._filteredUsers.length;\n var newIndex = (currentIndex - 1);\n if (newIndex < 0) {\n newIndex = count - 1;\n }\n\n if (count) {\n this._selectUserById(this._filteredUsers[newIndex].id);\n }\n };\n\n /**\n * Change to the next user in the grading list.\n *\n * @param {Event} e\n * @param {Boolean} saved Has the form already been saved? Skips checking for changes if true.\n */\n GradingNavigation.prototype._handleNextUser = function(e, saved) {\n e.preventDefault();\n var select = this._region.find('[data-action=change-user]');\n var currentUserId = select.attr('data-selected');\n var i = 0;\n var currentIndex = 0;\n\n for (i = 0; i < this._filteredUsers.length; i++) {\n if (this._filteredUsers[i].id == currentUserId) {\n currentIndex = i;\n break;\n }\n }\n\n var count = this._filteredUsers.length;\n var newIndex = (currentIndex + 1) % count;\n\n if (saved && count) {\n // If we've already saved the grade, skip checking if we've made any changes.\n var userid = this._filteredUsers[newIndex].id;\n var useridnumber = parseInt(userid, 10);\n select.attr('data-selected', userid);\n if (!isNaN(useridnumber) && useridnumber > 0) {\n $(document).trigger('user-changed', userid);\n }\n } else if (count) {\n this._selectUserById(this._filteredUsers[newIndex].id);\n }\n };\n\n /**\n * Set count string. This method only sets the value for the last time it was ever called to deal\n * with promises that return in a non-predictable order.\n *\n * @private\n * @method _setCountString\n * @param {Number} x\n * @param {Number} y\n */\n GradingNavigation.prototype._setCountString = function(x, y) {\n var updateNumber = 0;\n this._lastXofYUpdate++;\n updateNumber = this._lastXofYUpdate;\n\n var param = {x: x, y: y};\n str.get_string('xofy', 'mod_assign', param).done(function(s) {\n if (updateNumber == this._lastXofYUpdate) {\n this._region.find('[data-region=\"user-count-summary\"]').text(s);\n }\n }.bind(this)).fail(notification.exception);\n };\n\n /**\n * Rebuild the x of y string.\n *\n * @private\n * @method _refreshCount\n */\n GradingNavigation.prototype._refreshCount = function() {\n var select = this._region.find('[data-action=change-user]');\n var userid = select.attr('data-selected');\n var i = 0;\n var currentIndex = 0;\n\n if (isNaN(userid) || userid <= 0) {\n this._region.find('[data-region=\"user-count\"]').hide();\n } else {\n this._region.find('[data-region=\"user-count\"]').show();\n\n for (i = 0; i < this._filteredUsers.length; i++) {\n if (this._filteredUsers[i].id == userid) {\n currentIndex = i;\n break;\n }\n }\n var count = this._filteredUsers.length;\n if (count) {\n currentIndex += 1;\n }\n this._setCountString(currentIndex, count);\n // Update window URL\n if (currentIndex > 0) {\n var url = new URL(window.location);\n if (parseInt(url.searchParams.get('blindid')) > 0) {\n var newid = this._filteredUsers[currentIndex - 1].recordid;\n url.searchParams.set('blindid', newid);\n } else {\n url.searchParams.set('userid', userid);\n }\n // We do this so a browser refresh will return to the same user.\n window.history.replaceState({}, \"\", url);\n }\n }\n };\n\n /**\n * Respond to a user-changed event by updating the selector.\n *\n * @private\n * @method _refreshSelector\n * @param {Event} event\n * @param {String} userid\n */\n GradingNavigation.prototype._refreshSelector = function(event, userid) {\n var select = this._region.find('[data-action=change-user]');\n userid = parseInt(userid, 10);\n\n if (!isNaN(userid) && userid > 0) {\n select.attr('data-selected', userid);\n }\n this._refreshCount();\n };\n\n /**\n * Trigger the next user event depending on the number of filtered users\n *\n * @private\n * @method _triggerNextUserEvent\n */\n GradingNavigation.prototype._triggerNextUserEvent = function() {\n if (this._filteredUsers.length > 1) {\n $(document).trigger('next-user', {nextUserId: null, nextUser: true});\n } else {\n $(document).trigger('next-user', {nextUser: false});\n }\n };\n\n /**\n * Change to a different user in the grading list.\n *\n * @private\n * @method _handleChangeUser\n */\n GradingNavigation.prototype._handleChangeUser = function() {\n var select = this._region.find('[data-action=change-user]');\n var userid = parseInt(select.val(), 10);\n\n if (this._isLoading) {\n return;\n }\n if (checker.checkFormForChanges('[data-region=\"grade-panel\"] .gradeform')) {\n // Form has changes, so we need to confirm before switching users.\n str.get_strings([\n {key: 'unsavedchanges', component: 'mod_assign'},\n {key: 'unsavedchangesquestion', component: 'mod_assign'},\n {key: 'saveandcontinue', component: 'mod_assign'},\n {key: 'cancel', component: 'core'},\n ]).done(function(strs) {\n notification.confirm(strs[0], strs[1], strs[2], strs[3], function() {\n $(document).trigger('save-changes', userid);\n });\n });\n } else {\n if (!isNaN(userid) && userid > 0) {\n select.attr('data-selected', userid);\n\n $(document).trigger('user-changed', userid);\n }\n }\n };\n\n return GradingNavigation;\n});\n"],"names":["define","$","notification","str","autocomplete","ajax","UserRepository","checker","GradingNavigation","selector","_regionSelector","_region","_filters","_users","_filteredUsers","_lastXofYUpdate","_firstLoadUsers","url","URL","window","location","parseInt","searchParams","get","delete","history","replaceState","_loadAllUsers","find","on","this","_handlePreviousUser","bind","_handleNextUser","_handleChangeUser","_toggleExpandFilters","_toggleResetTable","document","_refreshSelector","toggleLink","getElementById","attr","_filterChanged","userid","data","_selectUserById","get_string","done","s","enhance","fail","exception","_isLoading","prototype","_lastFilters","select","assignmentid","groupid","filterPanel","filter","val","workflowFilter","markerFilter","call","methodname","args","assignid","onlyids","tablesort","_usersLoaded","users","length","trigger","_selectNoUser","_triggerNextUserEvent","_checkClickOutsideConfigureFilters","event","configPanel","is","target","has","hide","unbind","_updateFilterPreferences","userId","filterList","preferenceNames","preferences","i","deferred","Deferred","resolve","newValue","push","name","value","setUserPreferences","filters","each","idx","ele","element","prop","filterlist","text","join","currentUserID","foundIndex","index","user","id","_refreshCount","checkFormForChanges","get_strings","key","component","strs","confirm","useridnumber","isNaN","preventDefault","closest","expanded","css","stopPropagation","set","href","e","currentUserId","currentIndex","count","newIndex","saved","_setCountString","x","y","updateNumber","param","show","newid","recordid","nextUserId","nextUser"],"mappings":";;;;;;;;AAuBAA,uCAAO,CAAC,SAAU,oBAAqB,WAAY,yBAC3C,YAAa,uBAAwB,2CACtC,SAASC,EAAGC,aAAcC,IAAKC,aAAcC,KAAMC,eAAgBC,aAQlEC,kBAAoB,SAASC,eACxBC,gBAAkBD,cAClBE,QAAUV,EAAEQ,eACZG,SAAW,QACXC,OAAS,QACTC,eAAiB,QACjBC,gBAAkB,OAClBC,iBAAkB,MAEnBC,IAAM,IAAIC,IAAIC,OAAOC,UACrBC,SAASJ,IAAIK,aAAaC,IAAI,WAAa,IAG3CN,IAAIK,aAAaE,OAAO,UACxBL,OAAOM,QAAQC,aAAa,GAAI,GAAIT,WAInCU,qBAKAhB,QAAQiB,KAAK,iCAAiCC,GAAG,QAASC,KAAKC,oBAAoBC,KAAKF,YACxFnB,QAAQiB,KAAK,6BAA6BC,GAAG,QAASC,KAAKG,gBAAgBD,KAAKF,YAChFnB,QAAQiB,KAAK,+BAA+BC,GAAG,SAAUC,KAAKI,kBAAkBF,KAAKF,YACrFnB,QAAQiB,KAAK,gCAAgCC,GAAG,QAASC,KAAKK,qBAAqBH,KAAKF,YACxFnB,QAAQiB,KAAK,mCAAmCC,GAAG,QAASC,KAAKM,kBAAkBJ,QAExF/B,EAAEoC,UAAUR,GAAG,eAAgBC,KAAKQ,iBAAiBN,KAAKF,OAC1D7B,EAAEoC,UAAUR,GAAG,wBAAyBC,KAAKG,gBAAgBD,KAAKF,WAG9DS,WAAaT,KAAKnB,QAAQiB,KAAK,gCACjB3B,EAAEoC,SAASG,eAAeD,WAAWE,KAAK,mBAEhDZ,GAAG,SAAU,SAAUC,KAAKY,eAAeV,KAAKF,WAExDa,OAAS1C,EAAE,4CAA4C2C,KAAK,gBAC5DD,aACKE,gBAAgBF,QAGzBxC,IAAI2C,WAAW,aAAc,cAAcC,MAAK,SAASC,GACjD5C,aAAa6C,QAAQ,6BAA6B,EAAO,kCAAmCD,MAElGE,KAAKhD,aAAaiD,WAEpBlD,EAAEoC,UAAUL,KAAK,qBAAsB,gBAC9BoB,YAAa,GACpBpB,KAAKF,OACP7B,EAAEoC,UAAUL,KAAK,sBAAuB,gBAC/BoB,YAAa,GACpBpB,KAAKF,eAIXtB,kBAAkB6C,UAAUD,YAAa,EAGzC5C,kBAAkB6C,UAAU3C,gBAAkB,KAG9CF,kBAAkB6C,UAAUzC,SAAW,KAGvCJ,kBAAkB6C,UAAUxC,OAAS,KAGrCL,kBAAkB6C,UAAU1C,QAAU,KAGtCH,kBAAkB6C,UAAUC,aAAe,GAS3C9C,kBAAkB6C,UAAU1B,cAAgB,eACpC4B,OAASzB,KAAKnB,QAAQiB,KAAK,6BAC3B4B,aAAeD,OAAOd,KAAK,qBAC3BgB,QAAUF,OAAOd,KAAK,gBAEtBiB,YAAc5B,KAAKnB,QAAQiB,KAAK,qCAChC+B,OAASD,YAAY9B,KAAK,yBAAyBgC,MACnDC,eAAiBH,YAAY9B,KAAK,iCAClCiC,iBACAF,QAAU,IAAME,eAAeD,WAE/BE,aAAeJ,YAAY9B,KAAK,sCAChCkC,eACAH,QAAU,IAAMG,aAAaF,OAG7B9B,KAAKwB,cAAgBK,cAGpBL,aAAeK,OAEpBtD,KAAK0D,KAAK,CAAC,CACPC,WAAY,+BACZC,KAAM,CAACC,SAAUV,aAAcC,QAASA,QAASE,OAAQ,GAAIQ,SAAS,EAAMC,WAAW,GACvFrB,KAAMjB,KAAKuC,aAAarC,KAAKF,MAC7BoB,KAAMhD,aAAaiD,cAEhB,IAUX3C,kBAAkB6C,UAAUgB,aAAe,SAASC,eAC3CtD,iBAAkB,OAClBF,eAAiBgB,KAAKjB,OAASyD,MAChCxC,KAAKjB,OAAO0D,OAAQ,KAEhBhC,WAAaT,KAAKnB,QAAQiB,KAAK,gCACjB3B,EAAEoC,SAASG,eAAeD,WAAWE,KAAK,mBAEhDb,KAAK,yBAAyB4C,QAAQ,oBAE7CC,qBAEJC,yBAUTlE,kBAAkB6C,UAAUsB,mCAAqC,SAASC,WAClEC,YAAc/C,KAAKnB,QAAQiB,KAAK,yCAE/BiD,YAAYC,GAAGF,MAAMG,SAAoD,IAAzCF,YAAYG,IAAIJ,MAAMG,QAAQR,OAAc,KACzEhC,WAAaT,KAAKnB,QAAQiB,KAAK,gCAEnCiD,YAAYI,OACZJ,YAAYpC,KAAK,cAAe,QAChCF,WAAWE,KAAK,gBAAiB,SACjCxC,EAAEoC,UAAU6C,OAAO,yCAc3B1E,kBAAkB6C,UAAU8B,yBAA2B,SAASC,OAAQC,WAAYC,qBAC5EC,YAAc,GACdC,EAAI,KAEiB,GAArBH,WAAWd,QAAezC,KAAKd,gBAAiB,KAE5CyE,SAAWxF,EAAEyF,kBACjBD,SAASE,UACFF,aAIND,EAAI,EAAGA,EAAIH,WAAWd,OAAQiB,IAAK,KAChCI,SAAWP,WAAWG,GACV,QAAZI,WACAA,SAAW,IAGfL,YAAYM,KAAK,CACblD,OAAQyC,OACRU,KAAMR,gBAAgBE,GACtBO,MAAOH,kBAIRtF,eAAe0F,mBAAmBT,cAQ7C/E,kBAAkB6C,UAAUX,eAAiB,eAErCgB,YAAc5B,KAAKnB,QAAQiB,KAAK,qCAChCqE,QAAUvC,YAAY9B,KAAK,UAC3B0D,gBAAkB,QAEjB1E,SAAW,GAChBqF,QAAQC,KAAK,SAASC,IAAKC,SACnBC,QAAUpG,EAAEmG,UACXxF,SAASiF,KAAKQ,QAAQzC,OAC3B0B,gBAAgBO,KAAK,UAAYQ,QAAQC,KAAK,UAChDtE,KAAKF,WAGHyE,WAAa,GACjB7C,YAAY9B,KAAK,kBAAkBsE,MAAK,SAASC,IAAKC,KAClDG,WAAWA,WAAWhC,QAAUtE,EAAEmG,KAAKI,UAEvCD,WAAWhC,YACN5D,QAAQiB,KAAK,qCAAqC4E,KAAKD,WAAWE,KAAK,OAE5EtG,IAAI2C,WAAW,YAAa,cAAcC,KAAK,SAASC,QAC/CrC,QAAQiB,KAAK,qCAAqC4E,KAAKxD,IAC9DhB,KAAKF,OAAOoB,KAAKhD,aAAaiD,eAGhCI,OAASzB,KAAKnB,QAAQiB,KAAK,6BAC3B8E,cAAgBnD,OAAOX,KAAK,sBAC3BuC,yBAAyBuB,cAAe5E,KAAKlB,SAAU0E,iBAAiBvC,KAAK,eAEzEjB,KAAKH,gBAAiB,KACnBgB,OAAStB,SAASkC,OAAOd,KAAK,sBAC9BkE,WAAa,KAEjB1G,EAAEiG,KAAKpE,KAAKhB,gBAAgB,SAAS8F,MAAOC,MACpClE,QAAUkE,KAAKC,KACfH,WAAaC,UAIjB9E,KAAKhB,eAAeyD,QAAyB,OAAfoC,gBACzB9D,gBAAgBf,KAAKhB,eAAe6F,YAAYG,SAEhDrC,kBAIfzC,KAAKF,OAAOoB,KAAKhD,aAAaiD,gBAC3B4D,iBASTvG,kBAAkB6C,UAAUoB,cAAgB,WAEpC3C,KAAKsB,aAGL7C,QAAQyG,oBAAoB,0CAE5B7G,IAAI8G,YAAY,CACZ,CAACC,IAAK,iBAAkBC,UAAW,cACnC,CAACD,IAAK,yBAA0BC,UAAW,cAC3C,CAACD,IAAK,kBAAmBC,UAAW,cACpC,CAACD,IAAK,SAAUC,UAAW,UAC5BpE,MAAK,SAASqE,MACblH,aAAamH,QAAQD,KAAK,GAAIA,KAAK,GAAIA,KAAK,GAAIA,KAAK,IAAI,WACrDnH,EAAEoC,UAAUmC,QAAQ,gBAAiB,SAI7CvE,EAAEoC,UAAUmC,QAAQ,gBAAiB,KAW7ChE,kBAAkB6C,UAAUR,gBAAkB,SAASF,YAC/CY,OAASzB,KAAKnB,QAAQiB,KAAK,6BAC3B0F,aAAejG,SAASsB,OAAQ,IAGhCb,KAAKsB,aAGL7C,QAAQyG,oBAAoB,0CAE5B7G,IAAI8G,YAAY,CACZ,CAACC,IAAK,iBAAkBC,UAAW,cACnC,CAACD,IAAK,yBAA0BC,UAAW,cAC3C,CAACD,IAAK,kBAAmBC,UAAW,cACpC,CAACD,IAAK,SAAUC,UAAW,UAC5BpE,MAAK,SAASqE,MACblH,aAAamH,QAAQD,KAAK,GAAIA,KAAK,GAAIA,KAAK,GAAIA,KAAK,IAAI,WACrDnH,EAAEoC,UAAUmC,QAAQ,eAAgB8C,qBAI5C/D,OAAOd,KAAK,gBAAiBE,QAGzBb,KAAKhB,eAAeyD,OAAS,IAAMgD,MAAMD,eAAiBA,aAAe,GACzErH,EAAEoC,UAAUmC,QAAQ,eAAgB8C,iBAYhD9G,kBAAkB6C,UAAUlB,qBAAuB,SAASyC,OACxDA,MAAM4C,qBACFjF,WAAatC,EAAE2E,MAAMG,QAAQ0C,QAAQ,gCACrCC,SAA+C,QAApCnF,WAAWE,KAAK,iBAC3BoC,YAAc5E,EAAEoC,SAASG,eAAeD,WAAWE,KAAK,mBAExDiF,UACA7C,YAAYI,OACZJ,YAAYpC,KAAK,cAAe,QAChCF,WAAWE,KAAK,gBAAiB,SACjCxC,EAAEoC,UAAU6C,OAAO,yCAEnBL,YAAY8C,IAAI,UAAW,gBAC3B9C,YAAYpC,KAAK,cAAe,SAChCF,WAAWE,KAAK,gBAAiB,QACjCmC,MAAMgD,kBACN3H,EAAEoC,UAAUR,GAAG,sCAAuCC,KAAK6C,mCAAmC3C,KAAKF,SAU3GtB,kBAAkB6C,UAAUjB,kBAAoB,eACxCnB,IAAM,IAAIC,IAAIC,OAAOC,UACzBH,IAAIK,aAAauG,IAAI,SAAU,KAC/B1G,OAAOC,SAAS0G,KAAO7G,KAU3BT,kBAAkB6C,UAAUtB,oBAAsB,SAASgG,GACvDA,EAAEP,qBAEEQ,cADSlG,KAAKnB,QAAQiB,KAAK,6BACJa,KAAK,iBAC5B+C,EAAI,EACJyC,aAAe,MAEdzC,EAAI,EAAGA,EAAI1D,KAAKhB,eAAeyD,OAAQiB,OACpC1D,KAAKhB,eAAe0E,GAAGsB,IAAMkB,cAAe,CAC5CC,aAAezC,YAKnB0C,MAAQpG,KAAKhB,eAAeyD,OAC5B4D,SAAYF,aAAe,EAC3BE,SAAW,IACXA,SAAWD,MAAQ,GAGnBA,YACKrF,gBAAgBf,KAAKhB,eAAeqH,UAAUrB,KAU3DtG,kBAAkB6C,UAAUpB,gBAAkB,SAAS8F,EAAGK,OACtDL,EAAEP,qBACEjE,OAASzB,KAAKnB,QAAQiB,KAAK,6BAC3BoG,cAAgBzE,OAAOd,KAAK,iBAC5B+C,EAAI,EACJyC,aAAe,MAEdzC,EAAI,EAAGA,EAAI1D,KAAKhB,eAAeyD,OAAQiB,OACpC1D,KAAKhB,eAAe0E,GAAGsB,IAAMkB,cAAe,CAC5CC,aAAezC,YAKnB0C,MAAQpG,KAAKhB,eAAeyD,OAC5B4D,UAAYF,aAAe,GAAKC,SAEhCE,OAASF,MAAO,KAEZvF,OAASb,KAAKhB,eAAeqH,UAAUrB,GACvCQ,aAAejG,SAASsB,OAAQ,IACpCY,OAAOd,KAAK,gBAAiBE,SACxB4E,MAAMD,eAAiBA,aAAe,GACvCrH,EAAEoC,UAAUmC,QAAQ,eAAgB7B,aAEjCuF,YACFrF,gBAAgBf,KAAKhB,eAAeqH,UAAUrB,KAa3DtG,kBAAkB6C,UAAUgF,gBAAkB,SAASC,EAAGC,OAClDC,kBACCzH,kBACLyH,aAAe1G,KAAKf,oBAEhB0H,MAAQ,CAACH,EAAGA,EAAGC,EAAGA,GACtBpI,IAAI2C,WAAW,OAAQ,aAAc2F,OAAO1F,KAAK,SAASC,GAClDwF,cAAgB1G,KAAKf,sBAChBJ,QAAQiB,KAAK,sCAAsC4E,KAAKxD,IAEnEhB,KAAKF,OAAOoB,KAAKhD,aAAaiD,YASpC3C,kBAAkB6C,UAAU0D,cAAgB,eAEpCpE,OADSb,KAAKnB,QAAQiB,KAAK,6BACXa,KAAK,iBACrB+C,EAAI,EACJyC,aAAe,KAEfV,MAAM5E,SAAWA,QAAU,OACtBhC,QAAQiB,KAAK,8BAA8BqD,WAC7C,UACEtE,QAAQiB,KAAK,8BAA8B8G,OAE3ClD,EAAI,EAAGA,EAAI1D,KAAKhB,eAAeyD,OAAQiB,OACpC1D,KAAKhB,eAAe0E,GAAGsB,IAAMnE,OAAQ,CACrCsF,aAAezC,YAInB0C,MAAQpG,KAAKhB,eAAeyD,UAC5B2D,QACAD,cAAgB,QAEfI,gBAAgBJ,aAAcC,OAE/BD,aAAe,EAAG,KACdhH,IAAM,IAAIC,IAAIC,OAAOC,aACrBC,SAASJ,IAAIK,aAAaC,IAAI,YAAc,EAAG,KAC3CoH,MAAQ7G,KAAKhB,eAAemH,aAAe,GAAGW,SAClD3H,IAAIK,aAAauG,IAAI,UAAWc,YAEhC1H,IAAIK,aAAauG,IAAI,SAAUlF,QAGnCxB,OAAOM,QAAQC,aAAa,GAAI,GAAIT,QAahDT,kBAAkB6C,UAAUf,iBAAmB,SAASsC,MAAOjC,YACvDY,OAASzB,KAAKnB,QAAQiB,KAAK,6BAC/Be,OAAStB,SAASsB,OAAQ,KAErB4E,MAAM5E,SAAWA,OAAS,GAC3BY,OAAOd,KAAK,gBAAiBE,aAE5BoE,iBASTvG,kBAAkB6C,UAAUqB,sBAAwB,WAC5C5C,KAAKhB,eAAeyD,OAAS,EAC7BtE,EAAEoC,UAAUmC,QAAQ,YAAa,CAACqE,WAAY,KAAMC,UAAU,IAE9D7I,EAAEoC,UAAUmC,QAAQ,YAAa,CAACsE,UAAU,KAUpDtI,kBAAkB6C,UAAUnB,kBAAoB,eACxCqB,OAASzB,KAAKnB,QAAQiB,KAAK,6BAC3Be,OAAStB,SAASkC,OAAOK,MAAO,IAEhC9B,KAAKsB,aAGL7C,QAAQyG,oBAAoB,0CAE5B7G,IAAI8G,YAAY,CACZ,CAACC,IAAK,iBAAkBC,UAAW,cACnC,CAACD,IAAK,yBAA0BC,UAAW,cAC3C,CAACD,IAAK,kBAAmBC,UAAW,cACpC,CAACD,IAAK,SAAUC,UAAW,UAC5BpE,MAAK,SAASqE,MACblH,aAAamH,QAAQD,KAAK,GAAIA,KAAK,GAAIA,KAAK,GAAIA,KAAK,IAAI,WACrDnH,EAAEoC,UAAUmC,QAAQ,eAAgB7B,eAIvC4E,MAAM5E,SAAWA,OAAS,IAC3BY,OAAOd,KAAK,gBAAiBE,QAE7B1C,EAAEoC,UAAUmC,QAAQ,eAAgB7B,WAKzCnC"}
\ No newline at end of file
+{"version":3,"file":"grading_navigation.min.js","sources":["../src/grading_navigation.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Javascript to handle changing users via the user selector in the header.\n *\n * @module mod_assign/grading_navigation\n * @copyright 2016 Damyon Wiese \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 3.1\n */\ndefine(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',\n 'core/ajax', 'core_user/repository', 'mod_assign/grading_form_change_checker'],\n function($, notification, str, autocomplete, ajax, UserRepository, checker) {\n\n /**\n * GradingNavigation class.\n *\n * @class mod_assign/grading_navigation\n * @param {String} selector The selector for the page region containing the user navigation.\n */\n var GradingNavigation = function(selector) {\n this._regionSelector = selector;\n this._region = $(selector);\n this._filters = [];\n this._users = [];\n this._filteredUsers = [];\n this._lastXofYUpdate = 0;\n this._firstLoadUsers = true;\n\n let url = new URL(window.location);\n if (parseInt(url.searchParams.get('treset')) > 0) {\n // Remove 'treset' url parameter to make sure that\n // table preferences won't be reset on page refresh.\n url.searchParams.delete('treset');\n window.history.replaceState({}, \"\", url);\n }\n\n // Get the current user list from a webservice.\n this._loadAllUsers();\n\n // We do not allow navigation while ajax requests are pending.\n // Attach listeners to the select and arrow buttons.\n\n this._region.find('[data-action=\"previous-user\"]').on('click', this._handlePreviousUser.bind(this));\n this._region.find('[data-action=\"next-user\"]').on('click', this._handleNextUser.bind(this));\n this._region.find('[data-action=\"change-user\"]').on('change', this._handleChangeUser.bind(this));\n this._region.find('[data-region=\"user-filters\"]').on('click', this._toggleExpandFilters.bind(this));\n this._region.find('[data-region=\"user-resettable\"]').on('click', this._toggleResetTable.bind());\n\n $(document).on('user-changed', this._refreshSelector.bind(this));\n $(document).on('done-saving-show-next', this._handleNextUser.bind(this));\n\n // Position the configure filters panel under the link that expands it.\n var toggleLink = this._region.find('[data-region=\"user-filters\"]');\n var configPanel = $(document.getElementById(toggleLink.attr('aria-controls')));\n\n configPanel.on('change', 'select', this._filterChanged.bind(this));\n\n var userid = $('[data-region=\"grading-navigation-panel\"]').data('first-userid');\n if (userid) {\n this._selectUserById(userid);\n }\n\n str.get_string('changeuser', 'mod_assign').done(function(s) {\n autocomplete.enhance('[data-action=change-user]', false, 'mod_assign/participant_selector', s);\n }\n ).fail(notification.exception);\n\n $(document).bind(\"start-loading-user\", function() {\n this._isLoading = true;\n }.bind(this));\n $(document).bind(\"finish-loading-user\", function() {\n this._isLoading = false;\n }.bind(this));\n };\n\n /** @property {Boolean} Boolean tracking active ajax requests. */\n GradingNavigation.prototype._isLoading = false;\n\n /** @property {String} Selector for the page region containing the user navigation. */\n GradingNavigation.prototype._regionSelector = null;\n\n /** @property {Array} The list of active filter keys */\n GradingNavigation.prototype._filters = null;\n\n /** @property {Array} The list of users */\n GradingNavigation.prototype._users = null;\n\n /** @property {JQuery} JQuery node for the page region containing the user navigation. */\n GradingNavigation.prototype._region = null;\n\n /** @property {String} Last active filters */\n GradingNavigation.prototype._lastFilters = '';\n\n /**\n * Load the list of all users for this assignment.\n *\n * @private\n * @method _loadAllUsers\n * @return {Boolean} True if the user list was fetched.\n */\n GradingNavigation.prototype._loadAllUsers = function() {\n var select = this._region.find('[data-action=change-user]');\n var assignmentid = select.attr('data-assignmentid');\n var groupid = select.attr('data-groupid');\n\n var filterPanel = this._region.find('[data-region=\"configure-filters\"]');\n var filter = filterPanel.find('select[name=\"filter\"]').val();\n var workflowFilter = filterPanel.find('select[name=\"workflowfilter\"]');\n if (workflowFilter) {\n filter += ',' + workflowFilter.val();\n }\n var markerFilter = filterPanel.find('select[name=\"markerfilter\"]');\n if (markerFilter) {\n filter += ',' + markerFilter.val();\n }\n\n if (this._lastFilters == filter) {\n return false;\n }\n this._lastFilters = filter;\n\n ajax.call([{\n methodname: 'mod_assign_list_participants',\n args: {assignid: assignmentid, groupid: groupid, filter: '', onlyids: true, tablesort: true},\n done: this._usersLoaded.bind(this),\n fail: notification.exception\n }]);\n return true;\n };\n\n /**\n * Call back to rebuild the user selector and x of y info when the user list is updated.\n *\n * @private\n * @method _usersLoaded\n * @param {Array} users\n */\n GradingNavigation.prototype._usersLoaded = function(users) {\n this._firstLoadUsers = false;\n this._filteredUsers = this._users = users;\n if (this._users.length) {\n // Position the configure filters panel under the link that expands it.\n var toggleLink = this._region.find('[data-region=\"user-filters\"]');\n var configPanel = $(document.getElementById(toggleLink.attr('aria-controls')));\n\n configPanel.find('select[name=\"filter\"]').trigger('change');\n } else {\n this._selectNoUser();\n }\n this._triggerNextUserEvent();\n };\n\n /**\n * Close the configure filters panel if a click is detected outside of it.\n *\n * @private\n * @method _checkClickOutsideConfigureFilters\n * @param {Event} event\n */\n GradingNavigation.prototype._checkClickOutsideConfigureFilters = function(event) {\n var configPanel = this._region.find('[data-region=\"configure-filters\"]');\n\n if (!configPanel.is(event.target) && configPanel.has(event.target).length === 0) {\n var toggleLink = this._region.find('[data-region=\"user-filters\"]');\n\n configPanel.hide();\n configPanel.attr('aria-hidden', 'true');\n toggleLink.attr('aria-expanded', 'false');\n $(document).unbind('click.mod_assign_grading_navigation');\n }\n };\n\n /**\n * Close the configure filters panel if a click is detected outside of it.\n *\n * @private\n * @method _updateFilterPreference\n * @param {Number} userId The current user id.\n * @param {Array} filterList The list of current filter values.\n * @param {Array} preferenceNames The names of the preferences to update\n * @return {Promise} Resolved when all the preferences are updated.\n */\n GradingNavigation.prototype._updateFilterPreferences = function(userId, filterList, preferenceNames) {\n var preferences = [],\n i = 0;\n\n if (filterList.length == 0 || this._firstLoadUsers) {\n // Nothing to update.\n var deferred = $.Deferred();\n deferred.resolve();\n return deferred;\n }\n // General filter.\n // Set the user preferences to the current filters.\n for (i = 0; i < filterList.length; i++) {\n var newValue = filterList[i];\n if (newValue == 'none') {\n newValue = '';\n }\n\n preferences.push({\n userid: userId,\n name: preferenceNames[i],\n value: newValue\n });\n }\n\n return UserRepository.setUserPreferences(preferences);\n };\n /**\n * Turn a filter on or off.\n *\n * @private\n * @method _filterChanged\n */\n GradingNavigation.prototype._filterChanged = function() {\n // There are 3 types of filter right now.\n var filterPanel = this._region.find('[data-region=\"configure-filters\"]');\n var filters = filterPanel.find('select');\n var preferenceNames = [];\n\n this._filters = [];\n filters.each(function(idx, ele) {\n var element = $(ele);\n this._filters.push(element.val());\n preferenceNames.push('assign_' + element.prop('name'));\n }.bind(this));\n\n // Update the active filter string.\n var filterlist = [];\n filterPanel.find('option:checked').each(function(idx, ele) {\n filterlist[filterlist.length] = $(ele).text();\n });\n if (filterlist.length) {\n this._region.find('[data-region=\"user-filters\"] span').text(filterlist.join(', '));\n } else {\n str.get_string('nofilters', 'mod_assign').done(function(s) {\n this._region.find('[data-region=\"user-filters\"] span').text(s);\n }.bind(this)).fail(notification.exception);\n }\n\n var select = this._region.find('[data-action=change-user]');\n var currentUserID = select.data('currentuserid');\n this._updateFilterPreferences(currentUserID, this._filters, preferenceNames).then(function() {\n // Reload the list of users to apply the new filters.\n if (!this._loadAllUsers()) {\n var userid = parseInt(select.attr('data-selected'));\n let foundIndex = null;\n // Search the returned users for the current selection.\n $.each(this._filteredUsers, function(index, user) {\n if (userid == user.id) {\n foundIndex = index;\n }\n });\n\n if (this._filteredUsers.length && foundIndex !== null) {\n this._selectUserById(this._filteredUsers[foundIndex].id);\n } else {\n this._selectNoUser();\n }\n\n }\n }.bind(this)).catch(notification.exception);\n this._refreshCount();\n };\n\n /**\n * Select no users, because no users match the filters.\n *\n * @private\n * @method _selectNoUser\n */\n GradingNavigation.prototype._selectNoUser = function() {\n // Detect unsaved changes, and offer to save them - otherwise change user right now.\n if (this._isLoading) {\n return;\n }\n if (checker.checkFormForChanges('[data-region=\"grade-panel\"] .gradeform')) {\n // Form has changes, so we need to confirm before switching users.\n str.get_strings([\n {key: 'unsavedchanges', component: 'mod_assign'},\n {key: 'unsavedchangesquestion', component: 'mod_assign'},\n {key: 'saveandcontinue', component: 'mod_assign'},\n {key: 'cancel', component: 'core'},\n ]).done(function(strs) {\n notification.confirm(strs[0], strs[1], strs[2], strs[3], function() {\n $(document).trigger('save-changes', -1);\n });\n });\n } else {\n $(document).trigger('user-changed', -1);\n }\n };\n\n /**\n * Select the specified user by id.\n *\n * @private\n * @method _selectUserById\n * @param {Number} userid\n */\n GradingNavigation.prototype._selectUserById = function(userid) {\n var select = this._region.find('[data-action=change-user]');\n var useridnumber = parseInt(userid, 10);\n\n // Detect unsaved changes, and offer to save them - otherwise change user right now.\n if (this._isLoading) {\n return;\n }\n if (checker.checkFormForChanges('[data-region=\"grade-panel\"] .gradeform')) {\n // Form has changes, so we need to confirm before switching users.\n str.get_strings([\n {key: 'unsavedchanges', component: 'mod_assign'},\n {key: 'unsavedchangesquestion', component: 'mod_assign'},\n {key: 'saveandcontinue', component: 'mod_assign'},\n {key: 'cancel', component: 'core'},\n ]).done(function(strs) {\n notification.confirm(strs[0], strs[1], strs[2], strs[3], function() {\n $(document).trigger('save-changes', useridnumber);\n });\n });\n } else {\n select.attr('data-selected', userid);\n\n // If we have some filtered users, and userid is specified, then trigger change.\n if (this._filteredUsers.length > 0 && !isNaN(useridnumber) && useridnumber > 0) {\n $(document).trigger('user-changed', useridnumber);\n }\n }\n };\n\n /**\n * Expand or collapse the filter config panel.\n *\n * @private\n * @method _toggleExpandFilters\n * @param {Event} event\n */\n GradingNavigation.prototype._toggleExpandFilters = function(event) {\n event.preventDefault();\n var toggleLink = $(event.target).closest('[data-region=\"user-filters\"]');\n var expanded = toggleLink.attr('aria-expanded') == 'true';\n var configPanel = $(document.getElementById(toggleLink.attr('aria-controls')));\n\n if (expanded) {\n configPanel.hide();\n configPanel.attr('aria-hidden', 'true');\n toggleLink.attr('aria-expanded', 'false');\n $(document).unbind('click.mod_assign_grading_navigation');\n } else {\n configPanel.css('display', 'inline-block');\n configPanel.attr('aria-hidden', 'false');\n toggleLink.attr('aria-expanded', 'true');\n event.stopPropagation();\n $(document).on('click.mod_assign_grading_navigation', this._checkClickOutsideConfigureFilters.bind(this));\n }\n };\n\n /**\n * Reset table preferences.\n *\n * @private\n * @method _toggleResetTable\n */\n GradingNavigation.prototype._toggleResetTable = function() {\n let url = new URL(window.location);\n url.searchParams.set('treset', '1');\n window.location.href = url;\n };\n\n /**\n * Change to the previous user in the grading list.\n *\n * @private\n * @method _handlePreviousUser\n * @param {Event} e\n */\n GradingNavigation.prototype._handlePreviousUser = function(e) {\n e.preventDefault();\n var select = this._region.find('[data-action=change-user]');\n var currentUserId = select.attr('data-selected');\n var i = 0;\n var currentIndex = 0;\n\n for (i = 0; i < this._filteredUsers.length; i++) {\n if (this._filteredUsers[i].id == currentUserId) {\n currentIndex = i;\n break;\n }\n }\n\n var count = this._filteredUsers.length;\n var newIndex = (currentIndex - 1);\n if (newIndex < 0) {\n newIndex = count - 1;\n }\n\n if (count) {\n this._selectUserById(this._filteredUsers[newIndex].id);\n }\n };\n\n /**\n * Change to the next user in the grading list.\n *\n * @param {Event} e\n * @param {Boolean} saved Has the form already been saved? Skips checking for changes if true.\n */\n GradingNavigation.prototype._handleNextUser = function(e, saved) {\n e.preventDefault();\n var select = this._region.find('[data-action=change-user]');\n var currentUserId = select.attr('data-selected');\n var i = 0;\n var currentIndex = 0;\n\n for (i = 0; i < this._filteredUsers.length; i++) {\n if (this._filteredUsers[i].id == currentUserId) {\n currentIndex = i;\n break;\n }\n }\n\n var count = this._filteredUsers.length;\n var newIndex = (currentIndex + 1) % count;\n\n if (saved && count) {\n // If we've already saved the grade, skip checking if we've made any changes.\n var userid = this._filteredUsers[newIndex].id;\n var useridnumber = parseInt(userid, 10);\n select.attr('data-selected', userid);\n if (!isNaN(useridnumber) && useridnumber > 0) {\n $(document).trigger('user-changed', userid);\n }\n } else if (count) {\n this._selectUserById(this._filteredUsers[newIndex].id);\n }\n };\n\n /**\n * Set count string. This method only sets the value for the last time it was ever called to deal\n * with promises that return in a non-predictable order.\n *\n * @private\n * @method _setCountString\n * @param {Number} x\n * @param {Number} y\n */\n GradingNavigation.prototype._setCountString = function(x, y) {\n var updateNumber = 0;\n this._lastXofYUpdate++;\n updateNumber = this._lastXofYUpdate;\n\n var param = {x: x, y: y};\n str.get_string('xofy', 'mod_assign', param).done(function(s) {\n if (updateNumber == this._lastXofYUpdate) {\n this._region.find('[data-region=\"user-count-summary\"]').text(s);\n }\n }.bind(this)).fail(notification.exception);\n };\n\n /**\n * Rebuild the x of y string.\n *\n * @private\n * @method _refreshCount\n */\n GradingNavigation.prototype._refreshCount = function() {\n var select = this._region.find('[data-action=change-user]');\n var userid = select.attr('data-selected');\n var i = 0;\n var currentIndex = 0;\n\n if (isNaN(userid) || userid <= 0) {\n this._region.find('[data-region=\"user-count\"]').hide();\n } else {\n this._region.find('[data-region=\"user-count\"]').show();\n\n for (i = 0; i < this._filteredUsers.length; i++) {\n if (this._filteredUsers[i].id == userid) {\n currentIndex = i;\n break;\n }\n }\n var count = this._filteredUsers.length;\n if (count) {\n currentIndex += 1;\n }\n this._setCountString(currentIndex, count);\n // Update window URL\n if (currentIndex > 0) {\n var url = new URL(window.location);\n if (parseInt(url.searchParams.get('blindid')) > 0) {\n var newid = this._filteredUsers[currentIndex - 1].recordid;\n url.searchParams.set('blindid', newid);\n } else {\n url.searchParams.set('userid', userid);\n }\n // We do this so a browser refresh will return to the same user.\n window.history.replaceState({}, \"\", url);\n }\n }\n };\n\n /**\n * Respond to a user-changed event by updating the selector.\n *\n * @private\n * @method _refreshSelector\n * @param {Event} event\n * @param {String} userid\n */\n GradingNavigation.prototype._refreshSelector = function(event, userid) {\n var select = this._region.find('[data-action=change-user]');\n userid = parseInt(userid, 10);\n\n if (!isNaN(userid) && userid > 0) {\n select.attr('data-selected', userid);\n }\n this._refreshCount();\n };\n\n /**\n * Trigger the next user event depending on the number of filtered users\n *\n * @private\n * @method _triggerNextUserEvent\n */\n GradingNavigation.prototype._triggerNextUserEvent = function() {\n if (this._filteredUsers.length > 1) {\n $(document).trigger('next-user', {nextUserId: null, nextUser: true});\n } else {\n $(document).trigger('next-user', {nextUser: false});\n }\n };\n\n /**\n * Change to a different user in the grading list.\n *\n * @private\n * @method _handleChangeUser\n */\n GradingNavigation.prototype._handleChangeUser = function() {\n var select = this._region.find('[data-action=change-user]');\n var userid = parseInt(select.val(), 10);\n\n if (this._isLoading) {\n return;\n }\n if (checker.checkFormForChanges('[data-region=\"grade-panel\"] .gradeform')) {\n // Form has changes, so we need to confirm before switching users.\n str.get_strings([\n {key: 'unsavedchanges', component: 'mod_assign'},\n {key: 'unsavedchangesquestion', component: 'mod_assign'},\n {key: 'saveandcontinue', component: 'mod_assign'},\n {key: 'cancel', component: 'core'},\n ]).done(function(strs) {\n notification.confirm(strs[0], strs[1], strs[2], strs[3], function() {\n $(document).trigger('save-changes', userid);\n });\n });\n } else {\n if (!isNaN(userid) && userid > 0) {\n select.attr('data-selected', userid);\n\n $(document).trigger('user-changed', userid);\n }\n }\n };\n\n return GradingNavigation;\n});\n"],"names":["define","$","notification","str","autocomplete","ajax","UserRepository","checker","GradingNavigation","selector","_regionSelector","_region","_filters","_users","_filteredUsers","_lastXofYUpdate","_firstLoadUsers","url","URL","window","location","parseInt","searchParams","get","delete","history","replaceState","_loadAllUsers","find","on","this","_handlePreviousUser","bind","_handleNextUser","_handleChangeUser","_toggleExpandFilters","_toggleResetTable","document","_refreshSelector","toggleLink","getElementById","attr","_filterChanged","userid","data","_selectUserById","get_string","done","s","enhance","fail","exception","_isLoading","prototype","_lastFilters","select","assignmentid","groupid","filterPanel","filter","val","workflowFilter","markerFilter","call","methodname","args","assignid","onlyids","tablesort","_usersLoaded","users","length","trigger","_selectNoUser","_triggerNextUserEvent","_checkClickOutsideConfigureFilters","event","configPanel","is","target","has","hide","unbind","_updateFilterPreferences","userId","filterList","preferenceNames","preferences","i","deferred","Deferred","resolve","newValue","push","name","value","setUserPreferences","filters","each","idx","ele","element","prop","filterlist","text","join","currentUserID","then","foundIndex","index","user","id","catch","_refreshCount","checkFormForChanges","get_strings","key","component","strs","confirm","useridnumber","isNaN","preventDefault","closest","expanded","css","stopPropagation","set","href","e","currentUserId","currentIndex","count","newIndex","saved","_setCountString","x","y","updateNumber","param","show","newid","recordid","nextUserId","nextUser"],"mappings":";;;;;;;;AAuBAA,uCAAO,CAAC,SAAU,oBAAqB,WAAY,yBAC3C,YAAa,uBAAwB,2CACtC,SAASC,EAAGC,aAAcC,IAAKC,aAAcC,KAAMC,eAAgBC,aAQlEC,kBAAoB,SAASC,eACxBC,gBAAkBD,cAClBE,QAAUV,EAAEQ,eACZG,SAAW,QACXC,OAAS,QACTC,eAAiB,QACjBC,gBAAkB,OAClBC,iBAAkB,MAEnBC,IAAM,IAAIC,IAAIC,OAAOC,UACrBC,SAASJ,IAAIK,aAAaC,IAAI,WAAa,IAG3CN,IAAIK,aAAaE,OAAO,UACxBL,OAAOM,QAAQC,aAAa,GAAI,GAAIT,WAInCU,qBAKAhB,QAAQiB,KAAK,iCAAiCC,GAAG,QAASC,KAAKC,oBAAoBC,KAAKF,YACxFnB,QAAQiB,KAAK,6BAA6BC,GAAG,QAASC,KAAKG,gBAAgBD,KAAKF,YAChFnB,QAAQiB,KAAK,+BAA+BC,GAAG,SAAUC,KAAKI,kBAAkBF,KAAKF,YACrFnB,QAAQiB,KAAK,gCAAgCC,GAAG,QAASC,KAAKK,qBAAqBH,KAAKF,YACxFnB,QAAQiB,KAAK,mCAAmCC,GAAG,QAASC,KAAKM,kBAAkBJ,QAExF/B,EAAEoC,UAAUR,GAAG,eAAgBC,KAAKQ,iBAAiBN,KAAKF,OAC1D7B,EAAEoC,UAAUR,GAAG,wBAAyBC,KAAKG,gBAAgBD,KAAKF,WAG9DS,WAAaT,KAAKnB,QAAQiB,KAAK,gCACjB3B,EAAEoC,SAASG,eAAeD,WAAWE,KAAK,mBAEhDZ,GAAG,SAAU,SAAUC,KAAKY,eAAeV,KAAKF,WAExDa,OAAS1C,EAAE,4CAA4C2C,KAAK,gBAC5DD,aACKE,gBAAgBF,QAGzBxC,IAAI2C,WAAW,aAAc,cAAcC,MAAK,SAASC,GACjD5C,aAAa6C,QAAQ,6BAA6B,EAAO,kCAAmCD,MAElGE,KAAKhD,aAAaiD,WAEpBlD,EAAEoC,UAAUL,KAAK,qBAAsB,gBAC9BoB,YAAa,GACpBpB,KAAKF,OACP7B,EAAEoC,UAAUL,KAAK,sBAAuB,gBAC/BoB,YAAa,GACpBpB,KAAKF,eAIXtB,kBAAkB6C,UAAUD,YAAa,EAGzC5C,kBAAkB6C,UAAU3C,gBAAkB,KAG9CF,kBAAkB6C,UAAUzC,SAAW,KAGvCJ,kBAAkB6C,UAAUxC,OAAS,KAGrCL,kBAAkB6C,UAAU1C,QAAU,KAGtCH,kBAAkB6C,UAAUC,aAAe,GAS3C9C,kBAAkB6C,UAAU1B,cAAgB,eACpC4B,OAASzB,KAAKnB,QAAQiB,KAAK,6BAC3B4B,aAAeD,OAAOd,KAAK,qBAC3BgB,QAAUF,OAAOd,KAAK,gBAEtBiB,YAAc5B,KAAKnB,QAAQiB,KAAK,qCAChC+B,OAASD,YAAY9B,KAAK,yBAAyBgC,MACnDC,eAAiBH,YAAY9B,KAAK,iCAClCiC,iBACAF,QAAU,IAAME,eAAeD,WAE/BE,aAAeJ,YAAY9B,KAAK,sCAChCkC,eACAH,QAAU,IAAMG,aAAaF,OAG7B9B,KAAKwB,cAAgBK,cAGpBL,aAAeK,OAEpBtD,KAAK0D,KAAK,CAAC,CACPC,WAAY,+BACZC,KAAM,CAACC,SAAUV,aAAcC,QAASA,QAASE,OAAQ,GAAIQ,SAAS,EAAMC,WAAW,GACvFrB,KAAMjB,KAAKuC,aAAarC,KAAKF,MAC7BoB,KAAMhD,aAAaiD,cAEhB,IAUX3C,kBAAkB6C,UAAUgB,aAAe,SAASC,eAC3CtD,iBAAkB,OAClBF,eAAiBgB,KAAKjB,OAASyD,MAChCxC,KAAKjB,OAAO0D,OAAQ,KAEhBhC,WAAaT,KAAKnB,QAAQiB,KAAK,gCACjB3B,EAAEoC,SAASG,eAAeD,WAAWE,KAAK,mBAEhDb,KAAK,yBAAyB4C,QAAQ,oBAE7CC,qBAEJC,yBAUTlE,kBAAkB6C,UAAUsB,mCAAqC,SAASC,WAClEC,YAAc/C,KAAKnB,QAAQiB,KAAK,yCAE/BiD,YAAYC,GAAGF,MAAMG,SAAoD,IAAzCF,YAAYG,IAAIJ,MAAMG,QAAQR,OAAc,KACzEhC,WAAaT,KAAKnB,QAAQiB,KAAK,gCAEnCiD,YAAYI,OACZJ,YAAYpC,KAAK,cAAe,QAChCF,WAAWE,KAAK,gBAAiB,SACjCxC,EAAEoC,UAAU6C,OAAO,yCAc3B1E,kBAAkB6C,UAAU8B,yBAA2B,SAASC,OAAQC,WAAYC,qBAC5EC,YAAc,GACdC,EAAI,KAEiB,GAArBH,WAAWd,QAAezC,KAAKd,gBAAiB,KAE5CyE,SAAWxF,EAAEyF,kBACjBD,SAASE,UACFF,aAIND,EAAI,EAAGA,EAAIH,WAAWd,OAAQiB,IAAK,KAChCI,SAAWP,WAAWG,GACV,QAAZI,WACAA,SAAW,IAGfL,YAAYM,KAAK,CACblD,OAAQyC,OACRU,KAAMR,gBAAgBE,GACtBO,MAAOH,kBAIRtF,eAAe0F,mBAAmBT,cAQ7C/E,kBAAkB6C,UAAUX,eAAiB,eAErCgB,YAAc5B,KAAKnB,QAAQiB,KAAK,qCAChCqE,QAAUvC,YAAY9B,KAAK,UAC3B0D,gBAAkB,QAEjB1E,SAAW,GAChBqF,QAAQC,KAAK,SAASC,IAAKC,SACnBC,QAAUpG,EAAEmG,UACXxF,SAASiF,KAAKQ,QAAQzC,OAC3B0B,gBAAgBO,KAAK,UAAYQ,QAAQC,KAAK,UAChDtE,KAAKF,WAGHyE,WAAa,GACjB7C,YAAY9B,KAAK,kBAAkBsE,MAAK,SAASC,IAAKC,KAClDG,WAAWA,WAAWhC,QAAUtE,EAAEmG,KAAKI,UAEvCD,WAAWhC,YACN5D,QAAQiB,KAAK,qCAAqC4E,KAAKD,WAAWE,KAAK,OAE5EtG,IAAI2C,WAAW,YAAa,cAAcC,KAAK,SAASC,QAC/CrC,QAAQiB,KAAK,qCAAqC4E,KAAKxD,IAC9DhB,KAAKF,OAAOoB,KAAKhD,aAAaiD,eAGhCI,OAASzB,KAAKnB,QAAQiB,KAAK,6BAC3B8E,cAAgBnD,OAAOX,KAAK,sBAC3BuC,yBAAyBuB,cAAe5E,KAAKlB,SAAU0E,iBAAiBqB,KAAK,eAEzE7E,KAAKH,gBAAiB,KACnBgB,OAAStB,SAASkC,OAAOd,KAAK,sBAC9BmE,WAAa,KAEjB3G,EAAEiG,KAAKpE,KAAKhB,gBAAgB,SAAS+F,MAAOC,MACpCnE,QAAUmE,KAAKC,KACfH,WAAaC,UAIjB/E,KAAKhB,eAAeyD,QAAyB,OAAfqC,gBACzB/D,gBAAgBf,KAAKhB,eAAe8F,YAAYG,SAEhDtC,kBAIfzC,KAAKF,OAAOkF,MAAM9G,aAAaiD,gBAC5B8D,iBASTzG,kBAAkB6C,UAAUoB,cAAgB,WAEpC3C,KAAKsB,aAGL7C,QAAQ2G,oBAAoB,0CAE5B/G,IAAIgH,YAAY,CACZ,CAACC,IAAK,iBAAkBC,UAAW,cACnC,CAACD,IAAK,yBAA0BC,UAAW,cAC3C,CAACD,IAAK,kBAAmBC,UAAW,cACpC,CAACD,IAAK,SAAUC,UAAW,UAC5BtE,MAAK,SAASuE,MACbpH,aAAaqH,QAAQD,KAAK,GAAIA,KAAK,GAAIA,KAAK,GAAIA,KAAK,IAAI,WACrDrH,EAAEoC,UAAUmC,QAAQ,gBAAiB,SAI7CvE,EAAEoC,UAAUmC,QAAQ,gBAAiB,KAW7ChE,kBAAkB6C,UAAUR,gBAAkB,SAASF,YAC/CY,OAASzB,KAAKnB,QAAQiB,KAAK,6BAC3B4F,aAAenG,SAASsB,OAAQ,IAGhCb,KAAKsB,aAGL7C,QAAQ2G,oBAAoB,0CAE5B/G,IAAIgH,YAAY,CACZ,CAACC,IAAK,iBAAkBC,UAAW,cACnC,CAACD,IAAK,yBAA0BC,UAAW,cAC3C,CAACD,IAAK,kBAAmBC,UAAW,cACpC,CAACD,IAAK,SAAUC,UAAW,UAC5BtE,MAAK,SAASuE,MACbpH,aAAaqH,QAAQD,KAAK,GAAIA,KAAK,GAAIA,KAAK,GAAIA,KAAK,IAAI,WACrDrH,EAAEoC,UAAUmC,QAAQ,eAAgBgD,qBAI5CjE,OAAOd,KAAK,gBAAiBE,QAGzBb,KAAKhB,eAAeyD,OAAS,IAAMkD,MAAMD,eAAiBA,aAAe,GACzEvH,EAAEoC,UAAUmC,QAAQ,eAAgBgD,iBAYhDhH,kBAAkB6C,UAAUlB,qBAAuB,SAASyC,OACxDA,MAAM8C,qBACFnF,WAAatC,EAAE2E,MAAMG,QAAQ4C,QAAQ,gCACrCC,SAA+C,QAApCrF,WAAWE,KAAK,iBAC3BoC,YAAc5E,EAAEoC,SAASG,eAAeD,WAAWE,KAAK,mBAExDmF,UACA/C,YAAYI,OACZJ,YAAYpC,KAAK,cAAe,QAChCF,WAAWE,KAAK,gBAAiB,SACjCxC,EAAEoC,UAAU6C,OAAO,yCAEnBL,YAAYgD,IAAI,UAAW,gBAC3BhD,YAAYpC,KAAK,cAAe,SAChCF,WAAWE,KAAK,gBAAiB,QACjCmC,MAAMkD,kBACN7H,EAAEoC,UAAUR,GAAG,sCAAuCC,KAAK6C,mCAAmC3C,KAAKF,SAU3GtB,kBAAkB6C,UAAUjB,kBAAoB,eACxCnB,IAAM,IAAIC,IAAIC,OAAOC,UACzBH,IAAIK,aAAayG,IAAI,SAAU,KAC/B5G,OAAOC,SAAS4G,KAAO/G,KAU3BT,kBAAkB6C,UAAUtB,oBAAsB,SAASkG,GACvDA,EAAEP,qBAEEQ,cADSpG,KAAKnB,QAAQiB,KAAK,6BACJa,KAAK,iBAC5B+C,EAAI,EACJ2C,aAAe,MAEd3C,EAAI,EAAGA,EAAI1D,KAAKhB,eAAeyD,OAAQiB,OACpC1D,KAAKhB,eAAe0E,GAAGuB,IAAMmB,cAAe,CAC5CC,aAAe3C,YAKnB4C,MAAQtG,KAAKhB,eAAeyD,OAC5B8D,SAAYF,aAAe,EAC3BE,SAAW,IACXA,SAAWD,MAAQ,GAGnBA,YACKvF,gBAAgBf,KAAKhB,eAAeuH,UAAUtB,KAU3DvG,kBAAkB6C,UAAUpB,gBAAkB,SAASgG,EAAGK,OACtDL,EAAEP,qBACEnE,OAASzB,KAAKnB,QAAQiB,KAAK,6BAC3BsG,cAAgB3E,OAAOd,KAAK,iBAC5B+C,EAAI,EACJ2C,aAAe,MAEd3C,EAAI,EAAGA,EAAI1D,KAAKhB,eAAeyD,OAAQiB,OACpC1D,KAAKhB,eAAe0E,GAAGuB,IAAMmB,cAAe,CAC5CC,aAAe3C,YAKnB4C,MAAQtG,KAAKhB,eAAeyD,OAC5B8D,UAAYF,aAAe,GAAKC,SAEhCE,OAASF,MAAO,KAEZzF,OAASb,KAAKhB,eAAeuH,UAAUtB,GACvCS,aAAenG,SAASsB,OAAQ,IACpCY,OAAOd,KAAK,gBAAiBE,SACxB8E,MAAMD,eAAiBA,aAAe,GACvCvH,EAAEoC,UAAUmC,QAAQ,eAAgB7B,aAEjCyF,YACFvF,gBAAgBf,KAAKhB,eAAeuH,UAAUtB,KAa3DvG,kBAAkB6C,UAAUkF,gBAAkB,SAASC,EAAGC,OAClDC,kBACC3H,kBACL2H,aAAe5G,KAAKf,oBAEhB4H,MAAQ,CAACH,EAAGA,EAAGC,EAAGA,GACtBtI,IAAI2C,WAAW,OAAQ,aAAc6F,OAAO5F,KAAK,SAASC,GAClD0F,cAAgB5G,KAAKf,sBAChBJ,QAAQiB,KAAK,sCAAsC4E,KAAKxD,IAEnEhB,KAAKF,OAAOoB,KAAKhD,aAAaiD,YASpC3C,kBAAkB6C,UAAU4D,cAAgB,eAEpCtE,OADSb,KAAKnB,QAAQiB,KAAK,6BACXa,KAAK,iBACrB+C,EAAI,EACJ2C,aAAe,KAEfV,MAAM9E,SAAWA,QAAU,OACtBhC,QAAQiB,KAAK,8BAA8BqD,WAC7C,UACEtE,QAAQiB,KAAK,8BAA8BgH,OAE3CpD,EAAI,EAAGA,EAAI1D,KAAKhB,eAAeyD,OAAQiB,OACpC1D,KAAKhB,eAAe0E,GAAGuB,IAAMpE,OAAQ,CACrCwF,aAAe3C,YAInB4C,MAAQtG,KAAKhB,eAAeyD,UAC5B6D,QACAD,cAAgB,QAEfI,gBAAgBJ,aAAcC,OAE/BD,aAAe,EAAG,KACdlH,IAAM,IAAIC,IAAIC,OAAOC,aACrBC,SAASJ,IAAIK,aAAaC,IAAI,YAAc,EAAG,KAC3CsH,MAAQ/G,KAAKhB,eAAeqH,aAAe,GAAGW,SAClD7H,IAAIK,aAAayG,IAAI,UAAWc,YAEhC5H,IAAIK,aAAayG,IAAI,SAAUpF,QAGnCxB,OAAOM,QAAQC,aAAa,GAAI,GAAIT,QAahDT,kBAAkB6C,UAAUf,iBAAmB,SAASsC,MAAOjC,YACvDY,OAASzB,KAAKnB,QAAQiB,KAAK,6BAC/Be,OAAStB,SAASsB,OAAQ,KAErB8E,MAAM9E,SAAWA,OAAS,GAC3BY,OAAOd,KAAK,gBAAiBE,aAE5BsE,iBASTzG,kBAAkB6C,UAAUqB,sBAAwB,WAC5C5C,KAAKhB,eAAeyD,OAAS,EAC7BtE,EAAEoC,UAAUmC,QAAQ,YAAa,CAACuE,WAAY,KAAMC,UAAU,IAE9D/I,EAAEoC,UAAUmC,QAAQ,YAAa,CAACwE,UAAU,KAUpDxI,kBAAkB6C,UAAUnB,kBAAoB,eACxCqB,OAASzB,KAAKnB,QAAQiB,KAAK,6BAC3Be,OAAStB,SAASkC,OAAOK,MAAO,IAEhC9B,KAAKsB,aAGL7C,QAAQ2G,oBAAoB,0CAE5B/G,IAAIgH,YAAY,CACZ,CAACC,IAAK,iBAAkBC,UAAW,cACnC,CAACD,IAAK,yBAA0BC,UAAW,cAC3C,CAACD,IAAK,kBAAmBC,UAAW,cACpC,CAACD,IAAK,SAAUC,UAAW,UAC5BtE,MAAK,SAASuE,MACbpH,aAAaqH,QAAQD,KAAK,GAAIA,KAAK,GAAIA,KAAK,GAAIA,KAAK,IAAI,WACrDrH,EAAEoC,UAAUmC,QAAQ,eAAgB7B,eAIvC8E,MAAM9E,SAAWA,OAAS,IAC3BY,OAAOd,KAAK,gBAAiBE,QAE7B1C,EAAEoC,UAAUmC,QAAQ,eAAgB7B,WAKzCnC"}
\ No newline at end of file
diff --git a/mod/assign/amd/src/grading_navigation.js b/mod/assign/amd/src/grading_navigation.js
index 6dadc557e99..2c79a91d2a5 100644
--- a/mod/assign/amd/src/grading_navigation.js
+++ b/mod/assign/amd/src/grading_navigation.js
@@ -255,7 +255,7 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
var select = this._region.find('[data-action=change-user]');
var currentUserID = select.data('currentuserid');
- this._updateFilterPreferences(currentUserID, this._filters, preferenceNames).done(function() {
+ this._updateFilterPreferences(currentUserID, this._filters, preferenceNames).then(function() {
// Reload the list of users to apply the new filters.
if (!this._loadAllUsers()) {
var userid = parseInt(select.attr('data-selected'));
@@ -274,7 +274,7 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
}
}
- }.bind(this)).fail(notification.exception);
+ }.bind(this)).catch(notification.exception);
this._refreshCount();
};
diff --git a/r.php b/r.php
new file mode 100644
index 00000000000..2d59ba587f4
--- /dev/null
+++ b/r.php
@@ -0,0 +1,56 @@
+.
+
+/**
+ * Routing support for Moodle.
+ *
+ * @package core
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+// phpcs:disable moodle.Files.MoodleInternal.MoodleInternalGlobalState
+
+// Load the bootstrap and perform the bare early setup.
+// This just sets up the autoloaders, basic configuration, and so on.
+define('ABORT_AFTER_CONFIG', true);
+require_once('config.php');
+
+// Load the rest of the setup.
+require_once("{$CFG->libdir}/setuplib.php"); // Functions that MUST be loaded first.
+
+// Load up standard libraries.
+require_once("{$CFG->libdir}/filterlib.php"); // Functions for filtering test as it is output.
+require_once("{$CFG->libdir}/ajax/ajaxlib.php"); // Functions for managing our use of JavaScript and YUI.
+require_once("{$CFG->libdir}/weblib.php"); // Functions relating to HTTP and content.
+require_once("{$CFG->libdir}/outputlib.php"); // Functions for generating output.
+require_once("{$CFG->libdir}/navigationlib.php"); // Class for generating Navigation structure.
+require_once("{$CFG->libdir}/dmllib.php"); // Database access.
+require_once("{$CFG->libdir}/datalib.php"); // Legacy lib with a big-mix of functions.
+require_once("{$CFG->libdir}/accesslib.php"); // Access control functions.
+require_once("{$CFG->libdir}/deprecatedlib.php"); // Deprecated functions included for backward compatibility.
+require_once("{$CFG->libdir}/moodlelib.php"); // Other general-purpose functions.
+require_once("{$CFG->libdir}/enrollib.php"); // Enrolment related functions.
+require_once("{$CFG->libdir}/pagelib.php"); // Library that defines the moodle_page class, used for $PAGE.
+require_once("{$CFG->libdir}/blocklib.php"); // Library for controlling blocks.
+require_once("{$CFG->libdir}/grouplib.php"); // Groups functions.
+require_once("{$CFG->libdir}/sessionlib.php"); // All session and cookie related stuff.
+require_once("{$CFG->libdir}/editorlib.php"); // All text editor related functions and classes.
+require_once("{$CFG->libdir}/messagelib.php"); // Messagelib functions.
+require_once("{$CFG->libdir}/modinfolib.php"); // Cached information on course-module instances.
+require_once("{$CFG->dirroot}/cache/lib.php"); // Cache API.
+
+$router = \core\di::get(\core\router::class);
+$router->serve();
diff --git a/user/amd/build/repository.min.js b/user/amd/build/repository.min.js
index 19b649ff48b..edbf4e9a78e 100644
--- a/user/amd/build/repository.min.js
+++ b/user/amd/build/repository.min.js
@@ -1,3 +1,10 @@
-define("core_user/repository",["exports","core/ajax"],(function(_exports,_ajax){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.unenrolUser=_exports.submitUserEnrolmentForm=_exports.setUserPreferences=_exports.setUserPreference=_exports.sendMessagesToUsers=_exports.getUserPreferences=_exports.getUserPreference=_exports.createNotesForUsers=void 0;_exports.getUserPreference=function(name){let userid=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0;return getUserPreferences(name,userid).then((response=>response.preferences[0].value))};const getUserPreferences=function(){let name=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null,userid=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0;return(0,_ajax.call)([{methodname:"core_user_get_user_preferences",args:{name:name,userid:userid}}])[0]};_exports.getUserPreferences=getUserPreferences;_exports.setUserPreference=function(name){let value=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,userid=arguments.length>2&&void 0!==arguments[2]?arguments[2]:0;return setUserPreferences([{name:name,value:value,userid:userid}])};const setUserPreferences=preferences=>(0,_ajax.call)([{methodname:"core_user_set_user_preferences",args:{preferences:preferences}}])[0];_exports.setUserPreferences=setUserPreferences;_exports.unenrolUser=userEnrolmentId=>(0,_ajax.call)([{methodname:"core_enrol_unenrol_user_enrolment",args:{ueid:userEnrolmentId}}])[0];_exports.submitUserEnrolmentForm=formdata=>(0,_ajax.call)([{methodname:"core_enrol_submit_user_enrolment_form",args:{formdata:formdata}}])[0];_exports.createNotesForUsers=notes=>(0,_ajax.call)([{methodname:"core_notes_create_notes",args:{notes:notes}}])[0];_exports.sendMessagesToUsers=messages=>(0,_ajax.call)([{methodname:"core_message_send_instant_messages",args:{messages:messages}}])[0]}));
+define("core_user/repository",["exports","core/config","core/ajax","core/fetch"],(function(_exports,_config,_ajax,_fetch){var obj;
+/**
+ * Module to handle AJAX interactions.
+ *
+ * @module core_user/repository
+ * @copyright 2020 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.unenrolUser=_exports.submitUserEnrolmentForm=_exports.setUserPreferences=_exports.setUserPreference=_exports.sendMessagesToUsers=_exports.getUserPreferences=_exports.getUserPreference=_exports.createNotesForUsers=void 0,_config=(obj=_config)&&obj.__esModule?obj:{default:obj};const checkUserId=userid=>{if(0!==Number(userid)&&Number(userid)!==_config.default.userId)throw new Error("Invalid user ID: ".concat(userid,". It is only possible to manage preferences for the current user."))},addLegacySavedProperty=(response,preferences)=>{const debugLogger={get:(target,prop,receiver)=>"then"===prop?null:"saved"===prop?(window.console.warn("The saved property is deprecated. Please use the response object directly."),preferences.filter((preference=>target.hasOwnProperty(preference.name))).map((preference=>({name:preference.name,userid:_config.default.userid})))):Reflect.get(target,prop,receiver)};return Promise.resolve(new Proxy(response,debugLogger))};_exports.getUserPreference=function(name){let userid=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0;return getUserPreferences(name,userid).then((response=>response[name]))};const getUserPreferences=function(){let name=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null,userid=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0;checkUserId(userid);const endpoint=["current","preferences"];return name&&endpoint.push(name),(0,_fetch.performGet)("core_user",endpoint.join("/"))};_exports.getUserPreferences=getUserPreferences;_exports.setUserPreference=function(name){let value=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,userid=arguments.length>2&&void 0!==arguments[2]?arguments[2]:0;return checkUserId(userid),(0,_fetch.performPost)("core_user","current/preferences/".concat(name),{body:{value:value}}).then((response=>addLegacySavedProperty(response,[{name:name}])))};_exports.setUserPreferences=preferences=>(preferences.forEach((preference=>checkUserId(preference.userid))),(0,_fetch.performPost)("core_user","current/preferences",{body:{preferences:Object.fromEntries(preferences.map((preference=>[preference.name,preference.value])))}}).then((response=>addLegacySavedProperty(response,preferences))));_exports.unenrolUser=userEnrolmentId=>(0,_ajax.call)([{methodname:"core_enrol_unenrol_user_enrolment",args:{ueid:userEnrolmentId}}])[0];_exports.submitUserEnrolmentForm=formdata=>(0,_ajax.call)([{methodname:"core_enrol_submit_user_enrolment_form",args:{formdata:formdata}}])[0];_exports.createNotesForUsers=notes=>(0,_ajax.call)([{methodname:"core_notes_create_notes",args:{notes:notes}}])[0];_exports.sendMessagesToUsers=messages=>(0,_ajax.call)([{methodname:"core_message_send_instant_messages",args:{messages:messages}}])[0]}));
//# sourceMappingURL=repository.min.js.map
\ No newline at end of file
diff --git a/user/amd/build/repository.min.js.map b/user/amd/build/repository.min.js.map
index b59633bfe53..731768594ea 100644
--- a/user/amd/build/repository.min.js.map
+++ b/user/amd/build/repository.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"repository.min.js","sources":["../src/repository.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Module to handle AJAX interactions.\n *\n * @module core_user/repository\n * @copyright 2020 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {call as fetchMany} from 'core/ajax';\n\n/**\n * Get single user preference\n *\n * @param {String} name Name of the preference\n * @param {Number} userid User ID (defaults to current user)\n * @return {Promise}\n */\nexport const getUserPreference = (name, userid = 0) => {\n return getUserPreferences(name, userid)\n .then(response => response.preferences[0].value);\n};\n\n/**\n * Get multiple user preferences\n *\n * @param {String|null} name Name of the preference (omit if you want to retrieve all)\n * @param {Number} userid User ID (defaults to current user)\n * @return {Promise}\n */\nexport const getUserPreferences = (name = null, userid = 0) => {\n return fetchMany([{\n methodname: 'core_user_get_user_preferences',\n args: {name, userid}\n }])[0];\n};\n\n/**\n * Set single user preference\n *\n * @param {String} name Name of the preference\n * @param {String|null} value Value of the preference (omit if you want to remove the current value)\n * @param {Number} userid User ID (defaults to current user)\n * @return {Promise}\n */\nexport const setUserPreference = (name, value = null, userid = 0) => {\n return setUserPreferences([{name, value, userid}]);\n};\n\n/**\n * Set multiple user preferences\n *\n * @param {Object[]} preferences Array of preferences containing name/value/userid attributes\n * @return {Promise}\n */\nexport const setUserPreferences = (preferences) => {\n return fetchMany([{\n methodname: 'core_user_set_user_preferences',\n args: {preferences}\n }])[0];\n};\n\n/**\n * Unenrol the user with the specified user enrolmentid ID.\n *\n * @param {Number} userEnrolmentId\n * @return {Promise}\n */\nexport const unenrolUser = userEnrolmentId => {\n return fetchMany([{\n methodname: 'core_enrol_unenrol_user_enrolment',\n args: {\n ueid: userEnrolmentId,\n },\n }])[0];\n};\n\n/**\n * Submit the user enrolment form with the specified form data.\n *\n * @param {String} formdata\n * @return {Promise}\n */\nexport const submitUserEnrolmentForm = formdata => {\n return fetchMany([{\n methodname: 'core_enrol_submit_user_enrolment_form',\n args: {\n formdata,\n },\n }])[0];\n};\n\nexport const createNotesForUsers = notes => {\n return fetchMany([{\n methodname: 'core_notes_create_notes',\n args: {\n notes\n }\n }])[0];\n};\n\nexport const sendMessagesToUsers = messages => {\n return fetchMany([{\n methodname: 'core_message_send_instant_messages',\n args: {messages}\n }])[0];\n};\n"],"names":["name","userid","getUserPreferences","then","response","preferences","value","methodname","args","setUserPreferences","userEnrolmentId","ueid","formdata","notes","messages"],"mappings":"wYAgCiC,SAACA,UAAMC,8DAAS,SACtCC,mBAAmBF,KAAMC,QAC3BE,MAAKC,UAAYA,SAASC,YAAY,GAAGC,eAUrCJ,mBAAqB,eAACF,4DAAO,KAAMC,8DAAS,SAC9C,cAAU,CAAC,CACdM,WAAY,iCACZC,KAAM,CAACR,KAAAA,KAAMC,OAAAA,WACb,8EAWyB,SAACD,UAAMM,6DAAQ,KAAML,8DAAS,SACpDQ,mBAAmB,CAAC,CAACT,KAAAA,KAAMM,MAAAA,MAAOL,OAAAA,iBAShCQ,mBAAsBJ,cACxB,cAAU,CAAC,CACdE,WAAY,iCACZC,KAAM,CAACH,YAAAA,gBACP,uEASmBK,kBAChB,cAAU,CAAC,CACdH,WAAY,oCACZC,KAAM,CACFG,KAAMD,oBAEV,oCAS+BE,WAC5B,cAAU,CAAC,CACdL,WAAY,wCACZC,KAAM,CACFI,SAAAA,aAEJ,gCAG2BC,QACxB,cAAU,CAAC,CACdN,WAAY,0BACZC,KAAM,CACFK,MAAAA,UAEJ,gCAG2BC,WACxB,cAAU,CAAC,CACdP,WAAY,qCACZC,KAAM,CAACM,SAAAA,aACP"}
\ No newline at end of file
+{"version":3,"file":"repository.min.js","sources":["../src/repository.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Module to handle AJAX interactions.\n *\n * @module core_user/repository\n * @copyright 2020 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Config from 'core/config';\nimport {call as fetchMany} from 'core/ajax';\nimport {performGet, performPost} from 'core/fetch';\n\nconst checkUserId = (userid) => {\n if (Number(userid) === 0) {\n return;\n }\n if (Number(userid) === Config.userId) {\n return;\n }\n throw new Error(\n `Invalid user ID: ${userid}. It is only possible to manage preferences for the current user.`,\n );\n};\n\n/**\n * Turn the response object into a Proxy object that will log a warning if the saved property is accessed.\n *\n * @param {Object} response\n * @param {Object} preferences The preferences that might be in the response\n * @return {Promise}\n */\nconst addLegacySavedProperty = (response, preferences) => {\n const debugLogger = {\n get(target, prop, receiver) {\n if (prop === 'then') {\n // To proxy a Promise we have to return null when the then key is requested.\n return null;\n }\n if (prop === 'saved') {\n window.console.warn(\n 'The saved property is deprecated. Please use the response object directly.',\n );\n\n return preferences\n .filter((preference) => target.hasOwnProperty(preference.name))\n .map((preference) => ({\n name: preference.name,\n userid: Config.userid,\n }));\n }\n return Reflect.get(target, prop, receiver);\n },\n };\n\n return Promise.resolve(new Proxy(response, debugLogger));\n};\n\n/**\n * Get single user preference\n *\n * @param {String} name Name of the preference\n * @param {Number} userid User ID (defaults to current user)\n * @return {Promise}\n */\nexport const getUserPreference = (name, userid = 0) => getUserPreferences(name, userid)\n .then((response) => response[name]);\n\n/**\n * Get multiple user preferences\n *\n * @param {String|null} name Name of the preference (omit if you want to retrieve all)\n * @param {Number} userid User ID (defaults to current user)\n * @return {Promise>}\n */\nexport const getUserPreferences = (name = null, userid = 0) => {\n checkUserId(userid);\n const endpoint = ['current', 'preferences'];\n\n if (name) {\n endpoint.push(name);\n }\n\n return performGet('core_user', endpoint.join('/'));\n};\n\n/**\n * Set single user preference\n *\n * @param {String} name Name of the preference\n * @param {String|null} value Value of the preference (omit if you want to remove the current value)\n * @param {Number} userid User ID (defaults to current user)\n * @return {Promise}\n */\nexport const setUserPreference = (name, value = null, userid = 0) => {\n checkUserId(userid);\n return performPost(\n 'core_user',\n `current/preferences/${name}`,\n {\n body: {value},\n },\n )\n // Return the result of the fetch call, and also add in the legacy saved property.\n .then((response) => addLegacySavedProperty(response, [{name}]));\n};\n\n/**\n * Set multiple user preferences\n *\n * @param {Object[]} preferences Array of preferences containing name/value/userid attributes\n * @return {Promise}\n */\nexport const setUserPreferences = (preferences) => {\n preferences.forEach((preference) => checkUserId(preference.userid));\n return performPost(\n 'core_user',\n 'current/preferences',\n {\n body: {\n preferences: Object.fromEntries (preferences.map((preference) => ([preference.name, preference.value]))),\n },\n },\n )\n // Return the result of the fetch call, and also add in the legacy saved property.\n .then((response) => addLegacySavedProperty(response, preferences));\n};\n\n/**\n * Unenrol the user with the specified user enrolmentid ID.\n *\n * @param {Number} userEnrolmentId\n * @return {Promise}\n */\nexport const unenrolUser = userEnrolmentId => {\n return fetchMany([{\n methodname: 'core_enrol_unenrol_user_enrolment',\n args: {\n ueid: userEnrolmentId,\n },\n }])[0];\n};\n\n/**\n * Submit the user enrolment form with the specified form data.\n *\n * @param {String} formdata\n * @return {Promise}\n */\nexport const submitUserEnrolmentForm = formdata => {\n return fetchMany([{\n methodname: 'core_enrol_submit_user_enrolment_form',\n args: {\n formdata,\n },\n }])[0];\n};\n\nexport const createNotesForUsers = notes => {\n return fetchMany([{\n methodname: 'core_notes_create_notes',\n args: {\n notes\n }\n }])[0];\n};\n\nexport const sendMessagesToUsers = messages => {\n return fetchMany([{\n methodname: 'core_message_send_instant_messages',\n args: {messages}\n }])[0];\n};\n"],"names":["checkUserId","userid","Number","Config","userId","Error","addLegacySavedProperty","response","preferences","debugLogger","get","target","prop","receiver","window","console","warn","filter","preference","hasOwnProperty","name","map","Reflect","Promise","resolve","Proxy","getUserPreferences","then","endpoint","push","join","value","body","forEach","Object","fromEntries","userEnrolmentId","methodname","args","ueid","formdata","notes","messages"],"mappings":";;;;;;;gWA2BMA,YAAeC,YACM,IAAnBC,OAAOD,SAGPC,OAAOD,UAAYE,gBAAOC,aAGxB,IAAIC,iCACcJ,8EAWtBK,uBAAyB,CAACC,SAAUC,qBAChCC,YAAc,CAChBC,IAAG,CAACC,OAAQC,KAAMC,WACD,SAATD,KAEO,KAEE,UAATA,MACAE,OAAOC,QAAQC,KACX,8EAGGR,YACFS,QAAQC,YAAeP,OAAOQ,eAAeD,WAAWE,QACxDC,KAAKH,cACFE,KAAMF,WAAWE,KACjBnB,OAAQE,gBAAOF,YAGpBqB,QAAQZ,IAAIC,OAAQC,KAAMC,kBAIlCU,QAAQC,QAAQ,IAAIC,MAAMlB,SAAUE,0CAUd,SAACW,UAAMnB,8DAAS,SAAMyB,mBAAmBN,KAAMnB,QAC3E0B,MAAMpB,UAAaA,SAASa,eASpBM,mBAAqB,eAACN,4DAAO,KAAMnB,8DAAS,EACrDD,YAAYC,cACN2B,SAAW,CAAC,UAAW,sBAEzBR,MACAQ,SAASC,KAAKT,OAGX,qBAAW,YAAaQ,SAASE,KAAK,iFAWhB,SAACV,UAAMW,6DAAQ,KAAM9B,8DAAS,SAC3DD,YAAYC,SACL,sBACH,0CACuBmB,MACvB,CACIY,KAAM,CAACD,MAAAA,SAIdJ,MAAMpB,UAAaD,uBAAuBC,SAAU,CAAC,CAACa,KAAAA,uCASxBZ,cAC/BA,YAAYyB,SAASf,YAAelB,YAAYkB,WAAWjB,WACpD,sBACH,YACA,sBACA,CACI+B,KAAM,CACFxB,YAAa0B,OAAOC,YAAa3B,YAAYa,KAAKH,YAAgB,CAACA,WAAWE,KAAMF,WAAWa,aAK1GJ,MAAMpB,UAAaD,uBAAuBC,SAAUC,qCAS9B4B,kBAChB,cAAU,CAAC,CACdC,WAAY,oCACZC,KAAM,CACFC,KAAMH,oBAEV,oCAS+BI,WAC5B,cAAU,CAAC,CACdH,WAAY,wCACZC,KAAM,CACFE,SAAAA,aAEJ,gCAG2BC,QACxB,cAAU,CAAC,CACdJ,WAAY,0BACZC,KAAM,CACFG,MAAAA,UAEJ,gCAG2BC,WACxB,cAAU,CAAC,CACdL,WAAY,qCACZC,KAAM,CAACI,SAAAA,aACP"}
\ No newline at end of file
diff --git a/user/amd/src/repository.js b/user/amd/src/repository.js
index f6c078539a1..1b8b592e72d 100644
--- a/user/amd/src/repository.js
+++ b/user/amd/src/repository.js
@@ -21,7 +21,54 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
+import Config from 'core/config';
import {call as fetchMany} from 'core/ajax';
+import {performGet, performPost} from 'core/fetch';
+
+const checkUserId = (userid) => {
+ if (Number(userid) === 0) {
+ return;
+ }
+ if (Number(userid) === Config.userId) {
+ return;
+ }
+ throw new Error(
+ `Invalid user ID: ${userid}. It is only possible to manage preferences for the current user.`,
+ );
+};
+
+/**
+ * Turn the response object into a Proxy object that will log a warning if the saved property is accessed.
+ *
+ * @param {Object} response
+ * @param {Object} preferences The preferences that might be in the response
+ * @return {Promise}
+ */
+const addLegacySavedProperty = (response, preferences) => {
+ const debugLogger = {
+ get(target, prop, receiver) {
+ if (prop === 'then') {
+ // To proxy a Promise we have to return null when the then key is requested.
+ return null;
+ }
+ if (prop === 'saved') {
+ window.console.warn(
+ 'The saved property is deprecated. Please use the response object directly.',
+ );
+
+ return preferences
+ .filter((preference) => target.hasOwnProperty(preference.name))
+ .map((preference) => ({
+ name: preference.name,
+ userid: Config.userid,
+ }));
+ }
+ return Reflect.get(target, prop, receiver);
+ },
+ };
+
+ return Promise.resolve(new Proxy(response, debugLogger));
+};
/**
* Get single user preference
@@ -30,23 +77,25 @@ import {call as fetchMany} from 'core/ajax';
* @param {Number} userid User ID (defaults to current user)
* @return {Promise}
*/
-export const getUserPreference = (name, userid = 0) => {
- return getUserPreferences(name, userid)
- .then(response => response.preferences[0].value);
-};
+export const getUserPreference = (name, userid = 0) => getUserPreferences(name, userid)
+ .then((response) => response[name]);
/**
* Get multiple user preferences
*
* @param {String|null} name Name of the preference (omit if you want to retrieve all)
* @param {Number} userid User ID (defaults to current user)
- * @return {Promise}
+ * @return {Promise>}
*/
export const getUserPreferences = (name = null, userid = 0) => {
- return fetchMany([{
- methodname: 'core_user_get_user_preferences',
- args: {name, userid}
- }])[0];
+ checkUserId(userid);
+ const endpoint = ['current', 'preferences'];
+
+ if (name) {
+ endpoint.push(name);
+ }
+
+ return performGet('core_user', endpoint.join('/'));
};
/**
@@ -58,7 +107,16 @@ export const getUserPreferences = (name = null, userid = 0) => {
* @return {Promise}
*/
export const setUserPreference = (name, value = null, userid = 0) => {
- return setUserPreferences([{name, value, userid}]);
+ checkUserId(userid);
+ return performPost(
+ 'core_user',
+ `current/preferences/${name}`,
+ {
+ body: {value},
+ },
+ )
+ // Return the result of the fetch call, and also add in the legacy saved property.
+ .then((response) => addLegacySavedProperty(response, [{name}]));
};
/**
@@ -68,10 +126,18 @@ export const setUserPreference = (name, value = null, userid = 0) => {
* @return {Promise}
*/
export const setUserPreferences = (preferences) => {
- return fetchMany([{
- methodname: 'core_user_set_user_preferences',
- args: {preferences}
- }])[0];
+ preferences.forEach((preference) => checkUserId(preference.userid));
+ return performPost(
+ 'core_user',
+ 'current/preferences',
+ {
+ body: {
+ preferences: Object.fromEntries (preferences.map((preference) => ([preference.name, preference.value]))),
+ },
+ },
+ )
+ // Return the result of the fetch call, and also add in the legacy saved property.
+ .then((response) => addLegacySavedProperty(response, preferences));
};
/**
diff --git a/user/classes/route/api/preferences.php b/user/classes/route/api/preferences.php
new file mode 100644
index 00000000000..ff30b37dc90
--- /dev/null
+++ b/user/classes/route/api/preferences.php
@@ -0,0 +1,284 @@
+.
+
+namespace core_user\route\api;
+
+use core\exception\coding_exception;
+use core\exception\invalid_parameter_exception;
+use core\param;
+use core\router\route;
+use core\router\schema\objects\scalar_type;
+use core\router\schema\response\payload_response;
+use core\router\schema\response\content\payload_response_type;
+use core\router\schema\response\response_type;
+use core\user;
+use core_user\route\responses\user_preferences_response;
+use stdClass;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * User preference API handler.
+ *
+ * @package core_user
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+#[route(
+ path: '/{user}/preferences',
+ pathtypes: [
+ new \core\router\parameters\path_user(),
+ ],
+)]
+class preferences {
+ /**
+ * Fetch all user preferences, or a specific user preference.
+ *
+ * @param ResponseInterface $response
+ * @param ServerRequestInterface $request
+ * @param stdClass $user
+ * @param null|string $preference
+ * @return payload_response
+ */
+ #[route(
+ path: '[/{preference}]',
+ title: 'Fetch user preferences',
+ description: 'Fetch one user preference, or all user preferences',
+ pathtypes: [
+ new \core\router\schema\parameters\path_parameter(
+ name: 'preference',
+ type: param::RAW,
+ ),
+ ],
+ responses: [
+ new user_preferences_response(),
+ ],
+ )]
+ public function get_preferences(
+ ResponseInterface $response,
+ ServerRequestInterface $request,
+ stdClass $user,
+ ?string $preference,
+ ): payload_response {
+ $this->check_user($user);
+
+ $result = get_user_preferences(
+ name: $preference,
+ user: $user,
+ );
+
+ if (!is_array($result)) {
+ // Check if we received just one preference.
+ $result = [$preference => $result];
+ }
+
+ return new payload_response($result, $request, $response);
+ }
+
+ /**
+ * Set a set of user preferences.
+ *
+ * @param ResponseInterface $response
+ * @param stdClass $user
+ * @return payload_response
+ */
+ #[route(
+ method: ['POST'],
+ title: 'Set or update multiple user preferences',
+ requestbody: new \core\router\schema\request_body(
+ content: new payload_response_type(
+ schema: new \core\router\schema\objects\schema_object(
+ content: [
+ 'preferences' => new \core\router\schema\objects\array_of_strings(
+ keyparamtype: param::TEXT,
+ valueparamtype: param::RAW,
+ ),
+ ],
+ ),
+ ),
+ ),
+ responses: [
+ new user_preferences_response(),
+ ],
+ )]
+ public function set_preferences(
+ ResponseInterface $response,
+ ServerRequestInterface $request,
+ stdClass $user,
+ ): payload_response {
+ $this->check_user($user);
+
+ $values = $request->getParsedBody();
+ $preferences = $values['preferences'] ?? [];
+
+ foreach ($preferences as $preference => $value) {
+ $this->set_single_preference($user, $preference, $value);
+ }
+
+ $result = array_filter(
+ get_user_preferences(
+ user: $user,
+ ),
+ fn ($preference) => array_key_exists($preference, $preferences),
+ ARRAY_FILTER_USE_KEY,
+ );
+
+ return new payload_response($result, $request, $response);
+ }
+
+ /**
+ * Set a single user preference.
+ *
+ * @param ResponseInterface $response
+ * @param string $themename
+ * @param string $component
+ * @param null|string $identifier
+ * @return response_type
+ */
+ #[route(
+ path: '/{preference}',
+ method: ['POST'],
+ title: 'Set a single user preference',
+ description: 'Set a single user preference',
+ pathtypes: [
+ new \core\router\schema\parameters\path_parameter(
+ name: 'preference',
+ type: param::RAW,
+ ),
+ ],
+ requestbody: new \core\router\schema\request_body(
+ content: new payload_response_type(
+ schema: new \core\router\schema\objects\schema_object(
+ content: [
+ 'value' => new scalar_type(param::RAW),
+ ],
+ ),
+ ),
+ ),
+ responses: [
+ new \core\router\schema\response\response(
+ statuscode: 200,
+ description: 'OK',
+ content: [
+ new \core\router\schema\response\content\json_media_type(
+ schema: new \core\router\schema\objects\array_of_strings(
+ keyparamtype: param::TEXT,
+ valueparamtype: param::RAW,
+ ),
+ examples: [
+ new \core\router\schema\example(
+ name: 'A single preference value',
+ summary: 'A json response containing a single preference',
+ value: [
+ "drawers-open-index" => "1",
+ ],
+ ),
+ ]
+ ),
+ ],
+ ),
+ ],
+ )]
+ public function set_preference(
+ ResponseInterface $response,
+ ServerRequestInterface $request,
+ stdClass $user,
+ ?string $preference,
+ ): response_type {
+ $this->check_user($user);
+
+ $values = $request->getParsedBody();
+ $value = $values['value'] ?? null;
+ $this->set_single_preference($user, $preference, $value);
+
+ return $this->get_preferences($response, $request, $user, $preference);
+ }
+
+ /**
+ * Set a single user preference.
+ *
+ * @param \stdClass $user
+ * @param string $preference
+ * @param mixed $value
+ * @throws \core\exception\access_denied_exception
+ * @throws \invalid_parameter_exception
+ */
+ protected function set_single_preference(
+ stdClass $user,
+ string $preference,
+ mixed $value,
+ ): void {
+ try {
+ $definition = user::get_preference_definition($preference);
+ } catch (coding_exception $e) {
+ throw new invalid_parameter_exception("Invalid preference '$preference'");
+ }
+
+ if (!user::can_edit_preference($preference, $user)) {
+ throw new \core\exception\access_denied_exception('You do not have permission to edit this preference.');
+ }
+
+ if (isset($definition['type'])) {
+ $type = param::from_type($definition['type']);
+ $value = $this->standardise_value($type, $value);
+ }
+
+ $cleanvalue = user::clean_preference($value, $preference);
+ if ($cleanvalue !== $value) {
+ throw new \invalid_parameter_exception("Invalid value for preference '$preference': '{$value}'");
+ }
+ $value = $cleanvalue;
+ set_user_preference($preference, $value, $user->id);
+ }
+
+ /**
+ * Ensure that the requested user meets the requirements.
+ *
+ * @param stdClass $user
+ * @throws invalid_parameter_exception
+ */
+ protected function check_user(stdClass $user): void {
+ global $USER;
+
+ if ($user->id !== $USER->id) {
+ throw new \core\exception\access_denied_exception(
+ 'You do not have permission to view or edit preferences for other users.',
+ );
+ }
+ }
+
+ /**
+ * Standardise value based on type.
+ *
+ * Note: We cannot use \core\param here because we only want to cast some types.
+ * Requests do not have an inherent understanding of anything but strings. We need to be strict on typing of integers and bools.
+ *
+ * @param string param $type
+ * @param mixed $value
+ * @return mixed
+ */
+ protected function standardise_value(param $type, mixed $value): mixed {
+ if (is_numeric($value) || is_bool($value)) {
+ switch ($type) {
+ case param::INT:
+ case param::BOOL:
+ $value = (int) $value;
+ }
+ }
+
+ return $value;
+ }
+}
diff --git a/user/classes/route/responses/user_preferences_response.php b/user/classes/route/responses/user_preferences_response.php
new file mode 100644
index 00000000000..8f5c5f8b72f
--- /dev/null
+++ b/user/classes/route/responses/user_preferences_response.php
@@ -0,0 +1,61 @@
+.
+
+namespace core_user\route\responses;
+
+use core\param;
+use core\router\schema\response\content\payload_response_type;
+
+/**
+ * A standard response for user preferences.
+ *
+ * @package core_user
+ * @copyright 2023 Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class user_preferences_response extends \core\router\schema\response\response {
+ /**
+ * Constructor for a standard user preference response.
+ */
+ public function __construct() {
+ parent::__construct(
+ content: new payload_response_type(
+ schema: new \core\router\schema\objects\array_of_strings(
+ keyparamtype: param::TEXT,
+ valueparamtype: param::RAW,
+ ),
+ examples: [
+ new \core\router\schema\example(
+ name: 'A single preference value',
+ summary: 'A json response containing a single preference',
+ value: [
+ "drawers-open-index" => "1",
+ ],
+ ),
+ new \core\router\schema\example(
+ name: 'A set of preference values',
+ summary: 'A json response containing a set of preferences',
+ value: [
+ "drawers-open-index" => "1",
+ "login_failed_count_since_success" => "1",
+ "coursesectionspreferences_2" => "{\"contentcollapsed\":[]}",
+ ],
+ ),
+ ]
+ ),
+ );
+ }
+}
diff --git a/user/tests/route/api/preferences_test.php b/user/tests/route/api/preferences_test.php
new file mode 100644
index 00000000000..a12ec3f5ab4
--- /dev/null
+++ b/user/tests/route/api/preferences_test.php
@@ -0,0 +1,296 @@
+.
+
+namespace core_user\route\api;
+
+use core\tests\route_testcase;
+use GuzzleHttp\Psr7\Utils;
+
+/**
+ * Tests for user preference API handler.
+ *
+ * @package core_user
+ * @copyright Andrew Lyons
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core_user\route\api\preferences
+ * @covers \core_user\route\responses\user_preferences_response
+ */
+final class preferences_test extends route_testcase {
+ /**
+ * Ensure that preferences returned for a user without login are empty.
+ */
+ public function test_preferences_no_login(): void {
+ $this->add_class_routes_to_route_loader(preferences::class);
+ $response = $this->process_api_request('GET', '/current/preferences');
+
+ $this->assert_valid_response($response);
+ $payload = $this->decode_response($response);
+
+ $this->assertEmpty((array) $payload);
+ }
+
+ /**
+ * Test that the preferences are returned when logged in.
+ */
+ public function test_preferences_returned(): void {
+ $this->resetAfterTest();
+
+ $this->add_class_routes_to_route_loader(preferences::class);
+
+ $this->setAdminUser();
+ set_user_preference('filemanager_recentviewmode', 1);
+
+ $response = $this->process_api_request('GET', '/current/preferences');
+
+ $this->assert_valid_response($response);
+
+ $payload = $this->decode_response($response);
+
+ $this->assertObjectHasProperty('filemanager_recentviewmode', $payload);
+ $this->assertEquals(1, $payload->filemanager_recentviewmode);
+ }
+
+ public function test_preference_returned(): void {
+ $this->resetAfterTest();
+
+ $this->add_class_routes_to_route_loader(preferences::class);
+
+ $this->setAdminUser();
+ set_user_preference('filemanager_recentviewmode', 1);
+
+ $response = $this->process_api_request('GET', '/current/preferences/filemanager_recentviewmode');
+
+ $this->assert_valid_response($response);
+
+ $payload = $this->decode_response($response);
+
+ $this->assertObjectHasProperty('filemanager_recentviewmode', $payload);
+ $this->assertEquals(1, $payload->filemanager_recentviewmode);
+ }
+
+ public function test_preferences_set(): void {
+ $this->resetAfterTest();
+
+ $this->add_class_routes_to_route_loader(preferences::class);
+
+ $this->setAdminUser();
+
+ $response = $this->process_api_request(
+ 'POST',
+ '/current/preferences',
+ body: Utils::streamFor(json_encode([
+ 'preferences' => [
+ 'filemanager_recentviewmode' => 2,
+ 'drawer-open-index' => 1,
+ ],
+ ])),
+ );
+
+ $this->assert_valid_response($response);
+
+ // Check that the response contained the updtaed parameter.
+ $payload = (object) $this->decode_response($response);
+ $this->assertObjectHasProperty('filemanager_recentviewmode', $payload);
+ $this->assertObjectHasProperty('drawer-open-index', $payload);
+ $this->assertEquals(2, $payload->filemanager_recentviewmode);
+
+ // Check that the preference was updated.
+ $this->assertEquals(2, get_user_preferences('filemanager_recentviewmode'));
+ $this->assertEquals(1, get_user_preferences('drawer-open-index'));
+ }
+
+ /**
+ * Test that an invalid preference is rejected.
+ */
+ public function test_preferences_set_invalid_value(): void {
+ $this->resetAfterTest();
+
+ $this->add_class_routes_to_route_loader(preferences::class);
+
+ $this->setAdminUser();
+
+ $response = $this->process_api_request(
+ 'POST',
+ '/current/preferences',
+ body: Utils::streamFor(json_encode([
+ 'preferences' => [
+ 'filemanager_recentviewmode' => 4,
+ ],
+ ])),
+ );
+
+ $this->assert_invalid_parameter_response($response);
+ $payload = $this->decode_response($response);
+ $this->assertStringContainsString('filemanager_recentviewmode', $payload->message);
+ }
+
+ /**
+ * Test that a preference the user does not have permission to is rejected.
+ */
+ public function test_preferences_set_not_permitted_valid_login(): void {
+ $this->resetAfterTest();
+
+ $this->add_class_routes_to_route_loader(preferences::class);
+
+ $this->setAdminUser();
+
+ $response = $this->process_api_request(
+ 'POST',
+ '/current/preferences',
+ body: Utils::streamFor(json_encode([
+ 'preferences' => [
+ 'auth_forcepasswordchange' => 4,
+ ],
+ ])),
+ );
+
+ $this->assert_access_denied_response($response);
+ }
+
+ public function test_preference_set(): void {
+ $this->resetAfterTest();
+
+ $this->add_class_routes_to_route_loader(preferences::class);
+
+ $this->setAdminUser();
+
+ $response = $this->process_api_request(
+ 'POST',
+ '/current/preferences/filemanager_recentviewmode',
+ body: Utils::streamFor(json_encode([
+ 'value' => 2,
+ ])),
+ );
+
+ $this->assert_valid_response($response);
+
+ // Check that the response contained the updtaed parameter.
+ $payload = $this->decode_response($response);
+ $this->assertObjectHasProperty('filemanager_recentviewmode', $payload);
+ $this->assertEquals(2, $payload->filemanager_recentviewmode);
+
+ // Check that the preference was updated.
+ $this->assertEquals(2, get_user_preferences('filemanager_recentviewmode'));
+ }
+
+ /**
+ * Test that an invalid preference is rejected.
+ */
+ public function test_preference_set_invalid_value(): void {
+ $this->resetAfterTest();
+
+ $this->add_class_routes_to_route_loader(preferences::class);
+
+ $this->setAdminUser();
+
+ $response = $this->process_api_request(
+ 'POST',
+ '/current/preferences/filemanager_recentviewmode',
+ body: Utils::streamFor(json_encode([
+ 'value' => 4,
+ ])),
+ );
+
+ $this->assert_invalid_parameter_response($response);
+ $payload = $this->decode_response($response);
+ $this->assertStringContainsString('filemanager_recentviewmode', $payload->message);
+ }
+
+ /**
+ * Test that an invalid preference inentifier is rejected.
+ */
+ public function test_preference_set_invalid_preference(): void {
+ $this->resetAfterTest();
+
+ $this->add_class_routes_to_route_loader(preferences::class);
+
+ $this->setAdminUser();
+
+ $response = $this->process_api_request(
+ 'POST',
+ '/current/preferences/what_a_fake',
+ body: Utils::streamFor(json_encode([
+ 'value' => 4,
+ ])),
+ );
+
+ $this->assert_invalid_parameter_response($response);
+ $payload = $this->decode_response($response);
+ $this->assertStringContainsString('what_a_fake', $payload->message);
+ }
+
+ /**
+ * Test that a preference the user does not have permission to is rejected.
+ */
+ public function test_preference_set_not_permitted_valid_login(): void {
+ $this->resetAfterTest();
+
+ $this->add_class_routes_to_route_loader(preferences::class);
+
+ $this->setAdminUser();
+
+ $response = $this->process_api_request(
+ 'POST',
+ '/current/preferences/auth_forcepasswordchange',
+ body: Utils::streamFor(json_encode([
+ 'value' => 4,
+ ])),
+ );
+
+ $this->assert_access_denied_response($response);
+ }
+
+ /**
+ * A user cannot get or set preferences for anothe ruser.
+ */
+ public function test_preference_get_other_user(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+ $user = $this->getDataGenerator()->create_user();
+
+ $this->add_class_routes_to_route_loader(preferences::class);
+
+ // Get all preferences.
+ $response = $this->process_api_request('GET', "/{$user->id}/preferences");
+ $this->assert_access_denied_response($response);
+
+ // Get one preference.
+ $response = $this->process_api_request('GET', "/{$user->id}/preferences/example");
+ $this->assert_access_denied_response($response);
+
+ // Set all preferences.
+ $response = $this->process_api_request(
+ 'POST',
+ "/{$user->id}/preferences/filemanager_recentviewmode",
+ body: Utils::streamFor(json_encode([
+ 'value' => 4,
+ ])),
+ );
+ $this->assert_access_denied_response($response);
+
+ // Get all preferences.
+ $response = $this->process_api_request(
+ 'POST',
+ "/{$user->id}/preferences",
+ body: Utils::streamFor(json_encode([
+ 'preferences' => [
+ 'filemanager_recentviewmode' => 2,
+ ],
+ ])),
+ );
+ $this->assert_access_denied_response($response);
+ }
+}