diff --git a/classes/ilios.php b/classes/ilios.php index 0129024..38fb768 100644 --- a/classes/ilios.php +++ b/classes/ilios.php @@ -45,6 +45,11 @@ class ilios { */ const API_BASE_PATH = '/api/v3/'; + /** + * @var int The maximum number of filtering request parameters in a given API request. + */ + const MAX_FILTER_PARAMS = 250; + /** * @var string The API access token. */ @@ -79,8 +84,7 @@ public function __construct( * @throws moodle_exception */ public function get_schools(array $filterby = [], array $sortby = []): array { - $response = $this->get('schools', $filterby, $sortby); - return $response->schools; + return $this->get('schools', 'schools', $filterby, $sortby); } /** @@ -93,8 +97,7 @@ public function get_schools(array $filterby = [], array $sortby = []): array { * @throws moodle_exception */ public function get_cohorts(array $filterby = [], array $sortby = []): array { - $response = $this->get('cohorts', $filterby, $sortby); - return $response->cohorts; + return $this->get('cohorts', 'cohorts', $filterby, $sortby); } /** @@ -107,8 +110,7 @@ public function get_cohorts(array $filterby = [], array $sortby = []): array { * @throws moodle_exception */ public function get_programs(array $filterby = [], array $sortby = []): array { - $response = $this->get('programs', $filterby, $sortby); - return $response->programs; + return $this->get('programs', 'programs', $filterby, $sortby); } /** @@ -121,8 +123,7 @@ public function get_programs(array $filterby = [], array $sortby = []): array { * @throws moodle_exception */ public function get_program_years(array $filterby = [], array $sortby = []): array { - $response = $this->get('programyears', $filterby, $sortby); - return $response->programYears; + return $this->get('programyears', 'programYears', $filterby, $sortby); } /** @@ -135,8 +136,7 @@ public function get_program_years(array $filterby = [], array $sortby = []): arr * @throws moodle_exception */ public function get_learner_groups(array $filterby = [], array $sortby = []): array { - $response = $this->get('learnergroups', $filterby, $sortby); - return $response->learnerGroups; + return $this->get('learnergroups', 'learnerGroups', $filterby, $sortby); } /** @@ -149,8 +149,7 @@ public function get_learner_groups(array $filterby = [], array $sortby = []): ar * @throws moodle_exception */ public function get_instructor_groups(array $filterby = [], array $sortby = []): array { - $response = $this->get('instructorgroups', $filterby, $sortby); - return $response->instructorGroups; + return $this->get('instructorgroups', 'instructorGroups', $filterby, $sortby); } /** @@ -163,8 +162,7 @@ public function get_instructor_groups(array $filterby = [], array $sortby = []): * @throws moodle_exception */ public function get_offerings(array $filterby = [], array $sortby = []): array { - $response = $this->get('offerings', $filterby, $sortby); - return $response->offerings; + return $this->get('offerings', 'offerings', $filterby, $sortby); } /** @@ -177,8 +175,7 @@ public function get_offerings(array $filterby = [], array $sortby = []): array { * @throws moodle_exception */ public function get_ilms(array $filterby = [], array $sortby = []): array { - $response = $this->get('ilmsessions', $filterby, $sortby); - return $response->ilmSessions; + return $this->get('ilmsessions', 'ilmSessions', $filterby, $sortby); } /** @@ -191,8 +188,7 @@ public function get_ilms(array $filterby = [], array $sortby = []): array { * @throws moodle_exception */ public function get_users(array $filterby = [], array $sortby = []): array { - $response = $this->get('users', $filterby, $sortby); - return $response->users; + return $this->get('users', 'users', $filterby, $sortby); } /** @@ -204,11 +200,7 @@ public function get_users(array $filterby = [], array $sortby = []): array { * @throws moodle_exception */ public function get_school(int $id): ?object { - $response = $this->get_by_id('schools', $id); - if ($response) { - return $response->schools[0]; - } - return null; + return $this->get_by_id('schools', 'schools', $id); } /** @@ -220,11 +212,7 @@ public function get_school(int $id): ?object { * @throws moodle_exception */ public function get_cohort(int $id): ?object { - $response = $this->get_by_id('cohorts', $id); - if ($response) { - return $response->cohorts[0]; - } - return null; + return $this->get_by_id('cohorts', 'cohorts', $id); } /** @@ -236,11 +224,7 @@ public function get_cohort(int $id): ?object { * @throws moodle_exception */ public function get_program(int $id): ?object { - $response = $this->get_by_id('programs', $id); - if ($response) { - return $response->programs[0]; - } - return null; + return $this->get_by_id('programs', 'programs', $id); } /** @@ -252,11 +236,7 @@ public function get_program(int $id): ?object { * @throws moodle_exception */ public function get_learner_group(int $id): ?object { - $response = $this->get_by_id('learnergroups', $id); - if ($response) { - return $response->learnerGroups[0]; - } - return null; + return $this->get_by_id('learnergroups', 'learnerGroups', $id); } /** @@ -342,54 +322,50 @@ public function get_instructor_ids_from_learner_group(int $groupid): array { return array_values($instructorids); } - /** * Sends a GET request to a given API endpoint with given options. * - * @param string $path The target path fragment of the API request URL. May include query parameters. + * @param string $path The target path fragment of the API request URL. + * @param string $key The name of the property that holds the requested data points in the payload. * @param array $filterby An associative array of filter options. * @param array $sortby An associative array of sort options. - * @return object The decoded response body. + * @return array The data points from the decoded payload. * @throws GuzzleException * @throws moodle_exception */ public function get( string $path, + string $key, array $filterby = [], array $sortby = [], - ): object { + ): array { $this->validate_access_token($this->accesstoken); $options = ['headers' => ['X-JWT-Authorization' => 'Token ' . $this->accesstoken]]; + $url = $this->apibaseurl . $path; + // Construct query params from given filters and sort orders. // Unfortunately, http_build_query() doesn't cut it here, so we have to hand-roll this. - $queryparams = []; + $filterparams = []; if (!empty($filterby)) { foreach ($filterby as $param => $value) { if (is_array($value)) { foreach ($value as $val) { - $queryparams[] = "filters[$param][]=$val"; + $filterparams[] = "filters[$param][]=$val"; } } else { - $queryparams[] = "filters[$param]=$value"; + $filterparams[] = "filters[$param]=$value"; } } } - + $sortparams = []; if (!empty($sortby)) { foreach ($sortby as $param => $value) { - $queryparams[] = "order_by[$param]=$value"; + $sortparams[] = "order_by[$param]=$value"; } } - $url = $this->apibaseurl . $path; - - if (!empty($queryparams)) { - $url .= '?' . implode('&', $queryparams); - } - - $response = $this->httpclient->get($url, $options); - return $this->parse_result($response->getBody()); + return $this->send_get_request($url, $key, $options, $filterparams, $sortparams); } /** @@ -415,6 +391,7 @@ public static function get_access_token_payload(string $accesstoken): array { * Retrieves a given resource from Ilios by its given ID. * * @param string $path The URL path fragment that names the resource. + * @param string $key The name of the property that holds the requested data points in the payload. * @param int $id The ID. * @param bool $returnnullonnotfound If TRUE then NULL is returned if the resource cannot be found. * On FALSE, an exception is raised on 404/Not-Found. @@ -423,9 +400,10 @@ public static function get_access_token_payload(string $accesstoken): array { * @throws GuzzleException * @throws moodle_exception */ - public function get_by_id(string $path, int $id, bool $returnnullonnotfound = true): ?object { + public function get_by_id(string $path, string $key, int $id, bool $returnnullonnotfound = true): ?object { try { - return $this->get($path . '/' . $id); + $response = $this->get($path . '/' . $id, $key); + return $response[0]; } catch (ClientException $e) { if ($returnnullonnotfound && (404 === $e->getResponse()->getStatusCode())) { return null; @@ -435,6 +413,57 @@ public function get_by_id(string $path, int $id, bool $returnnullonnotfound = tr } } + /** + * Internal helper method for sending a GET request to the Ilios API. + * + * @param string $url The API request URL. May include query parameters. + * @param string $key The name of the property that holds the requested data points in the payload. + * @param array $options API Client options, such as header values. + * @param array $filterparams An associative array of filtering request parameters. + * @param array $sortparams An associative array of sorting request parameters. + * @return array The data points from the decoded payload. + * @throws GuzzleException + * @throws moodle_exception + */ + protected function send_get_request( + string $url, + string $key, + array $options = [], + array $filterparams = [], + array $sortparams = [] + ): array { + // Batch processing comes first. + // If the number of request parameters exceeds the given limit, + // we must chunk them up into smaller lists and run then individually, followed by results aggregation. + // This is done by recursively calling this method with chunked filtering options. + if (count($filterparams) > self::MAX_FILTER_PARAMS) { + $batches = array_chunk($filterparams, self::MAX_FILTER_PARAMS); + $results = []; + foreach ($batches as $batch) { + $result = $this->send_get_request($url, $key, $options, $batch, $sortparams); + $results = array_merge($results, $result); + } + return $results; + } + + $queryparams = array_merge($filterparams, $sortparams); + + if (!empty($queryparams)) { + $url .= '?' . implode('&', $queryparams); + } + + $response = $this->httpclient->get($url, $options); + $rhett = $this->parse_result($response->getBody()); + if (!property_exists($rhett, $key)) { + throw new moodle_exception( + 'errorresponseentitynotfound', + 'enrol_ilios', + a: $key, + ); + } + return $rhett->$key; + } + /** * Decodes and returns the given JSON-encoded input. * diff --git a/tests/ilios_test.php b/tests/ilios_test.php index b5640f4..f27284f 100644 --- a/tests/ilios_test.php +++ b/tests/ilios_test.php @@ -1028,17 +1028,17 @@ public function test_get(): void { di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); - $data = $ilios->get('schools'); + $data = $ilios->get('schools', 'schools'); $this->assertEquals('GET', $container[0]['request']->getMethod()); $this->assertEquals('ilios.demo', $container[0]['request']->getUri()->getHost()); $this->assertEquals('/api/v3/schools', $container[0]['request']->getUri()->getPath()); - $this->assertCount(2, $data->schools); - $this->assertEquals(1, $data->schools[0]->id); - $this->assertEquals('Medicine', $data->schools[0]->title); - $this->assertEquals(2, $data->schools[1]->id); - $this->assertEquals('Pharmacy', $data->schools[1]->title); + $this->assertCount(2, $data); + $this->assertEquals(1, $data[0]->id); + $this->assertEquals('Medicine', $data[0]->title); + $this->assertEquals(2, $data[1]->id); + $this->assertEquals('Pharmacy', $data[1]->title); } /** @@ -1070,7 +1070,7 @@ public function test_get_with_filtering_and_sorting_criteria( di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); - $ilios->get('geflarkniks', $filterby, $sortby); + $ilios->get('geflarkniks', 'geflarkniks', $filterby, $sortby); $this->assertEquals($expectedquerystring, urldecode($container[0]['request']->getUri()->getQuery())); } @@ -1094,6 +1094,79 @@ public static function get_with_filtering_and_sorting_provider(): array { ]; } + /** + * Tests get() in batch mode. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_in_batch_mode(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'schools' => [ + ['id' => 1, 'title' => 'Medicine'], + ['id' => 2, 'title' => 'Pharmacy'], + ], + ])), + new Response(200, [], json_encode([ + 'schools' => [ + ['id' => 3, 'title' => 'Dentistry'], + ['id' => 4, 'title' => 'Nursing'], + ], + ])), + new Response(200, [], json_encode([ + 'schools' => [ + ['id' => 5, 'title' => 'Other'], + ], + ])), + ])); + $container = []; + $history = Middleware::history($container); + $handlerstack->push($history); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + + $ilios = di::get(ilios::class); + $filterby = ['id' => range(1000, 1564)]; + $sortby = ['name' => 'DESC']; + $data = $ilios->get('schools', 'schools', $filterby, $sortby); + + $this->assertCount(3, $container); + $this->assertEquals('GET', $container[0]['request']->getMethod()); + $this->assertEquals('ilios.demo', $container[0]['request']->getUri()->getHost()); + $this->assertEquals('/api/v3/schools', $container[0]['request']->getUri()->getPath()); + $paramsbatch1 = explode('&', urldecode($container[0]['request']->getUri()->getQuery())); + $this->assertCount(251, $paramsbatch1); // That's 250 filtering params and 1 sorting param. + $this->assertEquals('filters[id][]=1000', $paramsbatch1[0]); + $this->assertEquals('filters[id][]=1249', $paramsbatch1[249]); + $this->assertEquals('order_by[name]=DESC', $paramsbatch1[250]); + $paramsbatch2 = explode('&', urldecode($container[1]['request']->getUri()->getQuery())); + $this->assertCount(251, $paramsbatch2); + $this->assertEquals('filters[id][]=1250', $paramsbatch2[0]); + $this->assertEquals('filters[id][]=1499', $paramsbatch2[249]); + $this->assertEquals('order_by[name]=DESC', $paramsbatch2[250]); + $paramsbatch3 = explode('&', urldecode($container[2]['request']->getUri()->getQuery())); + $this->assertCount(66, $paramsbatch3); + $this->assertEquals('filters[id][]=1500', $paramsbatch3[0]); + $this->assertEquals('filters[id][]=1564', $paramsbatch3[64]); + $this->assertEquals('order_by[name]=DESC', $paramsbatch3[65]); + + $this->assertCount(5, $data); + $this->assertEquals(1, $data[0]->id); + $this->assertEquals('Medicine', $data[0]->title); + $this->assertEquals(2, $data[1]->id); + $this->assertEquals('Pharmacy', $data[1]->title); + $this->assertEquals(3, $data[2]->id); + $this->assertEquals('Dentistry', $data[2]->title); + $this->assertEquals(4, $data[3]->id); + $this->assertEquals('Nursing', $data[3]->title); + $this->assertEquals(5, $data[4]->id); + $this->assertEquals('Other', $data[4]->title); + } /** * Tests retrieving a resource by its ID from the Ilios API. @@ -1115,14 +1188,11 @@ public function test_get_by_id(): void { di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); - $response = $ilios->get_by_id('geflarkniks', 12345); + $response = $ilios->get_by_id('geflarkniks', 'geflarkniks', 12345); $this->assertEquals('/api/v3/geflarkniks/12345', $container[0]['request']->getUri()->getPath()); - - $this->assertObjectHasProperty('geflarkniks', $response); - $this->assertCount(1, $response->geflarkniks); - $this->assertEquals('1', $response->geflarkniks[0]->id); - $this->assertEquals('whatever', $response->geflarkniks[0]->title); + $this->assertEquals('1', $response->id); + $this->assertEquals('whatever', $response->title); } /** @@ -1142,7 +1212,7 @@ public function test_get_by_id_fails_on_404(): void { $this->expectException(ClientException::class); $this->expectExceptionMessage('404 Not Found'); $this->expectExceptionCode(404); - $ilios->get_by_id('does-not-matter-here', 12345, false); + $ilios->get_by_id('does-not-matter-here', 'does-not-matter-here', 12345, false); } /** @@ -1158,7 +1228,7 @@ public function test_get_by_id_returns_null_on_404(): void { ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); - $response = $ilios->get_by_id('does-not-matter-here', 12345); + $response = $ilios->get_by_id('does-not-matter-here', 'does-not-matter-here', 12345); $this->assertNull($response); } @@ -1180,7 +1250,7 @@ public function test_get_fails_on_garbled_response(): void { $ilios = di::get(ilios::class); $this->expectException(moodle_exception::class); $this->expectExceptionMessage('Failed to decode response.'); - $ilios->get('schools'); + $ilios->get('schools', 'schools'); } /** @@ -1201,7 +1271,7 @@ public function test_get_fails_on_empty_response(): void { $ilios = di::get(ilios::class); $this->expectException(moodle_exception::class); $this->expectExceptionMessage('Empty response.'); - $ilios->get('schools'); + $ilios->get('schools', 'schools'); } /** @@ -1221,7 +1291,7 @@ public function test_get_fails_on_error_response(): void { $ilios = di::get(ilios::class); $this->expectException(moodle_exception::class); $this->expectExceptionMessage('The API responded with the following error: something went wrong.'); - $ilios->get('schools'); + $ilios->get('schools', 'schools'); } /** @@ -1246,7 +1316,30 @@ public function test_get_fails_on_server_side_error(): void { 'Server error: `GET http://ilios.demo/api/v3/schools` resulted in a `500 Internal Server Error` response' ); // phpcs:enable - $ilios->get('schools'); + $ilios->get('schools', 'schools'); + } + + /** + * Tests that get() fails if the given property key does not match the payload. + * + * @return void + * @throws moodle_exception + */ + public function test_get_fails_on_invalid_key(): void { + $this->resetAfterTest(); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode(['geflarkniks' => [ + ['id' => 1, 'title' => 'whatever'], + ]])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage('Cannot find schools in response.'); + $ilios->get('schools', 'schools'); } /** @@ -1262,7 +1355,7 @@ public function test_get_fails_with_expired_token(): void { $ilios = di::get(ilios::class); $this->expectException(moodle_exception::class); $this->expectExceptionMessage('API token is expired.'); - $ilios->get('schools'); + $ilios->get('schools', 'schools'); } /** @@ -1279,7 +1372,7 @@ public function test_get_fails_with_empty_token(string $accesstoken): void { $ilios = di::get(ilios::class); $this->expectException(moodle_exception::class); $this->expectExceptionMessage('API token is empty.'); - $ilios->get('schools'); + $ilios->get('schools', 'schools'); } /** @@ -1296,7 +1389,7 @@ public function test_get_fails_with_corrupted_token(string $accesstoken): void { $ilios = di::get(ilios::class); $this->expectException(moodle_exception::class); $this->expectExceptionMessage('Failed to decode API token.'); - $ilios->get('schools'); + $ilios->get('schools', 'schools'); } /** @@ -1313,7 +1406,7 @@ public function test_get_fails_with_invalid_token(string $accesstoken): void { $ilios = di::get(ilios::class); $this->expectException(moodle_exception::class); $this->expectExceptionMessage('API token has an incorrect number of segments.'); - $ilios->get('schools'); + $ilios->get('schools', 'schools'); } /** diff --git a/version.php b/version.php index 324109b..ef75865 100644 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2024110100; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2024110101; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2024041600; // Requires this Moodle version. $plugin->component = 'enrol_ilios'; // Full name of the plugin (used for diagnostics). $plugin->release = 'v4.4';