Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ The present file will list all changes made to the project; according to the
### Added

### Changed
- Added High-Level API version 2.1. Make sure you are pinning your requests to a specific version (Ex: `/api.php/v2.0`) if needed to exclude endpoints/properties added in later versions. See version pinning in the getting started documentation `/api.php/getting-started`.
- High-Level API responses for not found routes now correctly return a body including the standard error properties (status, title, detail). This is not controlled by the API version.

### Deprecated

Expand Down
7 changes: 4 additions & 3 deletions src/Glpi/Api/HL/Controller/CoreController.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,8 @@ public function showDocumentation(Request $request): Response
$swagger_content .= Html::script('/lib/swagger-ui.js');
$swagger_content .= Html::css('/lib/swagger-ui.css');
$favicon = Html::getPrefixedUrl('/pics/favicon.ico');
$doc_json_path = $CFG_GLPI['root_doc'] . '/api.php/doc.json';
$api_version = $this->getAPIVersion($request);
$doc_json_path = $CFG_GLPI['root_doc'] . '/api.php/v' . $api_version . '/doc.json';
$swagger_content .= <<<HTML
<link rel="shortcut icon" type="images/x-icon" href="$favicon" />
</head>
Expand Down Expand Up @@ -285,7 +286,7 @@ private function getAllowedMethodsForMatchedRoute(Request $request): array
)]
public function defaultRoute(Request $request): Response
{
return new JSONResponse(null, 404);
return self::getNotFoundErrorResponse();
}

