Skip to content

Commit dc8112c

Browse files
authored
prepare hlapi v2.1
1 parent 81457ae commit dc8112c

File tree

7 files changed

+192
-68
lines changed

7 files changed

+192
-68
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ The present file will list all changes made to the project; according to the
88
### Added
99

1010
### Changed
11+
- 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`.
12+
- 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.
1113

1214
### Deprecated
1315

src/Glpi/Api/HL/Controller/CoreController.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,8 @@ public function showDocumentation(Request $request): Response
163163
$swagger_content .= Html::script('/lib/swagger-ui.js');
164164
$swagger_content .= Html::css('/lib/swagger-ui.css');
165165
$favicon = Html::getPrefixedUrl('/pics/favicon.ico');
166-
$doc_json_path = $CFG_GLPI['root_doc'] . '/api.php/doc.json';
166+
$api_version = $this->getAPIVersion($request);
167+
$doc_json_path = $CFG_GLPI['root_doc'] . '/api.php/v' . $api_version . '/doc.json';
167168
$swagger_content .= <<<HTML
168169
<link rel="shortcut icon" type="images/x-icon" href="$favicon" />
169170
</head>
@@ -285,7 +286,7 @@ private function getAllowedMethodsForMatchedRoute(Request $request): array
285286
)]
286287
public function defaultRoute(Request $request): Response
287288
{
288-
return new JSONResponse(null, 404);
289+
return self::getNotFoundErrorResponse();
289290
}
290291

291292
#[Route(path: '/{req}', methods: ['OPTIONS'], requirements: ['req' => '.*'], priority: -1, security_level: Route::SECURITY_NONE)]
@@ -299,7 +300,7 @@ public function defaultOptionsRoute(Request $request): Response
299300
$authenticated = Session::getLoginUserID() !== false;
300301
$allowed_methods = $authenticated ? $this->getAllowedMethodsForMatchedRoute($request) : ['GET', 'POST', 'PATCH', 'PUT', "DELETE"];
301302
if (count($allowed_methods) === 0) {
302-
return new JSONResponse(null, 404);
303+
return self::getNotFoundErrorResponse();
303304
}
304305
$response_headers = [];
305306
if ($authenticated) {

src/Glpi/Api/HL/Doc/Schema.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,8 @@ public static function filterSchemaByAPIVersion(array $schema, string $api_versi
337337
'removed' => $schema['x-version-removed'] ?? null,
338338
];
339339

340+
$api_version = Router::normalizeAPIVersion($api_version);
341+
340342
// Check if the schema itself is applicable to the requested version
341343
// If the requested version is before the introduction of the schema, or after the removal of the schema, it is not applicable
342344
// Deprecation has no effect here

src/Glpi/Api/HL/OpenAPIGenerator.php

Lines changed: 33 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ private function getInfo(): array
131131
return [
132132
'title' => 'GLPI High-Level REST API',
133133
'description' => $description,
134-
'version' => Router::API_VERSION,
134+
'version' => $this->api_version,
135135
'license' => [
136136
'name' => 'GNU General Public License v3 or later',
137137
'url' => 'https://www.gnu.org/licenses/gpl-3.0.html',
@@ -141,40 +141,36 @@ private function getInfo(): array
141141

142142
public static function getComponentSchemas(string $api_version): array
143143
{
144-
static $schemas = null;
145-
146-
if ($schemas === null) {
147-
$schemas = [];
148-
149-
$controllers = Router::getInstance()->getControllers();
150-
foreach ($controllers as $controller) {
151-
$known_schemas = $controller::getKnownSchemas($api_version);
152-
$short_name = (new ReflectionClass($controller))->getShortName();
153-
$controller_name = str_replace('Controller', '', $short_name);
154-
foreach ($known_schemas as $schema_name => $known_schema) {
155-
// Ignore schemas starting with an underscore. They are only used internally.
156-
if (str_starts_with($schema_name, '_')) {
157-
continue;
158-
}
159-
$calculated_name = $schema_name;
160-
if (isset($schemas[$schema_name])) {
161-
// For now, set the new calculated name to the short name of the controller + the schema name
162-
$calculated_name = $controller_name . ' - ' . $schema_name;
163-
// Change the existing schema name to its own calculated name
164-
$other_short_name = (new ReflectionClass($schemas[$schema_name]['x-controller']))->getShortName();
165-
$other_calculated_name = str_replace('Controller', '', $other_short_name) . ' - ' . $schema_name;
166-
$schemas[$other_calculated_name] = $schemas[$schema_name];
167-
unset($schemas[$schema_name]);
168-
}
169-
if (!isset($known_schema['description']) && isset($known_schema['x-itemtype'])) {
170-
/** @var class-string<CommonGLPI> $itemtype */
171-
$itemtype = $known_schema['x-itemtype'];
172-
$known_schema['description'] = $itemtype::getTypeName(1);
173-
}
174-
$schemas[$calculated_name] = $known_schema;
175-
$schemas[$calculated_name]['x-controller'] = $controller::class;
176-
$schemas[$calculated_name]['x-schemaname'] = $schema_name;
144+
$schemas = [];
145+
146+
$controllers = Router::getInstance()->getControllers();
147+
foreach ($controllers as $controller) {
148+
$known_schemas = $controller::getKnownSchemas($api_version);
149+
$short_name = (new ReflectionClass($controller))->getShortName();
150+
$controller_name = str_replace('Controller', '', $short_name);
151+
foreach ($known_schemas as $schema_name => $known_schema) {
152+
// Ignore schemas starting with an underscore. They are only used internally.
153+
if (str_starts_with($schema_name, '_')) {
154+
continue;
177155
}
156+
$calculated_name = $schema_name;
157+
if (isset($schemas[$schema_name])) {
158+
// For now, set the new calculated name to the short name of the controller + the schema name
159+
$calculated_name = $controller_name . ' - ' . $schema_name;
160+
// Change the existing schema name to its own calculated name
161+
$other_short_name = (new ReflectionClass($schemas[$schema_name]['x-controller']))->getShortName();
162+
$other_calculated_name = str_replace('Controller', '', $other_short_name) . ' - ' . $schema_name;
163+
$schemas[$other_calculated_name] = $schemas[$schema_name];
164+
unset($schemas[$schema_name]);
165+
}
166+
if (!isset($known_schema['description']) && isset($known_schema['x-itemtype'])) {
167+
/** @var class-string<CommonGLPI> $itemtype */
168+
$itemtype = $known_schema['x-itemtype'];
169+
$known_schema['description'] = $itemtype::getTypeName(1);
170+
}
171+
$schemas[$calculated_name] = $known_schema;
172+
$schemas[$calculated_name]['x-controller'] = $controller::class;
173+
$schemas[$calculated_name]['x-schemaname'] = $schema_name;
178174
}
179175
}
180176

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

257253
foreach ($routes as $route_path) {
254+
if (!$route_path->matchesAPIVersion($this->api_version)) {
255+
continue;
256+
}
258257
/** @noinspection SlowArrayOperationsInLoopInspection */
259258
$paths = array_merge_recursive($paths, $this->getPathSchemas($route_path));
260259
}

src/Glpi/Api/HL/RoutePath.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,10 @@ public function getRouteVersion(): RouteVersion
362362
public function matchesAPIVersion(string $api_version): bool
363363
{
364364
$version = $this->getRouteVersion();
365-
return (version_compare($api_version, $version->introduced, '>=') && (empty($version->removed) || version_compare($api_version, $version->removed, '<')));
365+
return (
366+
version_compare($api_version, $version->introduced, '>=')
367+
&& (empty($version->removed) || version_compare($api_version, $version->removed, '<'))
368+
);
366369
}
367370

368371
private function setPath(string $path)

src/Glpi/Api/HL/Router.php

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
class Router
9090
{
9191
/** @var string */
92-
public const API_VERSION = '2.0.0';
92+
public const API_VERSION = '2.1.0';
9393

9494
/**
9595
* @var AbstractController[]
@@ -152,10 +152,6 @@ public static function getAPIVersions(): array
152152
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.
153153
It has no promise of stability between versions so it may change without warning.
154154
EOT;
155-
$current_version = self::API_VERSION;
156-
// Get short version which is the major part of the semver string
157-
$current_version_major = explode('.', $current_version)[0];
158-
159155
return [
160156
[
161157
'api_version' => '1',
@@ -164,9 +160,14 @@ public static function getAPIVersions(): array
164160
'endpoint' => $CFG_GLPI['url_base'] . '/api.php/v1',
165161
],
166162
[
167-
'api_version' => $current_version_major,
168-
'version' => self::API_VERSION,
169-
'endpoint' => $CFG_GLPI['url_base'] . '/api.php/v2',
163+
'api_version' => '2',
164+
'version' => '2.0.0',
165+
'endpoint' => $CFG_GLPI['url_base'] . '/api.php/v2.0',
166+
],
167+
[
168+
'api_version' => '2',
169+
'version' => '2.1.0',
170+
'endpoint' => $CFG_GLPI['url_base'] . '/api.php/v2.1',
170171
],
171172
];
172173
}
@@ -181,20 +182,24 @@ public static function getAPIVersions(): array
181182
* @param string $version
182183
* @return string
183184
*/
184-
public static function normalizeAPIVersion(string $version): string
185+
final public static function normalizeAPIVersion(string $version): string
185186
{
186187
$versions = array_column(static::getAPIVersions(), 'version');
187-
$best_match = self::API_VERSION;
188-
if (in_array($version, $versions, true)) {
189-
// Exact match
190-
return $version;
188+
$best_match = null;
189+
if (empty($version)) {
190+
$version = static::API_VERSION;
191191
}
192192

193193
foreach ($versions as $available_version) {
194-
if (str_starts_with($available_version, $version . '.') && version_compare($available_version, $best_match, '>')) {
195-
$best_match = $available_version;
194+
if (str_starts_with($available_version, $version)) {
195+
if ($best_match === null || version_compare($available_version, $best_match, '>')) {
196+
$best_match = $available_version;
197+
}
196198
}
197199
}
200+
if ($best_match === null) {
201+
$best_match = static::API_VERSION;
202+
}
198203
return $best_match;
199204
}
200205

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

491-
$api_version = $request->getHeaderLine('GLPI-API-Version') ?: static::API_VERSION;
496+
$api_version = self::normalizeAPIVersion($request->getHeaderLine('GLPI-API-Version') ?: static::API_VERSION);
492497
// Filter routes by the requested API version and method
493498
$routes = array_filter($routes, static function ($route) use ($request, $api_version) {
494499
if ($route->matchesAPIVersion($api_version) && in_array($request->getMethod(), $route->getRouteMethods(), true)) {

tests/functional/Glpi/Api/HL/RouterTest.php

Lines changed: 126 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -87,20 +87,12 @@ public function testAllSchemasHaveVersioningInfo()
8787
$this->assertEmpty($schemas_missing_versions, 'Schemas missing versioning info: ' . implode(', ', $schemas_missing_versions));
8888
}
8989

90-
public function testNormalizeAPIVersion()
91-
{
92-
$this->assertEquals('50.2.0', TestRouter::normalizeAPIVersion('50'));
93-
$this->assertEquals('50.1.1', TestRouter::normalizeAPIVersion('50.1.1'));
94-
$this->assertEquals('50.1.2', TestRouter::normalizeAPIVersion('50.1'));
95-
$this->assertEquals('50.2.0', TestRouter::normalizeAPIVersion('50.2'));
96-
}
97-
9890
public function testHLAPIDisabled()
9991
{
10092
global $CFG_GLPI;
10193

10294
$CFG_GLPI['enable_hlapi'] = 0;
103-
$router = TestRouter::getInstance();
95+
$router = Router::getInstance();
10496
$response = $router->handleRequest(new Request('GET', '/Computer'));
10597
$this->assertEquals(403, $response->getStatusCode());
10698
$this->assertStringContainsString('The High-Level API is disabled', (string) $response->getBody());
@@ -110,11 +102,71 @@ public function testHLAPIDisabled()
110102
$this->assertEquals(403, $response->getStatusCode());
111103
$this->assertStringContainsString('The High-Level API is disabled', (string) $response->getBody());
112104
}
105+
106+
public function testNormalizeVersion()
107+
{
108+
// invalid version = router default
109+
$this->assertEquals('51.0.0', TestRouter::normalizeAPIVersion('99'));
110+
// only major version = latest API version for this major
111+
$this->assertEquals('50.2.0', TestRouter::normalizeAPIVersion('50'));
112+
// major.minor version = latest API version for this major.minor
113+
$this->assertEquals('50.1.2', TestRouter::normalizeAPIVersion('50.1'));
114+
// major.minor.patch version = same version
115+
$this->assertEquals('50.1.1', TestRouter::normalizeAPIVersion('50.1.1'));
116+
117+
$this->assertEquals('50.2.0', TestRouter::normalizeAPIVersion('50.2'));
118+
}
119+
120+
public function testRoutingByVersion()
121+
{
122+
$router = TestRouter::getInstance();
123+
// 50.0 is requesting 50.0.X or earlier
124+
$this->assertNotEquals('/{req}', $router->match(new Request('GET', '/version500', ['GLPI-API-Version' => '50.0']))->getRoutePath());
125+
// 50 is requesting 50.X.X or earlier
126+
$this->assertNotEquals('/{req}', $router->match(new Request('GET', '/version500', ['GLPI-API-Version' => '50']))->getRoutePath());
127+
// 50.1 is requesting 50.1.X or earlier
128+
$this->assertNotEquals('/{req}', $router->match(new Request('GET', '/version500', ['GLPI-API-Version' => '50.1']))->getRoutePath());
129+
$this->assertNotEquals('/{req}', $router->match(new Request('GET', '/version500', ['GLPI-API-Version' => '51']))->getRoutePath());
130+
131+
$this->assertEquals('/{req}', $router->match(new Request('GET', '/version501', ['GLPI-API-Version' => '50.0']))->getRoutePath());
132+
$this->assertNotEquals('/{req}', $router->match(new Request('GET', '/version501', ['GLPI-API-Version' => '50.1']))->getRoutePath());
133+
$this->assertNotEquals('/{req}', $router->match(new Request('GET', '/version501', ['GLPI-API-Version' => '50']))->getRoutePath());
134+
135+
$this->assertEquals('/{req}', $router->match(new Request('GET', '/version510', ['GLPI-API-Version' => '50.0']))->getRoutePath());
136+
$this->assertEquals('/{req}', $router->match(new Request('GET', '/version510', ['GLPI-API-Version' => '50.1']))->getRoutePath());
137+
$this->assertNotEquals('/{req}', $router->match(new Request('GET', '/version510', ['GLPI-API-Version' => '51']))->getRoutePath());
138+
$this->assertEquals('/{req}', $router->match(new Request('GET', '/version510', ['GLPI-API-Version' => '50']))->getRoutePath());
139+
}
140+
141+
public function testSchemaByVersion()
142+
{
143+
// Note that schema version matching is always done against the "Router" class so it cannot be mocked with the TestRouter versions
144+
$this->assertEquals(['Schema200', 'Schema200_2', 'Schema210'], array_keys(TestController::getKnownSchemas('2')));
145+
$this->assertEquals(['Schema200', 'Schema200_2'], array_keys(TestController::getKnownSchemas('2.0')));
146+
$this->assertEquals(['Schema200', 'Schema200_2'], array_keys(TestController::getKnownSchemas('2.0.0')));
147+
$this->assertEquals(['Schema200', 'Schema200_2', 'Schema210'], array_keys(TestController::getKnownSchemas('2.1')));
148+
$this->assertEquals(['Schema200', 'Schema200_2', 'Schema210'], array_keys(TestController::getKnownSchemas('2.1.0')));
149+
150+
// Test the filtering of fields inside schemas
151+
$schema = TestController::getKnownSchemas('2')['Schema200'];
152+
$this->assertArrayHasKey('field1', $schema['properties']);
153+
$this->assertArrayHasKey('field2', $schema['properties']);
154+
155+
$schema = TestController::getKnownSchemas('2.0')['Schema200'];
156+
$this->assertArrayHasKey('field1', $schema['properties']);
157+
$this->assertArrayNotHasKey('field2', $schema['properties']);
158+
159+
$schema = TestController::getKnownSchemas('2.1')['Schema200'];
160+
$this->assertArrayHasKey('field1', $schema['properties']);
161+
$this->assertArrayHasKey('field2', $schema['properties']);
162+
}
113163
}
114164

115165
// @codingStandardsIgnoreStart
116166
class TestRouter extends Router
117167
{
168+
public const API_VERSION = '51.0.0';
169+
118170
// @codingStandardsIgnoreEnd
119171
public static function getInstance(): Router
120172
{
@@ -172,14 +224,74 @@ public static function getAPIVersions(): array
172224
class TestController extends AbstractController
173225
{
174226
// @codingStandardsIgnoreEnd
175-
/**
176-
* @param RequestInterface $request
177-
* @return Response
178-
*/
227+
228+
protected static function getRawKnownSchemas(): array
229+
{
230+
return [
231+
'Schema200' => [
232+
'type' => 'object',
233+
'x-version-introduced' => '2.0',
234+
'properties' => [
235+
'field1' => [
236+
'type' => 'string',
237+
],
238+
'field2' => [
239+
'type' => 'string',
240+
'x-version-introduced' => '2.1.0',
241+
],
242+
],
243+
],
244+
'Schema200_2' => [
245+
'type' => 'object',
246+
'x-version-introduced' => '2.0.0',
247+
'properties' => [
248+
'field1' => [
249+
'type' => 'string',
250+
],
251+
252+
'field2' => [
253+
'type' => 'string',
254+
'x-version-introduced' => '2.1.0',
255+
],
256+
],
257+
],
258+
'Schema210' => [
259+
'type' => 'object',
260+
'x-version-introduced' => '2.1.0',
261+
'properties' => [
262+
'field1' => [
263+
'type' => 'string',
264+
],
265+
],
266+
],
267+
];
268+
}
269+
179270
#[Route('/{req}', ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'], ['req' => '.*'], -1)]
180-
#[RouteVersion(introduced: TestRouter::API_VERSION)]
271+
#[RouteVersion(introduced: '50.0.0')]
181272
public function defaultRoute(RequestInterface $request): Response
182273
{
183274
return new Response(200, [], __FUNCTION__);
184275
}
276+
277+
#[Route('/version500', ['GET'])]
278+
#[RouteVersion(introduced: '50.0.0')]
279+
public function testVersion500(RequestInterface $request): Response
280+
{
281+
return new Response(200, [], __FUNCTION__);
282+
}
283+
284+
#[Route('/version501', ['GET'])]
285+
#[RouteVersion(introduced: '50.1.0')]
286+
public function testVersion501(RequestInterface $request): Response
287+
{
288+
return new Response(200, [], __FUNCTION__);
289+
}
290+
291+
#[Route('/version510', ['GET'])]
292+
#[RouteVersion(introduced: '51.0.0')]
293+
public function testVersion510(RequestInterface $request): Response
294+
{
295+
return new Response(200, [], __FUNCTION__);
296+
}
185297
}

0 commit comments

Comments
 (0)