diff --git a/.gitignore b/.gitignore index 8cd32a3368f..a57723961dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -project +/project smarty/templates_c .gitmodules project-* diff --git a/modules/api/php/endpoints/candidates.class.inc b/modules/api/php/endpoints/candidates.class.inc index a1dedbc78ae..6550fb34422 100644 --- a/modules/api/php/endpoints/candidates.class.inc +++ b/modules/api/php/endpoints/candidates.class.inc @@ -211,12 +211,12 @@ class Candidates extends Endpoint implements \LORIS\Middleware\ETagCalculator $candid = \Candidate::createNew( new \CenterID("$centerid"), $data['Candidate']['DoB'], - $data['Candidate']['EDC'], + $data['Candidate']['EDC'] ?? null, $sex, $pscid, $project->getId() ); - } catch (\LorisException $e) { + } catch (\LorisException | \InvalidArgumentException | \Exception $e) { return new \LORIS\Http\Response\JSON\BadRequest($e->getMessage()); } diff --git a/modules/api/php/endpoints/project/project.class.inc b/modules/api/php/endpoints/project/project.class.inc index effc0247ceb..f11c9830194 100644 --- a/modules/api/php/endpoints/project/project.class.inc +++ b/modules/api/php/endpoints/project/project.class.inc @@ -110,6 +110,9 @@ class Project extends Endpoint implements \LORIS\Middleware\ETagCalculator case 'instruments': $handler = new Instruments($this->_project); break; + case 'subprojects': + $handler = new Subprojects($this->_project); + break; case 'visits': $handler = new Visits($this->_project); break; diff --git a/modules/api/php/endpoints/project/subproject/subproject.class.inc b/modules/api/php/endpoints/project/subproject/subproject.class.inc new file mode 100644 index 00000000000..83eeee56561 --- /dev/null +++ b/modules/api/php/endpoints/project/subproject/subproject.class.inc @@ -0,0 +1,145 @@ + + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://github.com/aces/Loris + */ +namespace LORIS\api\Endpoints\Project\Subproject; + +use \Psr\Http\Message\ServerRequestInterface; +use \Psr\Http\Message\ResponseInterface; +use \LORIS\api\Endpoint; + +/** + * A class for handling the /projects/$projectname/subprojects/$subproject + * endpoint. + * + * @category API + * @package Loris + * @author Xavier Lecours Boucher + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://github.com/aces/Loris + */ +class Subproject extends Endpoint implements \LORIS\Middleware\ETagCalculator +{ + /** + * A cache of the results of the endpoint, so that + * it doesn't need to be recalculated for the ETag and handler + */ + private $_cache; + + /** + * The requested instrument + */ + private $_subproject; + + /** + * Contructor for the subproject endpoint + * + * @param \Subproject $subproject The subproject + */ + public function __construct(\Subproject $subproject) + { + $this->_subproject = $subproject; + } + + /** + * Return which methods are supported by this endpoint. + * + * @return array supported HTTP methods + */ + protected function allowedMethods() : array + { + return ['GET']; + } + + /** + * Versions of the LORIS API which are supported by this + * endpoint. + * + * @return array a list of supported API versions. + */ + protected function supportedVersions() : array + { + return [ + 'v0.0.4-dev', + ]; + } + + /** + * Handles a HTTP request + * + * @param ServerRequestInterface $request The incoming PSR7 request + * + * @return ResponseInterface The outgoing PSR7 response + */ + public function handle(ServerRequestInterface $request) : ResponseInterface + { + $pathparts = $request->getAttribute('pathparts'); + if (count($pathparts) !== 0) { + return new \LORIS\Http\Response\JSON\NotFound(); + } + + switch ($request->getMethod()) { + case 'GET': + return $this->_handleGET($request); + + case 'OPTIONS': + return (new \LORIS\Http\Response()) + ->withHeader('Allow', $this->allowedMethods()); + default: + return new \LORIS\Http\Response\JSON\MethodNotAllowed( + $this->allowedMethods() + ); + } + } + + /** + * Create an array representation of this endpoint's reponse body + * + * @return ResponseInterface The outgoing PSR7 response + */ + private function _handleGET(): ResponseInterface + { + if (isset($this->_cache)) { + return $this->_cache; + } + + $data = json_decode(json_encode($this->_subproject), true); + $visits = array_reduce( + $this->_subproject->getVisits(), + function ($carry, $item) { + // This is just changing the keys from visit_label to VisitLabel + // It should probably be delegated to a view object. + array_push($carry, ['VisitLabel' => $item['visit_label']]); + return $carry; + }, + [] + ); + + $data['Visits'] = $visits; + + $this->_cache = new \LORIS\Http\Response\JsonResponse($data); + + return $this->_cache; + } + + /** + * Implements the ETagCalculator interface + * + * @param ServerRequestInterface $request The PSR7 incoming request. + * + * @return string etag summarizing value of this request. + */ + public function ETag(ServerRequestInterface $request) : string + { + return md5(json_encode($this->_handleGET($request)->getBody())); + } +} + diff --git a/modules/api/php/endpoints/project/subprojects.class.inc b/modules/api/php/endpoints/project/subprojects.class.inc new file mode 100644 index 00000000000..43fb695826a --- /dev/null +++ b/modules/api/php/endpoints/project/subprojects.class.inc @@ -0,0 +1,160 @@ +_project = $project; + } + + /** + * Return which methods are supported by this endpoint. + * + * @return array supported HTTP methods + */ + protected function allowedMethods() : array + { + return ['GET']; + } + + /** + * Versions of the LORIS API which are supported by this + * endpoint. + * + * @return array a list of supported API versions. + */ + protected function supportedVersions() : array + { + return [ + 'v0.0.4-dev', + ]; + } + + /** + * Handles a HTTP request + * + * @param ServerRequestInterface $request The incoming PSR7 request + * + * @return ResponseInterface The outgoing PSR7 response + */ + public function handle(ServerRequestInterface $request) : ResponseInterface + { + $pathparts = $request->getAttribute('pathparts'); + if (count($pathparts) === 0) { + switch ($request->getMethod()) { + case 'GET': + return $this->_handleGET(); + + case 'OPTIONS': + return (new \LORIS\Http\Response()) + ->withHeader('Allow', $this->allowedMethods()); + default: + return new \LORIS\Http\Response\JSON\MethodNotAllowed( + $this->allowedMethods() + ); + } + } + + // Delegate to subproject specific endpoint. + $subproject_name = urldecode($pathparts[0] ?? ''); + + try { + $subproject = $this->_project->getSubproject($subproject_name); + } catch (\NotFound $e) { + return new \LORIS\Http\Response\JSON\NotFound(); + } + + $endpoint = new Subproject\Subproject($subproject); + + // Removing `/subprojects/` from pathparts. + $pathparts = array_slice($pathparts, 2); + $request = $request->withAttribute('pathparts', $pathparts); + + return $endpoint->process($request, $endpoint); + } + + /** + * Generates a JSON representation of this projecti subprojects following the API + * specification. + * + * @return ResponseInterface + */ + private function _handleGET(): ResponseInterface + { + if (isset($this->_cache)) { + return $this->_cache; + } + + $subprojects = array_map( + function ($subproject) { + return [ + 'SubprojectID' => $subproject['subprojectId'], + 'Title' => $subproject['title'], + 'UseEDC' => $subproject['useEDC'], + 'WindowDifference' => $subproject['windowDifference'], + 'RecruitmentTarget' => $subproject['recruitmentTarget'] + ]; + }, + $this->_project->getSubprojects() + ); + + $array = [ + 'Subprojects' => $subprojects + ]; + + $this->_cache = new \LORIS\Http\Response\JsonResponse($array); + + return $this->_cache; + } + /** + * Implements the ETagCalculator interface + * + * @param ServerRequestInterface $request The PSR7 incoming request. + * + * @return string etag summarizing value of this request. + */ + public function ETag(ServerRequestInterface $request) : string + { + return md5(json_encode($this->_handleGet($request)->getBody())); + } +} diff --git a/modules/api/php/endpoints/projects.class.inc b/modules/api/php/endpoints/projects.class.inc index 584cc7ab432..670882a2c0c 100644 --- a/modules/api/php/endpoints/projects.class.inc +++ b/modules/api/php/endpoints/projects.class.inc @@ -108,6 +108,10 @@ class Projects extends Endpoint implements \LORIS\Middleware\ETagCalculator return new \LORIS\Http\Response\JSON\NotFound(); } + if (!$project->isAccessibleBy($user)) { + return new \LORIS\Http\Response\JSON\Forbidden(); + } + $endpoint = new Project\Project($project); // Removing `/projects/` from pathparts. diff --git a/modules/api/php/endpoints/subproject/subproject.class.inc b/modules/api/php/endpoints/subproject/subproject.class.inc new file mode 100644 index 00000000000..3fc351ffd59 --- /dev/null +++ b/modules/api/php/endpoints/subproject/subproject.class.inc @@ -0,0 +1,133 @@ + + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://github.com/aces/Loris + */ +class Subproject extends Endpoint implements \LORIS\Middleware\ETagCalculator +{ + /** + * A cache of the results of the subprojects/$subprojectname endpoint, so that + * it doesn't need to be recalculated for the ETag and handler + */ + private $_cache; + + /** + * The requested subproject + */ + private $_subproject; + + /** + * Contructor + * + * @param string $subproject The requested subproject + */ + public function __construct(string $subproject) + { + $this->_subproject = $subproject; + } + + /** + * Return which methods are supported by this endpoint. + * + * @return array supported HTTP methods + */ + protected function allowedMethods() : array + { + return ['GET']; + } + + /** + * Versions of the LORIS API which are supported by this + * endpoint. + * + * @return array a list of supported API versions. + */ + protected function supportedVersions() : array + { + return [ + 'v0.0.4-dev', + ]; + } + + /** + * Handles a request that starts with /subprojects/$subprojectname + * + * @param ServerRequestInterface $request The incoming PSR7 request + * + * @return ResponseInterface The outgoing PSR7 response + */ + public function handle(ServerRequestInterface $request) : ResponseInterface + { + $pathparts = $request->getAttribute('pathparts'); + if (count($pathparts) === 0) { + switch ($request->getMethod()) { + case 'GET': + return $this->_handleGET($request); + + case 'OPTIONS': + return (new \LORIS\Http\Response()) + ->withHeader('Allow', $this->allowedMethods()); + + default: + return new \LORIS\Http\Response\JSON\MethodNotAllowed( + $this->allowedMethods() + ); + } + } + return new \LORIS\Http\Response\JSON\NotFound(); + } + + /** + * Generates a JSON representation of this subproject following the API + * specification. + * + * @param ServerRequestInterface $request The incoming PSR7 request @unused-param + * + * @return ResponseInterface + */ + private function _handleGET($request): ResponseInterface + { + if (isset($this->_cache)) { + return $this->_cache; + } + + $view = (new \LORIS\api\Views\SubProject($this->_subproject)) + ->toArray(); + $this->_cache = new \LORIS\Http\Response\JsonResponse($view); + + return $this->_cache; + } + + /** + * Implements the ETagCalculator interface + * + * @param ServerRequestInterface $request The incoming PSR7 request + * + * @return string etag summarizing value of this request. + */ + public function ETag($request) : string + { + return md5(json_encode($this->_handleGET($request)->getBody())); + } +} diff --git a/modules/api/php/views/project.class.inc b/modules/api/php/views/project.class.inc index 387b53cb7db..27aaa3e4290 100644 --- a/modules/api/php/views/project.class.inc +++ b/modules/api/php/views/project.class.inc @@ -29,7 +29,7 @@ class Project * * @var \Project */ - protected $_project; + private $_project; /** * Constructor @@ -55,7 +55,7 @@ class Project 'Meta' => $meta, 'Candidates' => $this->_project->getCandidateIds(), 'Instruments' => array_keys(\Utility::getAllInstruments()), - 'Visits' => $this->getVisits(), + 'Visits' => $this->_getVisits(), ]; } @@ -87,7 +87,7 @@ class Project return [ 'Meta' => $meta, - 'Visits' => $this->getVisits(), + 'Visits' => $this->_getVisits(), ]; } @@ -96,7 +96,7 @@ class Project * * @return array */ - protected function getVisits(): array + private function _getVisits(): array { // TODO :: This should be replaced by $this->_project->getVisitLabels(); return array_keys( diff --git a/modules/api/php/views/project_0_0_4_dev.class.inc b/modules/api/php/views/project_0_0_4_dev.class.inc index b8df8bd15f5..28583688c7c 100644 --- a/modules/api/php/views/project_0_0_4_dev.class.inc +++ b/modules/api/php/views/project_0_0_4_dev.class.inc @@ -20,8 +20,25 @@ namespace LORIS\api\Views; * @link https://www.github.com/aces/Loris/ */ -class Project_0_0_4_Dev extends Project +class Project_0_0_4_Dev { + /** + * The project to format + * + * @var \Project + */ + protected $_project; + + /** + * Constructor + * + * @param \Project $project The project to format + */ + public function __construct(\Project $project) + { + $this->_project = $project; + } + /** * Produce an array representation of this project. * @@ -29,18 +46,18 @@ class Project_0_0_4_Dev extends Project */ public function toArray(): array { - $meta = ['Project' => $this->_project->getName()]; + $name = $this->_project->getName(); return [ - 'Meta' => $meta, - 'Subprojects' => array_values( - \Utility::getSubprojectList( - $this->_project->getId() - ) - ), - 'Candidates' => $this->_project->getCandidateIds(), - 'Instruments' => array_keys(\Utility::getAllInstruments()), - 'Visits' => $this->getVisits(), + 'Id' => $this->_project->getId(), + 'Name' => $this->_project->getName(), + 'Alias' => $this->_project->getAlias(), + 'RecruitementTarget' => $this->_project->getRecruitmentTarget(), + 'Links' => [ + 'Candidates' => "/projects/$name/candidates", + 'Instruments' => "/projects/$name/instruments", + 'Subprojects' => "/projects/$name/subprojects" + ] ]; } } diff --git a/modules/api/php/views/subproject.class.inc b/modules/api/php/views/subproject.class.inc new file mode 100644 index 00000000000..7ab4dae9147 --- /dev/null +++ b/modules/api/php/views/subproject.class.inc @@ -0,0 +1,91 @@ +_subproject = $subproject; + } + + /** + * Produce an array representation of this project. + * + * @return array + */ + public function toArray(): array + { + $meta = ['Subproject' => $this->_subproject]; + + return [ + 'Meta' => $meta, + 'Visits' => $this->getVisits(), + ]; + } + + /** + * Generates the list of valid visit_labels for the subproject + * as defined in the config settings + * + * @return array + */ + protected function getVisits(): array + { + $visits = []; + + $subprojectID = array_search( + $this->_subproject, + \Utility::getSubprojectList() + ); + + $config =& \NDB_Config::singleton(); + $visitLabelSettings = $config->getSetting('visitLabel'); + foreach (\Utility::associativeToNumericArray($visitLabelSettings) + as $visitLabelSetting) { + if ($visitLabelSetting['@']['subprojectID'] == $subprojectID) { + + $items = \Utility::associativeToNumericArray( + $visitLabelSetting['labelSet']['item'] + ); + foreach ($items as $item) { + $visits[] = $item['@']['value']; + } + break; + } + } + + return $visits; + } +} + diff --git a/modules/api/php/views/subprojects.class.inc b/modules/api/php/views/subprojects.class.inc new file mode 100644 index 00000000000..815384d6590 --- /dev/null +++ b/modules/api/php/views/subprojects.class.inc @@ -0,0 +1,54 @@ +_subprojects = $subprojects; + } + + /** + * Gets the required data from the projects. + * + * @return array + */ + public function toArray(): array + { + $subprojects = []; + foreach ($this->_subprojects as $subproject) { + $subprojects[$subproject['title']] = [ + 'useEDC' => $subproject['useEDC'], + 'WindowDifference' => $subproject['WindowDifference'], + 'RecruitmentTarget' => $subproject['RecruitmentTarget'] + ]; + } + return ['Subprojects' => $subprojects]; + } +} diff --git a/modules/api/static/schema-v0.0.4-dev.yml b/modules/api/static/schema-v0.0.4-dev.yml new file mode 100644 index 00000000000..54887f3e70e --- /dev/null +++ b/modules/api/static/schema-v0.0.4-dev.yml @@ -0,0 +1,157 @@ +openapi: 3.0.1 +info: + title: LORIS - REST API endpoints + description: The LORIS API uses standard HTTP error codes and the body of any response will either be empty or contain only a JSON object for any request. + contact: + name: LORIS Development Team + url: 'https://github.com/aces/loris' + license: + name: 'GNU Public License, Version 3' + url: 'https://opensource.org/licenses/GPL-3.0' + version: 0.0.4-dev +servers: + - url: /api/v0.0.4-dev +security: + - ApiKeyAuth: [] +paths: + '/projects/{project}': + get: + tags: + - Projects + summary: Get that project descriptor + parameters: + - name: project + in: path + description: The project name + required: true + style: simple + explode: false + schema: + type: string + responses: + '200': + description: An object containing the project descriptor + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + '/projects/{project}/subprojects': + get: + tags: + - Projects + summary: List all subprojects of that project + parameters: + - name: project + in: path + description: The project name + required: true + style: simple + explode: false + schema: + type: string + responses: + '200': + description: An object containing the list of instrument summary descriptors + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectSubprojects' + '/projects/{project}/subprojects/{subproject}': + get: + tags: + - Projects + summary: Get a subproject descriptor in that project + parameters: + - name: project + in: path + description: The project name + required: true + style: simple + explode: false + schema: + type: string + - name: subproject + in: path + description: The subproject title + required: true + style: simple + explode: false + schema: + type: string + responses: + '200': + description: An JSON representation of the subproject + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectSubproject' +components: + schemas: + Project: + type: object + properties: + Id: + type: string + Name: + type: string + Alias: + type: string + RecruitementTarget: + type: integer + Links: + type: object + properties: + Candidates: + type: string + Instruments: + type: string + Subprojects: + type: string + ProjectSubprojects: + type: object + properties: + Subprojects: + type: array + items: + $ref: '#/components/schemas/Subproject' + Subproject: + type: object + properties: + SubprojectID: + type: string + Title: + type: string + UseEDC: + type: boolean + WindowDifference: + type: string + RecruitmentTarget: + type: integer + ProjectSubproject: + type: object + properties: + SubprojectID: + type: string + Title: + type: string + UseEDC: + type: boolean + WindowDifference: + type: string + enum: + - 'optimal' + - 'battery' + RecruitmentTarget: + type: integer + Visits: + type: array + items: + type: object + properties: + VisitLabel: + type: string + securitySchemes: + ApiKeyAuth: + type: apiKey + name: Authorization + in: header diff --git a/php/libraries/Project.class.inc b/php/libraries/Project.class.inc index b030a18b8d9..b5365ba8c3f 100644 --- a/php/libraries/Project.class.inc +++ b/php/libraries/Project.class.inc @@ -12,6 +12,7 @@ * @link https://www.github.com/aces/Loris/ */ +use \LORIS\StudyEntities\AccessibleResource; /** * The Project class encapsulates all details of a project. * @@ -21,7 +22,7 @@ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 * @link https://www.github.com/aces/Loris/ */ -class Project implements \JsonSerializable +class Project implements \JsonSerializable, AccessibleResource { private static $_instances = []; @@ -383,6 +384,42 @@ class Project implements \JsonSerializable return $subProjectData; } + /** + * Get a specific subproject associated with this project. + * + * @param string $title The subproject title + * + * @return \Subproject + */ + public function getSubproject(string $title): \Subproject + { + $filtered = array_filter( + $this->getSubprojects(), + function ($subproject) use ($title) { + return $subproject['title'] == $title; + } + ); + + if (empty($filtered)) { + throw new \NotFound('There is no matching subproject title'); + } + + if (count($filtered) > 1) { + throw new \LorisException('Multiple subprojects with the same title'); + } + + $subproject = array_shift($filtered); + + return new \Subproject( + $subproject['subprojectId'], + $this->_projectId, + $subproject['title'], + $subproject['useEDC'], + $subproject['windowDifference'], + $subproject['recruitmentTarget'] + ); + } + /** * Get that project's participants * @@ -470,4 +507,18 @@ class Project implements \JsonSerializable { return $this->_projectName; } + + /** + * Implements the AccessibleResource interface. A candidate can + * be accessed by a user if it shares the same registration project + * and site or it has access to any of the timepoints. + * + * @param \User $user The user whose access should be validated. + * + * @return bool + */ + public function isAccessibleBy(\User $user): bool + { + return in_array($this->_projectId, $user->getProjectIDs()); + } } diff --git a/php/libraries/Subproject.class.inc b/php/libraries/Subproject.class.inc new file mode 100644 index 00000000000..077e8190e16 --- /dev/null +++ b/php/libraries/Subproject.class.inc @@ -0,0 +1,111 @@ + + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ + +/** + * The Project class encapsulates all details of a project. + * + * @category Main + * @package Loris + * @author Xavier Lecours Boucher + * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3 + * @link https://www.github.com/aces/Loris/ + */ +class Subproject implements \JsonSerializable +{ + private $_subproject_id; + private $_project_id; + private $_title; + private $_useEDC; + private $_windowDifference; + private $_recruitmentTarget; + + /** + * Contructor + * + * @param string $id The subproject id + * @param \ProjectID $project_id the associated project id + * @param string $title This title + * @param ?string $useEDC useEDC Either "0", "1" or null + * @param ?string $windowDifference Value from the database + * @param ?string $recruitmentTarget A numeric string or null + */ + public function __construct( + string $id, + \ProjectID $project_id, + string $title, + ?string $useEDC = null, + ?string $windowDifference = null, + ?string $recruitmentTarget = null + ) { + $this->_subproject_id = $id; + $this->_project_id = $project_id; + $this->_title = $title; + if (!is_null($useEDC)) { + $this->_useEDC = boolval($useEDC); + } + $this->_windowDifference = $windowDifference; + if (!is_null($recruitmentTarget)) { + $this->_recruitmentTarget = intval($recruitmentTarget); + } + } + + /** + * Gets the visit label for that subproject + * + * @return array An array of string + */ + public function getVisits(): array + { + $factory = \NDB_Factory::singleton(); + $visitsData = $factory->database()->pselect( + " + SELECT + v.VisitName visit_label + FROM visit v + LEFT JOIN visit_project_subproject_rel vpsr + ON (v.VisitID = vpsr.VisitID) + LEFT JOIN project_subproject_rel psr + ON (vpsr.ProjectSubprojectRelID = psr.ProjectSubprojectRelID) + WHERE + psr.SubprojectID = :v_subproject_id AND + psr.ProjectID = :v_project_id + ", + [ + 'v_subproject_id' => $this->_subproject_id, + 'v_project_id' => $this->_project_id + ] + ); + + return $visitsData; + } + + /** + * Specify data which should be serialized to JSON. + * Returns data which can be serialized by json_encode(), which is a value of + * any type other than a resource. + * + * @see https://www.php.net/manual/en/jsonserializable.jsonserialize.php + * @return mixed + */ + public function jsonSerialize() + { + return [ + "SubprojectID" => $this->_subproject_id, + "Title" => $this->_title, + "UseEDC" => $this->_useEDC, + "WindowDifference" => $this->_windowDifference, + "RecruitementTarget" => $this->_recruitmentTarget + ]; + } +}