#[Route(path: '/{req}', methods: ['OPTIONS'], requirements: ['req' => '.*'], priority: -1, security_level: Route::SECURITY_NONE)]
Expand All @@ -299,7 +300,7 @@ public function defaultOptionsRoute(Request $request): Response
$authenticated = Session::getLoginUserID() !== false;
$allowed_methods = $authenticated ? $this->getAllowedMethodsForMatchedRoute($request) : ['GET', 'POST', 'PATCH', 'PUT', "DELETE"];
if (count($allowed_methods) === 0) {
return new JSONResponse(null, 404);
return self::getNotFoundErrorResponse();
}
$response_headers = [];
if ($authenticated) {
Expand Down
2 changes: 2 additions & 0 deletions src/Glpi/Api/HL/Doc/Schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,8 @@ public static function filterSchemaByAPIVersion(array $schema, string $api_versi
'removed' => $schema['x-version-removed'] ?? null,
];

$api_version = Router::normalizeAPIVersion($api_version);

// Check if the schema itself is applicable to the requested version
// If the requested version is before the introduction of the schema, or after the removal of the schema, it is not applicable
// Deprecation has no effect here
Expand Down
67 changes: 33 additions & 34 deletions src/Glpi/Api/HL/OpenAPIGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ private function getInfo(): array
return [
'title' => 'GLPI High-Level REST API',
'description' => $description,
'version' => Router::API_VERSION,
'version' => $this->api_version,
'license' => [
'name' => 'GNU General Public License v3 or later',
'url' => 'https://www.gnu.org/licenses/gpl-3.0.html',
Expand All @@ -141,40 +141,36 @@ private function getInfo(): array

public static function getComponentSchemas(string $api_version): array
{
static $schemas = null;

if ($schemas === null) {
$schemas = [];

$controllers = Router::getInstance()->getControllers();
foreach ($controllers as $controller) {
$known_schemas = $controller::getKnownSchemas($api_version);
$short_name = (new ReflectionClass($controller))->getShortName();
$controller_name = str_replace('Controller', '', $short_name);
foreach ($known_schemas as $schema_name => $known_schema) {
// Ignore schemas starting with an underscore. They are only used internally.
if (str_starts_with($schema_name, '_')) {
continue;
}
$calculated_name = $schema_name;
if (isset($schemas[$schema_name])) {
// For now, set the new calculated name to the short name of the controller + the schema name
$calculated_name = $controller_name . ' - ' . $schema_name;
// Change the existing schema name to its own calculated name
$other_short_name = (new ReflectionClass($schemas[$schema_name]['x-controller']))->getShortName();
$other_calculated_name = str_replace('Controller', '', $other_short_name) . ' - ' . $schema_name;
$schemas[$other_calculated_name] = $schemas[$schema_name];
unset($schemas[$schema_name]);
}
if (!isset($known_schema['description']) && isset($known_schema['x-itemtype'])) {
/** @var class-string<CommonGLPI> $itemtype */
$itemtype = $known_schema['x-itemtype'];
$known_schema['description'] = $itemtype::getTypeName(1);
}
$schemas[$calculated_name] = $known_schema;
$schemas[$calculated_name]['x-controller'] = $controller::class;
$schemas[$calculated_name]['x-schemaname'] = $schema_name;
$schemas = [];

$controllers = Router::getInstance()->getControllers();
foreach ($controllers as $controller) {
$known_schemas = $controller::getKnownSchemas($api_version);
$short_name = (new ReflectionClass($controller))->getShortName();
$controller_name = str_replace('Controller', '', $short_name);
foreach ($known_schemas as $schema_name => $known_schema) {
// Ignore schemas starting with an underscore. They are only used internally.
if (str_starts_with($schema_name, '_')) {
continue;
}
$calculated_name = $schema_name;
if (isset($schemas[$schema_name])) {
// For now, set the new calculated name to the short name of the controller + the schema name
$calculated_name = $controller_name . ' - ' . $schema_name;
// Change the existing schema name to its own calculated name
$other_short_name = (new ReflectionClass($schemas[$schema_name]['x-controller']))->getShortName();
$other_calculated_name = str_replace('Controller', '', $other_short_name) . ' - ' . $schema_name;
$schemas[$other_calculated_name] = $schemas[$schema_name];
unset($schemas[$schema_name]);
}
if (!isset($known_schema['description']) && isset($known_schema['x-itemtype'])) {
/** @var class-string<CommonGLPI> $itemtype */
$itemtype = $known_schema['x-itemtype'];
$known_schema['description'] = $itemtype::getTypeName(1);
}
$schemas[$calculated_name] = $known_schema;
$schemas[$calculated_name]['x-controller'] = $controller::class;
$schemas[$calculated_name]['x-schemaname'] = $schema_name;
}
}

Expand Down Expand Up @@ -255,6 +251,9 @@ public function getSchema(): array
$paths = [];

foreach ($routes as $route_path) {
if (!$route_path->matchesAPIVersion($this->api_version)) {
continue;
}
/** @noinspection SlowArrayOperationsInLoopInspection */
$paths = array_merge_recursive($paths, $this->getPathSchemas($route_path));
}
Expand Down
5 changes: 4 additions & 1 deletion src/Glpi/Api/HL/RoutePath.php
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,10 @@ public function getRouteVersion(): RouteVersion
public function matchesAPIVersion(string $api_version): bool
{
$version = $this->getRouteVersion();
return (version_compare($api_version, $version->introduced, '>=') && (empty($version->removed) || version_compare($api_version, $version->removed, '<')));
return (
version_compare($api_version, $version->introduced, '>=')
&& (empty($version->removed) || version_compare($api_version, $version->removed, '<'))
);
}

private function setPath(string $path)
Expand Down
37 changes: 21 additions & 16 deletions src/Glpi/Api/HL/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
class Router
{
/** @var string */
public const API_VERSION = '2.0.0';
public const API_VERSION = '2.1.0';

/**
* @var AbstractController[]
Expand Down Expand Up @@ -152,10 +152,6 @@ public static function getAPIVersions(): array
While not as user friendly as the high-level API, it is more powerful and allows to do some things that are not possible with the high-level API.
It has no promise of stability between versions so it may change without warning.
EOT;
$current_version = self::API_VERSION;
// Get short version which is the major part of the semver string
$current_version_major = explode('.', $current_version)[0];

return [
[
'api_version' => '1',
Expand All @@ -164,9 +160,14 @@ public static function getAPIVersions(): array
'endpoint' => $CFG_GLPI['url_base'] . '/api.php/v1',
],
[
'api_version' => $current_version_major,
'version' => self::API_VERSION,
'endpoint' => $CFG_GLPI['url_base'] . '/api.php/v2',
'api_version' => '2',
'version' => '2.0.0',
'endpoint' => $CFG_GLPI['url_base'] . '/api.php/v2.0',
],
[
'api_version' => '2',
'version' => '2.1.0',
'endpoint' => $CFG_GLPI['url_base'] . '/api.php/v2.1',
],
];
}
Expand All @@ -181,20 +182,24 @@ public static function getAPIVersions(): array
* @param string $version
* @return string
*/
public static function normalizeAPIVersion(string $version): string
final public static function normalizeAPIVersion(string $version): string
{
$versions = array_column(static::getAPIVersions(), 'version');
$best_match = self::API_VERSION;
if (in_array($version, $versions, true)) {
// Exact match
return $version;
$best_match = null;
if (empty($version)) {
$version = static::API_VERSION;
}

foreach ($versions as $available_version) {
if (str_starts_with($available_version, $version . '.') && version_compare($available_version, $best_match, '>')) {
$best_match = $available_version;
if (str_starts_with($available_version, $version)) {
if ($best_match === null || version_compare($available_version, $best_match, '>')) {
$best_match = $available_version;
}
}
}
if ($best_match === null) {
$best_match = static::API_VERSION;
}
return $best_match;
}

Expand Down Expand Up @@ -488,7 +493,7 @@ public function matchAll(Request $request): array
{
$routes = $this->getRoutesFromCache();

$api_version = $request->getHeaderLine('GLPI-API-Version') ?: static::API_VERSION;
$api_version = self::normalizeAPIVersion($request->getHeaderLine('GLPI-API-Version') ?: static::API_VERSION);
// Filter routes by the requested API version and method
$routes = array_filter($routes, static function ($route) use ($request, $api_version) {
if ($route->matchesAPIVersion($api_version) && in_array($request->getMethod(), $route->getRouteMethods(), true)) {
Expand Down
140 changes: 126 additions & 14 deletions tests/functional/Glpi/Api/HL/RouterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,20 +87,12 @@ public function testAllSchemasHaveVersioningInfo()
$this->assertEmpty($schemas_missing_versions, 'Schemas missing versioning info: ' . implode(', ', $schemas_missing_versions));
}

public function testNormalizeAPIVersion()
{
$this->assertEquals('50.2.0', TestRouter::normalizeAPIVersion('50'));
$this->assertEquals('50.1.1', TestRouter::normalizeAPIVersion('50.1.1'));
$this->assertEquals('50.1.2', TestRouter::normalizeAPIVersion('50.1'));
$this->assertEquals('50.2.0', TestRouter::normalizeAPIVersion('50.2'));
}

public function testHLAPIDisabled()
{
global $CFG_GLPI;

$CFG_GLPI['enable_hlapi'] = 0;
$router = TestRouter::getInstance();
$router = Router::getInstance();
$response = $router->handleRequest(new Request('GET', '/Computer'));
$this->assertEquals(403, $response->getStatusCode());
$this->assertStringContainsString('The High-Level API is disabled', (string) $response->getBody());
Expand All @@ -110,11 +102,71 @@ public function testHLAPIDisabled()
$this->assertEquals(403, $response->getStatusCode());
$this->assertStringContainsString('The High-Level API is disabled', (string) $response->getBody());
}

public function testNormalizeVersion()
{
// invalid version = router default
$this->assertEquals('51.0.0', TestRouter::normalizeAPIVersion('99'));
// only major version = latest API version for this major
$this->assertEquals('50.2.0', TestRouter::normalizeAPIVersion('50'));
// major.minor version = latest API version for this major.minor
$this->assertEquals('50.1.2', TestRouter::normalizeAPIVersion('50.1'));
// major.minor.patch version = same version
$this->assertEquals('50.1.1', TestRouter::normalizeAPIVersion('50.1.1'));

$this->assertEquals('50.2.0', TestRouter::normalizeAPIVersion('50.2'));
}

public function testRoutingByVersion()
{
$router = TestRouter::getInstance();
// 50.0 is requesting 50.0.X or earlier
$this->assertNotEquals('/{req}', $router->match(new Request('GET', '/version500', ['GLPI-API-Version' => '50.0']))->getRoutePath());
// 50 is requesting 50.X.X or earlier
$this->assertNotEquals('/{req}', $router->match(new Request('GET', '/version500', ['GLPI-API-Version' => '50']))->getRoutePath());
// 50.1 is requesting 50.1.X or earlier
$this->assertNotEquals('/{req}', $router->match(new Request('GET', '/version500', ['GLPI-API-Version' => '50.1']))->getRoutePath());
$this->assertNotEquals('/{req}', $router->match(new Request('GET', '/version500', ['GLPI-API-Version' => '51']))->getRoutePath());

$this->assertEquals('/{req}', $router->match(new Request('GET', '/version501', ['GLPI-API-Version' => '50.0']))->getRoutePath());
$this->assertNotEquals('/{req}', $router->match(new Request('GET', '/version501', ['GLPI-API-Version' => '50.1']))->getRoutePath());
$this->assertNotEquals('/{req}', $router->match(new Request('GET', '/version501', ['GLPI-API-Version' => '50']))->getRoutePath());

$this->assertEquals('/{req}', $router->match(new Request('GET', '/version510', ['GLPI-API-Version' => '50.0']))->getRoutePath());
$this->assertEquals('/{req}', $router->match(new Request('GET', '/version510', ['GLPI-API-Version' => '50.1']))->getRoutePath());
$this->assertNotEquals('/{req}', $router->match(new Request('GET', '/version510', ['GLPI-API-Version' => '51']))->getRoutePath());
$this->assertEquals('/{req}', $router->match(new Request('GET', '/version510', ['GLPI-API-Version' => '50']))->getRoutePath());
}

public function testSchemaByVersion()
{
// Note that schema version matching is always done against the "Router" class so it cannot be mocked with the TestRouter versions
$this->assertEquals(['Schema200', 'Schema200_2', 'Schema210'], array_keys(TestController::getKnownSchemas('2')));
$this->assertEquals(['Schema200', 'Schema200_2'], array_keys(TestController::getKnownSchemas('2.0')));
$this->assertEquals(['Schema200', 'Schema200_2'], array_keys(TestController::getKnownSchemas('2.0.0')));
$this->assertEquals(['Schema200', 'Schema200_2', 'Schema210'], array_keys(TestController::getKnownSchemas('2.1')));
$this->assertEquals(['Schema200', 'Schema200_2', 'Schema210'], array_keys(TestController::getKnownSchemas('2.1.0')));

// Test the filtering of fields inside schemas
$schema = TestController::getKnownSchemas('2')['Schema200'];
$this->assertArrayHasKey('field1', $schema['properties']);
$this->assertArrayHasKey('field2', $schema['properties']);

$schema = TestController::getKnownSchemas('2.0')['Schema200'];
$this->assertArrayHasKey('field1', $schema['properties']);
$this->assertArrayNotHasKey('field2', $schema['properties']);

$schema = TestController::getKnownSchemas('2.1')['Schema200'];
$this->assertArrayHasKey('field1', $schema['properties']);
$this->assertArrayHasKey('field2', $schema['properties']);
}
}

// @codingStandardsIgnoreStart
class TestRouter extends Router
{
public const API_VERSION = '51.0.0';

// @codingStandardsIgnoreEnd
public static function getInstance(): Router
{
Expand Down Expand Up @@ -172,14 +224,74 @@ public static function getAPIVersions(): array
class TestController extends AbstractController
{
// @codingStandardsIgnoreEnd
/**
* @param RequestInterface $request
* @return Response
*/

protected static function getRawKnownSchemas(): array
{
return [
'Schema200' => [
'type' => 'object',
'x-version-introduced' => '2.0',
'properties' => [
'field1' => [
'type' => 'string',
],
'field2' => [
'type' => 'string',
'x-version-introduced' => '2.1.0',
],
],
],
'Schema200_2' => [
'type' => 'object',
'x-version-introduced' => '2.0.0',
'properties' => [
'field1' => [
'type' => 'string',
],

'field2' => [
'type' => 'string',
'x-version-introduced' => '2.1.0',
],
],
],
'Schema210' => [
'type' => 'object',
'x-version-introduced' => '2.1.0',
'properties' => [
'field1' => [
'type' => 'string',
],
],
],
];
}

#[Route('/{req}', ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'], ['req' => '.*'], -1)]
#[RouteVersion(introduced: TestRouter::API_VERSION)]
#[RouteVersion(introduced: '50.0.0')]
public function defaultRoute(RequestInterface $request): Response
{
return new Response(200, [], __FUNCTION__);
}

#[Route('/version500', ['GET'])]
#[RouteVersion(introduced: '50.0.0')]
public function testVersion500(RequestInterface $request): Response
{
return new Response(200, [], __FUNCTION__);
}

#[Route('/version501', ['GET'])]
#[RouteVersion(introduced: '50.1.0')]
public function testVersion501(RequestInterface $request): Response
{
return new Response(200, [], __FUNCTION__);
}

#[Route('/version510', ['GET'])]
#[RouteVersion(introduced: '51.0.0')]
public function testVersion510(RequestInterface $request): Response
{
return new Response(200, [], __FUNCTION__);
}
}