diff --git a/CHANGELOG.md b/CHANGELOG.md index 82c66beddbc..d4ccb801f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,21 @@ The present file will list all changes made to the project; according to the ### Added - Some missing `readOnly` flags for properties for some schemas in High-Level API. - Some missing UUID and color pattern constraints for properties in some schemas in High-Level API. +- High-Level API endpoints for configuration settings `/Setup/Config/{context}/{name}`. +- `uuid`, `user_tech`, `group_tech`, `date`, `date_creation`, `date_mod`, `planned_begin`, `planned_end`, `timeline_position`, `source_item_id`, and `source_of_item_id` properties for the applicable Ticket, Change and Problem Task schemas in the High-Level API v2.1. +- `date`, `timeline_position`, `source_item_id`, and `source_of_item_id` properties for the Followup schema in the High-Level API v2.1. +- `approver`, `approval_followup`, `date_creation`, `date_mod`, and `date_approval` properties for the Solution schema in the High-Level API v2.1. +- `timeline_position` property for the TicketValidation, ChangeValidation and Document_Item schemas in the High-Level API v2.1. +- `date_solve`, `date_close`, and `global_validation` properties for the applicable Ticket, Change and Problem schemas in the High-Level API v2.1. +- New schemas/endpoints for Reminders, RSS Feeds, and Reservations in the High-Level API v2.1. +- `date_password_change`, `location`, `authtype`, `last_login`, `default_profile` and `default_entity` properties for the User schema in the High-Level API v2.1. +- New UserPreferences schema and endpoints in the High-Level API v2.1. +- Some missing `completename` and `level` properties for some schemas in High-Level API v2.1. ### Changed - Fixed `id` property in dropdown/linked object properties in schemas in High-Level API showing as readOnly when they are writable. +- 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 diff --git a/src/Glpi/Api/HL/Controller/AdministrationController.php b/src/Glpi/Api/HL/Controller/AdministrationController.php index 34b06aa53af..adffdf7313d 100644 --- a/src/Glpi/Api/HL/Controller/AdministrationController.php +++ b/src/Glpi/Api/HL/Controller/AdministrationController.php @@ -35,7 +35,9 @@ namespace Glpi\Api\HL\Controller; +use Auth; use CommonDBTM; +use CommonITILObject; use Entity; use Glpi\Api\HL\Doc as Doc; use Glpi\Api\HL\Middleware\ResultFormatterMiddleware; @@ -45,7 +47,9 @@ use Glpi\Http\JSONResponse; use Glpi\Http\Request; use Glpi\Http\Response; +use Glpi\UI\ThemeManager; use Group; +use Planning; use Profile; use Session; use Toolbox; @@ -62,7 +66,7 @@ final class AdministrationController extends AbstractController public static function getRawKnownSchemas(): array { - return [ + $schemas = [ 'User' => [ 'x-version-introduced' => '2.0', 'x-itemtype' => User::class, @@ -188,6 +192,41 @@ public static function getRawKnownSchemas(): array return $CFG_GLPI["root_doc"] . '/pics/picture.png'; }, ], + 'date_password_change' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + 'description' => 'Date of last password change', + 'readOnly' => true, + 'x-field' => 'password_last_update', + ], + 'location' => self::getDropdownTypeSchema(class: 'Location', full_schema: 'Location') + ['x-version-introduced' => '2.1.0'], + 'authtype' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_NUMBER, + 'enum' => [Auth::DB_GLPI, Auth::MAIL, Auth::LDAP, Auth::EXTERNAL, Auth::CAS, Auth::X509], + 'description' => << [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + ], + 'default_profile' => self::getDropdownTypeSchema(class: Profile::class, full_schema: 'Profile') + [ + 'x-version-introduced' => '2.1.0', + 'description' => 'Default profile', + ], + 'default_entity' => self::getDropdownTypeSchema(class: Entity::class, full_schema: 'Entity') + [ + 'x-version-introduced' => '2.1.0', + 'description' => 'Default entity', + ], ], ], 'Group' => [ @@ -343,6 +382,340 @@ public static function getRawKnownSchemas(): array ], ], ]; + + $schemas['UserPreferences'] = [ + 'x-version-introduced' => '2.1.0', + 'x-itemtype' => User::class, + 'type' => Doc\Schema::TYPE_OBJECT, + 'x-rights-conditions' => [ + 'read' => $schemas['User']['x-rights-conditions']['read'], + ], + 'properties' => [ + 'id' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'description' => 'User ID', + 'readOnly' => true, + ], + 'language' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'description' => 'Language code (POSIX compliant format e.g. en_US or fr_FR)', + ], + 'use_mode' => [ + 'type' => Doc\Schema::TYPE_NUMBER, + 'enum' => [Session::NORMAL_MODE, Session::DEBUG_MODE], + 'description' => << [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'min' => 5, + 'multipleOf' => 5, + ], + 'date_format' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'enum' => [0, 1, 2], + 'description' => << [ + 'type' => Doc\Schema::TYPE_STRING, + 'enum' => [0, 1, 2, 3, 4], + 'description' => << [ + 'type' => Doc\Schema::TYPE_STRING, + 'enum' => [User::REALNAME_BEFORE, User::FIRSTNAME_BEFORE], + 'description' => << 'names_format', + ], + 'csv_delimiter' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'enum' => [';', ','], + ], + 'is_ids_visible' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + ], + 'use_flat_dropdowntree' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Display the tree dropdown complete name in dropdown inputs', + ], + 'use_flat_dropdowntree_on_search_result' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Display the complete name of tree dropdown in search results', + ], + 'show_new_tickets_on_home' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Show new tickets on the home page', + 'x-field' => 'show_jobs_at_login', + ], + 'priority_color_verylow' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'description' => 'Hex color code for very low priority', + 'pattern' => Doc\Schema::PATTERN_COLOR_HEX, + 'x-field' => 'priority_1', + ], + 'priority_color_low' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'description' => 'Hex color code for low priority', + 'pattern' => Doc\Schema::PATTERN_COLOR_HEX, + 'x-field' => 'priority_2', + ], + 'priority_color_medium' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'description' => 'Hex color code for medium priority', + 'pattern' => Doc\Schema::PATTERN_COLOR_HEX, + 'x-field' => 'priority_3', + ], + 'priority_color_high' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'description' => 'Hex color code for high priority', + 'pattern' => Doc\Schema::PATTERN_COLOR_HEX, + 'x-field' => 'priority_4', + ], + 'priority_color_veryhigh' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'description' => 'Hex color code for very high priority', + 'pattern' => Doc\Schema::PATTERN_COLOR_HEX, + 'x-field' => 'priority_5', + ], + 'priority_color_major' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'description' => 'Hex color code for major priority', + 'pattern' => Doc\Schema::PATTERN_COLOR_HEX, + 'x-field' => 'priority_6', + ], + 'private_followups_by_default' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Private followups by default', + 'x-field' => 'followup_private', + ], + 'private_tasks_by_default' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Private tasks by default', + 'x-field' => 'task_private', + ], + 'default_requesttype' => self::getDropdownTypeSchema(class: 'RequestType', field: 'default_requesttypes_id', full_schema: 'RequestType'), + 'show_count_on_tabs' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Show counters on tabs', + ], + 'refresh_view_interval' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'description' => 'Auto-refresh interval for tickets list, kanbans, and dashboards in minutes', + 'min' => 0, + 'max' => 30, + 'x-field' => 'refresh_views', + ], + 'set_default_tech' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Pre-select me as a technician when creating a ticket', + ], + 'set_default_requester' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Pre-select me as a requester when creating a ticket', + ], + 'set_followup_tech' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Add me as a technician when adding a ticket followup', + ], + 'set_solution_tech' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Add me as a technician when adding a ticket solution', + ], + 'home_list_limit' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'min' => 0, + 'max' => 30, + 'description' => 'Results to display on home page', + 'x-field' => 'display_count_on_home', + ], + 'notification_to_myself' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Notifications for my changes', + ], + 'duedate_color_ok' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'description' => 'Hex color code for on-time due dates', + 'pattern' => Doc\Schema::PATTERN_COLOR_HEX, + 'x-field' => 'duedateok_color', + ], + 'duedate_color_warning' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'description' => 'Hex color code for warning due dates', + 'pattern' => Doc\Schema::PATTERN_COLOR_HEX, + 'x-field' => 'duedatewarning_color', + ], + 'duedate_color_critical' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'description' => 'Hex color code for overdue due dates', + 'pattern' => Doc\Schema::PATTERN_COLOR_HEX, + 'x-field' => 'duedatecritical_color', + ], + 'duedate_threshold_warning' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'x-field' => 'duedatewarning_less', + ], + 'duedate_threshold_warning_unit' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'enum' => ['%', 'hours', 'days'], + 'x-field' => 'duedatewarning_unit', + ], + 'duedate_threshold_critical' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'x-field' => 'duedatecritical_less', + ], + 'duedate_threshold_critical_unit' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'enum' => ['%', 'hours', 'days'], + 'x-field' => 'duedatecritical_unit', + ], + 'pdf_font' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'description' => 'PDF export font', + 'enum' => array_keys(\GLPIPDF::getFontList()), + 'x-field' => 'pdffont', + ], + 'keep_devices_when_purging_item' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Keep linked devices when purging an item', + ], + 'show_new_item_after_creation' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Go to created item after creation', + 'x-field' => 'backcreated', + ], + 'default_task_state' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'enum' => [Planning::INFO, Planning::TODO, Planning::DONE], + 'description' => << 'task_state', + ], + 'default_task_state_planned' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'enum' => [Planning::INFO, Planning::TODO, Planning::DONE], + 'description' => << 'planned_task_state', + ], + 'palette' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'description' => 'Color palette/theme', + 'enum' => array_map(static fn($theme) => $theme->getKey(), ThemeManager::getInstance()->getAllThemes()), + ], + 'page_layout' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'enum' => ['horizontal', 'vertical'], + ], + 'timeline_order' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'enum' => [CommonITILObject::TIMELINE_ORDER_NATURAL, CommonITILObject::TIMELINE_ORDER_REVERSE], + 'description' => << [ + 'type' => Doc\Schema::TYPE_STRING, + 'enum' => ['inline', 'classic'], + 'description' => << [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Automatically lock items when editing', + 'x-field' => 'lock_autolock_mode', + ], + 'directunlock_notification' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Direct Notification (requester for unlock will be the notification sender)', + 'x-field' => 'lock_directunlock_notification', + ], + 'highcontrast_css' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Enable high contrast', + 'x-field' => 'highcontrast_css', + ], + //TODO Add default dashboard options when dashboards added to HLAPI + 'default_homepage_tab' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'enum' => [0, 1, 2, 3, 4], + 'description' => << 'default_central_tab', + ], + 'toast_location' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'enum' => ['top-lest', 'top-right', 'bottom-left', 'bottom-right'], + 'description' => 'Location for toast notifications', + ], + 'timeline_action_button_layout' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'enum' => [0, 1], + 'description' => << 'timeline_action_btn_layout', + ], + 'timeline_date_format' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'enum' => [0, 1], + 'description' => << [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Enable notifications by default. If disabled, notifications on tickets, change and problems can be optionally enabled as needed but other items will not send notifications at all', + 'x-field' => 'is_notif_enable_default', + ], + 'show_search_form' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Show search form above results', + ], + 'search_pagination_on_top' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Show search pagination above results', + ], + ], + ]; + + return $schemas; } #[Route(path: '/User', methods: ['GET'], middlewares: [ResultFormatterMiddleware::class])] @@ -837,4 +1210,56 @@ public function deleteProfileByID(Request $request): Response { return ResourceAccessor::deleteBySchema($this->getKnownSchema('Profile', $this->getAPIVersion($request)), $request->getAttributes(), $request->getParameters()); } + + #[Route(path: '/User/{id}/Preference', methods: ['GET'], requirements: ['id' => '\d+'], middlewares: [ResultFormatterMiddleware::class])] + #[RouteVersion(introduced: '2.1')] + #[Doc\GetRoute(schema_name: 'UserPreferences')] + public function getUserPreferencesByID(Request $request): Response + { + return ResourceAccessor::getOneBySchema($this->getKnownSchema('UserPreferences', $this->getAPIVersion($request)), $request->getAttributes(), $request->getParameters()); + } + + #[Route(path: '/User/Me/Preference', methods: ['GET'], middlewares: [ResultFormatterMiddleware::class])] + #[RouteVersion(introduced: '2.1')] + #[Doc\GetRoute(schema_name: 'UserPreferences')] + public function getMyPreferences(Request $request): Response + { + $my_user_id = $this->getMyUserID(); + return ResourceAccessor::getOneBySchema($this->getKnownSchema('UserPreferences', $this->getAPIVersion($request)), ['id' => $my_user_id], $request->getParameters()); + } + + #[Route(path: '/User/{username}/Preference', methods: ['GET'], requirements: ['username' => '[a-zA-Z0-9_]+'], middlewares: [ResultFormatterMiddleware::class])] + #[RouteVersion(introduced: '2.1')] + #[Doc\GetRoute(schema_name: 'UserPreferences')] + public function getUserPreferencesByUsername(Request $request): Response + { + $users_id = ResourceAccessor::getIDForOtherUniqueFieldBySchema($this->getKnownSchema('User', $this->getAPIVersion($request)), 'username', $request->getAttribute('username')); + return ResourceAccessor::getOneBySchema($this->getKnownSchema('UserPreferences', $this->getAPIVersion($request)), ['id' => $users_id], $request->getParameters()); + } + + #[Route(path: '/User/{id}/Preference', methods: ['PATCH'], requirements: ['id' => '\d+'])] + #[RouteVersion(introduced: '2.1')] + #[Doc\UpdateRoute(schema_name: 'UserPreferences')] + public function updateUserPreferencesByID(Request $request): Response + { + return ResourceAccessor::updateBySchema($this->getKnownSchema('UserPreferences', $this->getAPIVersion($request)), $request->getAttributes(), $request->getParameters()); + } + + #[Route(path: '/User/Me/Preference', methods: ['PATCH'])] + #[RouteVersion(introduced: '2.1')] + #[Doc\UpdateRoute(schema_name: 'UserPreferences')] + public function updateMyPreferences(Request $request): Response + { + $my_user_id = $this->getMyUserID(); + return ResourceAccessor::updateBySchema($this->getKnownSchema('UserPreferences', $this->getAPIVersion($request)), ['id' => $my_user_id], $request->getParameters()); + } + + #[Route(path: '/User/{username}/Preference', methods: ['PATCH'], requirements: ['username' => '[a-zA-Z0-9_]+'])] + #[RouteVersion(introduced: '2.1')] + #[Doc\UpdateRoute(schema_name: 'UserPreferences')] + public function updateUserPreferencesByUsername(Request $request): Response + { + $users_id = ResourceAccessor::getIDForOtherUniqueFieldBySchema($this->getKnownSchema('User', $this->getAPIVersion($request)), 'username', $request->getAttribute('username')); + return ResourceAccessor::updateBySchema($this->getKnownSchema('UserPreferences', $this->getAPIVersion($request)), ['id' => $users_id], $request->getParameters()); + } } diff --git a/src/Glpi/Api/HL/Controller/AssetController.php b/src/Glpi/Api/HL/Controller/AssetController.php index 1cf49d94bcd..323e9bfc747 100644 --- a/src/Glpi/Api/HL/Controller/AssetController.php +++ b/src/Glpi/Api/HL/Controller/AssetController.php @@ -550,6 +550,19 @@ class: User::class, } } + // Post v2 additions to general assets + $schemas['SoftwareLicense']['properties']['completename'] = [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'readOnly' => true, + ]; + $schemas['SoftwareLicense']['properties']['level'] = [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_INTEGER, + 'readOnly' => true, + ]; + + // Additional asset schemas $schemas['Cartridge'] = [ 'x-version-introduced' => '2.0', 'x-itemtype' => Cartridge::class, diff --git a/src/Glpi/Api/HL/Controller/CoreController.php b/src/Glpi/Api/HL/Controller/CoreController.php index 6b93c7d3e9a..0a12255653e 100644 --- a/src/Glpi/Api/HL/Controller/CoreController.php +++ b/src/Glpi/Api/HL/Controller/CoreController.php @@ -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 .= << @@ -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)] @@ -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) { diff --git a/src/Glpi/Api/HL/Controller/ITILController.php b/src/Glpi/Api/HL/Controller/ITILController.php index ef201e409f4..2d3792ff95f 100644 --- a/src/Glpi/Api/HL/Controller/ITILController.php +++ b/src/Glpi/Api/HL/Controller/ITILController.php @@ -62,6 +62,8 @@ use ITILFollowup; use ITILSolution; use Location; +use OLA; +use OlaLevel; use Planning; use PlanningEventCategory; use PlanningExternalEvent; @@ -71,6 +73,8 @@ use RecurrentChange; use RequestType; use Session; +use SLA; +use SlaLevel; use TaskCategory; use Ticket; use TicketRecurrent; @@ -122,6 +126,11 @@ public static function getRawKnownSchemas(): array 'type' => Doc\Schema::TYPE_STRING, 'readOnly' => true, ], + 'level' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_INTEGER, + 'readOnly' => true, + ], 'comment' => ['type' => Doc\Schema::TYPE_STRING], 'entity' => self::getDropdownTypeSchema(class: Entity::class, full_schema: 'Entity'), 'is_recursive' => ['type' => Doc\Schema::TYPE_BOOLEAN], @@ -180,6 +189,8 @@ public static function getRawKnownSchemas(): array ], 'name' => ['type' => Doc\Schema::TYPE_STRING], 'content' => ['type' => Doc\Schema::TYPE_STRING], + 'user_recipient' => self::getDropdownTypeSchema(class: User::class, field: 'users_id_recipient', full_schema: 'User') + ['x-version-introduced' => '2.1.0'], + 'user_editor' => self::getDropdownTypeSchema(class: User::class, field: 'users_id_lastupdater', full_schema: 'User') + ['x-version-introduced' => '2.1.0'], 'is_deleted' => ['type' => Doc\Schema::TYPE_BOOLEAN], 'category' => self::getDropdownTypeSchema(class: ITILCategory::class, full_schema: 'ITILCategory'), 'location' => self::getDropdownTypeSchema(class: Location::class, full_schema: 'Location'), @@ -202,9 +213,54 @@ public static function getRawKnownSchemas(): array 'type' => Doc\Schema::TYPE_INTEGER, 'readOnly' => true, ], + 'begin_waiting_date' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'readOnly' => true, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + ], + 'waiting_duration' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_INTEGER, + 'readOnly' => true, + 'description' => 'Total waiting duration in seconds', + ], + 'resolution_duration' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_INTEGER, + 'readOnly' => true, + 'x-field' => 'solve_delay_stat', + 'description' => 'Total resolution duration in seconds', + ], + 'close_duration' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_INTEGER, + 'readOnly' => true, + 'x-field' => 'close_delay_stat', + 'description' => 'Total close duration in seconds', + ], + 'resolution_date' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + 'readOnly' => true, + 'x-field' => 'time_to_resolve', + ], 'date_creation' => ['type' => Doc\Schema::TYPE_STRING, 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME], 'date_mod' => ['type' => Doc\Schema::TYPE_STRING, 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME], 'date' => ['type' => Doc\Schema::TYPE_STRING, 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME], + 'date_solve' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + 'x-field' => 'solvedate', + ], + 'date_close' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + 'x-field' => 'closedate', + ], ], ]; @@ -376,6 +432,85 @@ public static function getRawKnownSchemas(): array 'type' => Doc\Schema::TYPE_STRING, ]; $schemas[$itil_type]['properties']['request_type'] = self::getDropdownTypeSchema(class: RequestType::class, full_schema: 'RequestType'); + + // SLA/OLA Properties + $schemas[$itil_type]['properties']['take_into_account_date'] = [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + 'readOnly' => true, + 'x-field' => 'takeintoaccountdate', + ]; + $schemas[$itil_type]['properties']['take_into_account_duration'] = [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_INTEGER, + 'readOnly' => true, + 'description' => 'Total take into account duration in seconds', + 'x-field' => 'takeintoaccount_delay_stat', + ]; + $schemas[$itil_type]['properties']['sla_ttr'] = self::getDropdownTypeSchema(class: SLA::class, field: 'slas_id_ttr', full_schema: 'SLA') + ['x-version-introduced' => '2.1.0']; + $schemas[$itil_type]['properties']['sla_tto'] = self::getDropdownTypeSchema(class: SLA::class, field: 'slas_id_tto', full_schema: 'SLA') + ['x-version-introduced' => '2.1.0']; + $schemas[$itil_type]['properties']['ola_ttr'] = self::getDropdownTypeSchema(class: OLA::class, field: 'olas_id_ttr', full_schema: 'OLA') + ['x-version-introduced' => '2.1.0']; + $schemas[$itil_type]['properties']['ola_tto'] = self::getDropdownTypeSchema(class: OLA::class, field: 'olas_id_tto', full_schema: 'OLA') + ['x-version-introduced' => '2.1.0']; + $schemas[$itil_type]['properties']['sla_level_ttr'] = self::getDropdownTypeSchema(class: SlaLevel::class, field: 'slalevels_id_ttr', full_schema: 'SLALevel') + ['x-version-introduced' => '2.1.0']; + $schemas[$itil_type]['properties']['ola_level_ttr'] = self::getDropdownTypeSchema(class: OlaLevel::class, field: 'olalevels_id_ttr', full_schema: 'OLALevel') + ['x-version-introduced' => '2.1.0']; + $schemas[$itil_type]['properties']['sla_waiting_duration'] = [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_INTEGER, + 'readOnly' => true, + 'description' => 'Total SLA waiting duration in seconds', + ]; + $schemas[$itil_type]['properties']['ola_waiting_duration'] = [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_INTEGER, + 'readOnly' => true, + 'description' => 'Total OLA waiting duration in seconds', + ]; + $schemas[$itil_type]['properties']['ola_ttr_begin_date'] = [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'readOnly' => true, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + ]; + $schemas[$itil_type]['properties']['ola_tto_begin_date'] = [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'readOnly' => true, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + ]; + $schemas[$itil_type]['properties']['internal_resolution_date'] = [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + 'readOnly' => true, + 'x-field' => 'internal_time_to_resolve', + ]; + $schemas[$itil_type]['properties']['internal_take_into_account_date'] = [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + 'readOnly' => true, + 'x-field' => 'internal_time_to_own', + ]; + } + if ($itil_type === Ticket::class || $itil_type === Change::class) { + $schemas[$itil_type]['properties']['global_validation'] = [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_INTEGER, + 'enum' => [ + CommonITILValidation::NONE, + CommonITILValidation::WAITING, + CommonITILValidation::ACCEPTED, + CommonITILValidation::REFUSED, + ], + 'description' => << Doc\Schema::TYPE_OBJECT, 'x-rights-conditions' => [ // Object-level extra permissions @@ -446,11 +599,46 @@ public static function getRawKnownSchemas(): array 'format' => Doc\Schema::FORMAT_INTEGER_INT64, 'readOnly' => true, ], + 'uuid' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::PATTERN_UUIDV4, + 'readOnly' => true, + ], 'content' => ['type' => Doc\Schema::TYPE_STRING], 'is_private' => ['type' => Doc\Schema::TYPE_BOOLEAN], 'user' => self::getDropdownTypeSchema(class: User::class, full_schema: 'User'), 'user_editor' => self::getDropdownTypeSchema(class: User::class, field: 'users_id_editor', full_schema: 'User'), + 'user_tech' => self::getDropdownTypeSchema(class: User::class, field: 'users_id_tech', full_schema: 'User') + ['x-version-introduced' => '2.1.0'], + 'group_tech' => self::getDropdownTypeSchema(class: Group::class, field: 'groups_id_tech', full_schema: 'Group') + ['x-version-introduced' => '2.1.0'], + 'date' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + ], + 'date_creation' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + ], + 'date_mod' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + ], 'duration' => ['type' => Doc\Schema::TYPE_INTEGER, 'x-field' => 'actiontime'], + 'planned_begin' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + 'x-field' => 'begin', + ], + 'planned_end' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + 'x-field' => 'end', + ], 'state' => [ 'type' => Doc\Schema::TYPE_INTEGER, 'enum' => [ @@ -466,6 +654,12 @@ public static function getRawKnownSchemas(): array EOT, ], 'category' => self::getDropdownTypeSchema(class: TaskCategory::class, full_schema: 'TaskCategory'), + 'timeline_position' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_NUMBER, + 'enum' => $timeline_position_enum, + 'description' => $timeline_position_description, + ], ], ]; @@ -473,6 +667,18 @@ public static function getRawKnownSchemas(): array $schemas['TicketTask']['x-version-introduced'] = '2.0'; $schemas['TicketTask']['x-itemtype'] = TicketTask::class; $schemas['TicketTask']['properties'][Ticket::getForeignKeyField()] = ['type' => Doc\Schema::TYPE_INTEGER, 'format' => Doc\Schema::FORMAT_INTEGER_INT64]; + $schemas['TicketTask']['properties']['source_item_id'] = [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'x-field' => 'sourceitems_id', + ]; + $schemas['TicketTask']['properties']['source_of_item_id'] = [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'x-field' => 'sourceof_items_id', + ]; $schemas['ChangeTask'] = $base_task_schema; $schemas['ChangeTask']['x-version-introduced'] = '2.0'; @@ -496,6 +702,17 @@ public static function getRawKnownSchemas(): array ], 'name' => ['type' => Doc\Schema::TYPE_STRING], 'is_active' => ['type' => Doc\Schema::TYPE_BOOLEAN], + 'completename' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'readOnly' => true, + ], + 'parent' => self::getDropdownTypeSchema(class: TaskCategory::class, full_schema: 'TaskCategory') + ['x-version-introduced' => '2.1.0'], + 'level' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_INTEGER, + 'readOnly' => true, + ], ], ]; @@ -561,8 +778,31 @@ public static function getRawKnownSchemas(): array 'user' => self::getDropdownTypeSchema(class: User::class, full_schema: 'User'), 'user_editor' => self::getDropdownTypeSchema(class: User::class, field: 'users_id_editor', full_schema: 'User'), 'request_type' => self::getDropdownTypeSchema(RequestType::class, full_schema: 'RequestType'), + 'date' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + ], 'date_creation' => ['type' => Doc\Schema::TYPE_STRING, 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME], 'date_mod' => ['type' => Doc\Schema::TYPE_STRING, 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME], + 'timeline_position' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_NUMBER, + 'enum' => $timeline_position_enum, + 'description' => $timeline_position_description, + ], + 'source_item_id' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'x-field' => 'sourceitems_id', + ], + 'source_of_item_id' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'x-field' => 'sourceof_items_id', + ], ], ]; @@ -581,6 +821,56 @@ public static function getRawKnownSchemas(): array 'content' => ['type' => Doc\Schema::TYPE_STRING], 'user' => self::getDropdownTypeSchema(class: User::class, full_schema: 'User'), 'user_editor' => self::getDropdownTypeSchema(class: User::class, field: 'users_id_editor', full_schema: 'User'), + 'approver' => self::getDropdownTypeSchema(class: User::class, field: 'users_id_approval', full_schema: 'User') + ['x-version-introduced' => '2.1.0'], + 'status' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'enum' => [ + CommonITILValidation::NONE, + CommonITILValidation::WAITING, + CommonITILValidation::ACCEPTED, + CommonITILValidation::REFUSED, + ], + 'description' => << [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_OBJECT, + 'x-field' => ITILFollowup::getForeignKeyField(), + 'x-itemtype' => ITILFollowup::class, + 'x-join' => [ + 'table' => ITILFollowup::getTable(), + 'fkey' => ITILFollowup::getForeignKeyField(), + 'field' => 'id', + ], + 'properties' => [ + 'id' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'readOnly' => true, + ], + ], + ], + 'date_creation' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + ], + 'date_mod' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + ], + 'date_approval' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + ], ], ]; @@ -627,6 +917,12 @@ public static function getRawKnownSchemas(): array ], 'submission_date' => ['type' => Doc\Schema::TYPE_STRING, 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME], 'approval_date' => ['type' => Doc\Schema::TYPE_STRING, 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, 'x-field' => 'validation_date'], + 'timeline_position' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_NUMBER, + 'enum' => $timeline_position_enum, + 'description' => $timeline_position_description, + ], ], ]; diff --git a/src/Glpi/Api/HL/Controller/ManagementController.php b/src/Glpi/Api/HL/Controller/ManagementController.php index 666ace6f7d6..9644fc470a6 100644 --- a/src/Glpi/Api/HL/Controller/ManagementController.php +++ b/src/Glpi/Api/HL/Controller/ManagementController.php @@ -40,6 +40,7 @@ use BusinessCriticity; use Cluster; use CommonDBTM; +use CommonITILObject; use Contact; use Contract; use Database; @@ -370,9 +371,47 @@ protected static function getRawKnownSchemas(): array 'x-mapped-from' => 'documents_id', 'x-mapper' => static fn($v) => $CFG_GLPI["root_doc"] . "/front/document.send.php?docid=" . $v, ], + 'timeline_position' => [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_NUMBER, + 'enum' => [ + CommonITILObject::NO_TIMELINE, + CommonITILObject::TIMELINE_NOTSET, + CommonITILObject::TIMELINE_LEFT, + CommonITILObject::TIMELINE_MIDLEFT, + CommonITILObject::TIMELINE_MIDRIGHT, + CommonITILObject::TIMELINE_RIGHT, + ], + 'description' => << '2.1.0', + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'readOnly' => true, + ]; + $schemas['License']['properties']['completename'] = [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_STRING, + 'readOnly' => true, + ]; + $schemas['License']['properties']['level'] = [ + 'x-version-introduced' => '2.1.0', + 'type' => Doc\Schema::TYPE_INTEGER, + 'readOnly' => true, + ]; + $schemas['Infocom'] = [ 'x-version-introduced' => '2.0', 'type' => Doc\Schema::TYPE_OBJECT, diff --git a/src/Glpi/Api/HL/Controller/SetupController.php b/src/Glpi/Api/HL/Controller/SetupController.php index dc55400a9cf..dbd6a165e2a 100644 --- a/src/Glpi/Api/HL/Controller/SetupController.php +++ b/src/Glpi/Api/HL/Controller/SetupController.php @@ -36,7 +36,10 @@ namespace Glpi\Api\HL\Controller; use AuthLDAP; +use Calendar; use CommonDBTM; +use Config; +use Entity; use Glpi\Api\HL\Doc as Doc; use Glpi\Api\HL\Middleware\ResultFormatterMiddleware; use Glpi\Api\HL\ResourceAccessor; @@ -45,12 +48,83 @@ use Glpi\Http\JSONResponse; use Glpi\Http\Request; use Glpi\Http\Response; +use OLA; +use OlaLevel; +use SLA; +use SlaLevel; +use SLM; #[Route(path: '/Setup', tags: ['Setup'])] final class SetupController extends AbstractController { public static function getRawKnownSchemas(): array { + global $DB; + + $base_la_properties = [ + 'id' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'readOnly' => true, + ], + 'name' => ['type' => Doc\Schema::TYPE_STRING], + 'slm' => self::getDropdownTypeSchema(class: SLM::class, full_schema: 'SLM'), + 'entity' => self::getDropdownTypeSchema(class: Entity::class, full_schema: 'Entity'), + 'is_recursive' => ['type' => Doc\Schema::TYPE_BOOLEAN], + 'type' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'enum' => [SLM::TTR, SLM::TTO], + 'description' => << ['type' => Doc\Schema::TYPE_STRING], + 'time' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'x-field' => 'number_time', + 'description' => 'Time in the unit defined by the time_unit property', + ], + 'time_unit' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'enum' => ['minute', 'hour', 'day', 'month'], + 'description' => 'Unit of time for the time property', + ], + 'use_ticket_calendar' => ['type' => Doc\Schema::TYPE_BOOLEAN], + 'calendar' => self::getDropdownTypeSchema(class: Calendar::class, full_schema: 'Calendar'), + 'end_of_working_day' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Whether the time computation will target the end of the working day', + ], + 'date_creation' => ['type' => Doc\Schema::TYPE_STRING, 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME], + 'date_mod' => ['type' => Doc\Schema::TYPE_STRING, 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME], + ]; + + $base_la_level_properties = [ + 'id' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'readOnly' => true, + ], + 'uuid' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'pattern' => Doc\Schema::PATTERN_UUIDV4, + 'readOnly' => true, + ], + 'name' => ['type' => Doc\Schema::TYPE_STRING], + 'entity' => self::getDropdownTypeSchema(class: Entity::class, full_schema: 'Entity'), + 'is_recursive' => ['type' => Doc\Schema::TYPE_BOOLEAN], + 'execution_time' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'readOnly' => true, + ], + 'operator' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'enum' => ['AND', 'OR'], + 'x-field' => 'match', + ], + ]; + return [ 'LDAPDirectory' => [ 'x-version-introduced' => '2.0', @@ -104,6 +178,92 @@ public static function getRawKnownSchemas(): array ], ], ], + 'Config' => [ + 'x-version-introduced' => '2.1', + 'x-itemtype' => Config::class, + 'type' => Doc\Schema::TYPE_OBJECT, + 'properties' => [ + 'id' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'readOnly' => true, + ], + 'context' => ['type' => Doc\Schema::TYPE_STRING], + 'name' => ['type' => Doc\Schema::TYPE_STRING], + 'value' => ['type' => Doc\Schema::TYPE_STRING], + ], + 'x-rights-conditions' => [ + 'read' => static function () use ($DB) { + // Make a SQL request to get all config items so we can check which are undisclosed + // We are using safe IDs rather than undisclosed IDs to avoid issues with concurrent modifications + // We cannot reliably lock the table due to the fact that the DB connection here may differ from the one used to perform the actual read in the Search code + $disclosed_ids = []; + + $it = $DB->request([ + 'SELECT' => ['id', 'context', 'name'], + 'FROM' => 'glpi_configs', + ]); + $test_configs = []; + foreach ($it as $row) { + $test_configs[] = $row + ['value' => 'dummy']; + } + foreach ($test_configs as $f) { + if (!self::isUndisclosedConfig($f['context'], $f['name'])) { + $disclosed_ids[] = $f['id']; + } + } + return ['WHERE' => ['_.id' => $disclosed_ids]]; + }, + ], + ], + 'SLM' => [ + 'x-version-introduced' => '2.1.0', + 'x-itemtype' => SLM::class, + 'type' => Doc\Schema::TYPE_OBJECT, + 'properties' => [ + 'id' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'readOnly' => true, + ], + 'name' => ['type' => Doc\Schema::TYPE_STRING], + 'entity' => self::getDropdownTypeSchema(class: Entity::class, full_schema: 'Entity'), + 'is_recursive' => ['type' => Doc\Schema::TYPE_BOOLEAN], + 'comment' => ['type' => Doc\Schema::TYPE_STRING], + 'use_ticket_calendar' => ['type' => Doc\Schema::TYPE_BOOLEAN], + 'calendar' => self::getDropdownTypeSchema(class: Calendar::class, full_schema: 'Calendar'), + 'date_creation' => ['type' => Doc\Schema::TYPE_STRING, 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME], + 'date_mod' => ['type' => Doc\Schema::TYPE_STRING, 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME], + ], + ], + 'SLA' => [ + 'x-version-introduced' => '2.1.0', + 'x-itemtype' => SLA::class, + 'type' => Doc\Schema::TYPE_OBJECT, + 'properties' => $base_la_properties, + ], + 'OLA' => [ + 'x-version-introduced' => '2.1.0', + 'x-itemtype' => OLA::class, + 'type' => Doc\Schema::TYPE_OBJECT, + 'properties' => $base_la_properties, + ], + 'SLALevel' => [ + 'x-version-introduced' => '2.1.0', + 'x-itemtype' => SlaLevel::class, + 'type' => Doc\Schema::TYPE_OBJECT, + 'properties' => $base_la_level_properties + [ + 'sla' => self::getDropdownTypeSchema(class: SLA::class, full_schema: 'SLA'), + ], + ], + 'OLALevel' => [ + 'x-version-introduced' => '2.1.0', + 'x-itemtype' => OlaLevel::class, + 'type' => Doc\Schema::TYPE_OBJECT, + 'properties' => $base_la_level_properties + [ + 'ola' => self::getDropdownTypeSchema(class: OLA::class, full_schema: 'OLA'), + ], + ], ]; } @@ -118,6 +278,7 @@ public static function getSetupTypes(bool $types_only = true): array if ($types === null) { $types = [ 'LDAPDirectory' => AuthLDAP::getTypeName(1), + // Do not add Config here as it is handled specially ]; } return $types_only ? array_keys($types) : $types; @@ -212,4 +373,107 @@ public function deleteItem(Request $request): Response $itemtype = $request->getAttribute('itemtype'); return ResourceAccessor::deleteBySchema($this->getKnownSchema($itemtype, $this->getAPIVersion($request)), $request->getAttributes(), $request->getParameters()); } + + private static function isUndisclosedConfig(string $context, string $name): bool + { + $f = ['context' => $context, 'name' => $name, 'value' => 'dummy']; + Config::unsetUndisclosedFields($f); + return !array_key_exists('value', $f); + } + + #[Route(path: '/Config/{context}/{name}', methods: ['PATCH'], requirements: [ + 'context' => '\w+', + 'name' => '\w+', + ], middlewares: [ResultFormatterMiddleware::class])] + #[RouteVersion(introduced: '2.1')] + #[Doc\UpdateRoute(schema_name: 'Config')] + public function setConfigValue(Request $request): Response + { + // Skip using ResourceAccessor given the particularities of Config + if (!Config::canUpdate()) { + return AbstractController::getAccessDeniedErrorResponse(); + } + $context = $request->getAttribute('context'); + $name = $request->getAttribute('name'); + $value = $request->getParameter('value'); + Config::setConfigurationValues($context, [$name => $value]); + // Return the updated config + if (self::isUndisclosedConfig($context, $name)) { + // If the field is undisclosed, only return a 204 to indicate success without revealing the value + return new JSONResponse(null, 204); + } + return new JSONResponse([ + 'context' => $context, + 'name' => $name, + 'value' => Config::getConfigurationValue($context, $name), + ]); + } + + #[Route(path: '/Config', methods: ['GET'], middlewares: [ResultFormatterMiddleware::class])] + #[RouteVersion(introduced: '2.1')] + #[Doc\SearchRoute(schema_name: 'Config')] + public function searchConfigValues(Request $request): Response + { + return ResourceAccessor::searchBySchema($this->getKnownSchema('Config', $this->getAPIVersion($request)), $request->getParameters()); + } + + #[Route(path: '/Config/{context}', methods: ['GET'], requirements: [ + 'context' => '\w+', + ], middlewares: [ResultFormatterMiddleware::class])] + #[RouteVersion(introduced: '2.1')] + #[Doc\SearchRoute(schema_name: 'Config')] + public function searchConfigValuesByContext(Request $request): Response + { + $filters = $request->hasParameter('filter') ? $request->getParameter('filter') : ''; + $filters .= ';context==' . $request->getAttribute('context'); + $request->setParameter('filter', $filters); + return ResourceAccessor::searchBySchema($this->getKnownSchema('Config', $this->getAPIVersion($request)), $request->getParameters()); + } + + #[Route(path: '/Config/{context}/{name}', methods: ['GET'], requirements: [ + 'context' => '\w+', + 'name' => '\w+', + ], middlewares: [ResultFormatterMiddleware::class])] + #[RouteVersion(introduced: '2.1')] + #[Doc\GetRoute(schema_name: 'Config')] + public function getConfigValue(Request $request): Response + { + // Skip using ResourceAccessor given the particularities of Config + $context = $request->getAttribute('context'); + $name = $request->getAttribute('name'); + $config = new Config(); + if (!$config->getFromDBByCrit(['context' => $context, 'name' => $name,])) { + return AbstractController::getNotFoundErrorResponse(); + } + if (self::isUndisclosedConfig($context, $name) || !$config->can($config->getID(), READ)) { + return AbstractController::getAccessDeniedErrorResponse(); + } + return new JSONResponse([ + 'context' => $context, + 'name' => $name, + 'value' => Config::getConfigurationValue($context, $name), + ]); + } + + #[Route(path: '/Config/{context}/{name}', methods: ['DELETE'], requirements: [ + 'context' => '\w+', + 'name' => '\w+', + ])] + #[RouteVersion(introduced: '2.1')] + #[Doc\DeleteRoute(schema_name: 'Config')] + public function deleteConfigValue(Request $request): Response + { + // Skip using ResourceAccessor given the particularities of Config + if (!Config::canUpdate()) { + return AbstractController::getAccessDeniedErrorResponse(); + } + $context = $request->getAttribute('context'); + $name = $request->getAttribute('name'); + $config = new Config(); + if (!$config->getFromDBByCrit(['context' => $context, 'name' => $name])) { + return AbstractController::getNotFoundErrorResponse(); + } + Config::deleteConfigurationValues($context, [$name]); + return new JSONResponse(null, 204); + } } diff --git a/src/Glpi/Api/HL/Controller/ToolController.php b/src/Glpi/Api/HL/Controller/ToolController.php new file mode 100644 index 00000000000..3c298ac4904 --- /dev/null +++ b/src/Glpi/Api/HL/Controller/ToolController.php @@ -0,0 +1,347 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Api\HL\Controller; + +use CommonDBTM; +use Entity; +use Glpi\Api\HL\Doc as Doc; +use Glpi\Api\HL\Middleware\ResultFormatterMiddleware; +use Glpi\Api\HL\ResourceAccessor; +use Glpi\Api\HL\Route; +use Glpi\Api\HL\RouteVersion; +use Glpi\Http\JSONResponse; +use Glpi\Http\Request; +use Glpi\Http\Response; +use Planning; +use Reminder; +use ReservationItem; +use RSSFeed; +use User; + +#[Route(path: '/Tools', tags: ['Tools'])] +final class ToolController extends AbstractController +{ + public static function getRawKnownSchemas(): array + { + return [ + 'Reminder' => [ + 'x-version-introduced' => '2.1.0', + 'x-itemtype' => Reminder::class, + 'type' => Doc\Schema::TYPE_OBJECT, + 'properties' => [ + 'id' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'readOnly' => true, + ], + 'uuid' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'pattern' => Doc\Schema::PATTERN_UUIDV4, + 'readOnly' => true, + ], + 'name' => ['type' => Doc\Schema::TYPE_STRING], + 'text' => ['type' => Doc\Schema::TYPE_STRING], + 'date' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + ], + 'user' => self::getDropdownTypeSchema(class: User::class, full_schema: 'User'), + 'date_begin' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + 'x-field' => 'begin', + ], + 'date_end' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + 'x-field' => 'end', + ], + 'is_planned' => ['type' => Doc\Schema::TYPE_BOOLEAN], + 'state' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'enum' => [Planning::INFO, Planning::TODO, Planning::DONE], + 'description' => << [ + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + 'x-field' => 'begin_view_date', + ], + 'date_view_end' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + 'x-field' => 'end_view_date', + ], + 'date_creation' => ['type' => Doc\Schema::TYPE_STRING, 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME], + 'date_mod' => ['type' => Doc\Schema::TYPE_STRING, 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME], + ], + ], + 'RSSFeed' => [ + 'x-version-introduced' => '2.1.0', + 'x-itemtype' => RSSFeed::class, + 'type' => Doc\Schema::TYPE_OBJECT, + 'properties' => [ + 'id' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'readOnly' => true, + ], + 'comment' => ['type' => Doc\Schema::TYPE_STRING], + 'url' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'required' => true, + ], + 'refresh_interval' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'description' => 'Refresh interval in seconds', + 'x-field' => 'refresh_rate', + 'min' => HOUR_TIMESTAMP, + 'max' => DAY_TIMESTAMP, + 'multipleOf' => HOUR_TIMESTAMP, + ], + 'max_items' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'description' => 'Maximum number of items to fetch', + ], + 'have_error' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'readOnly' => true, + 'description' => 'Whether the last fetch had errors', + ], + 'is_active' => ['type' => Doc\Schema::TYPE_BOOLEAN], + 'user' => self::getDropdownTypeSchema(class: User::class, full_schema: 'User'), + 'date_creation' => ['type' => Doc\Schema::TYPE_STRING, 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME], + 'date_mod' => ['type' => Doc\Schema::TYPE_STRING, 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME], + ], + ], + 'ReservableItem' => [ + 'x-version-introduced' => '2.1.0', + 'x-itemtype' => ReservationItem::class, + 'type' => Doc\Schema::TYPE_OBJECT, + 'properties' => [ + 'id' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'readOnly' => true, + ], + 'itemtype' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'description' => 'The itemtype of the reservable item', + ], + 'items_id' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'description' => 'The ID of the reservable item', + ], + 'comment' => ['type' => Doc\Schema::TYPE_STRING], + 'entity' => self::getDropdownTypeSchema(class: Entity::class, full_schema: 'Entity'), + 'is_recursive' => ['type' => Doc\Schema::TYPE_BOOLEAN], + 'is_active' => [ + 'type' => Doc\Schema::TYPE_BOOLEAN, + 'description' => 'Whether the item is currently active for reservations', + ], + ], + ], + 'Reservation' => [ + 'x-version-introduced' => '2.1.0', + 'x-itemtype' => ReservationItem::class, + 'type' => Doc\Schema::TYPE_OBJECT, + 'properties' => [ + 'id' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'readOnly' => true, + ], + 'reservable_item' => [ + 'type' => Doc\Schema::TYPE_OBJECT, + 'x-field' => 'reservationitems_id', + 'x-itemtype' => ReservationItem::class, + 'x-join' => [ + 'table' => ReservationItem::getTable(), + 'fkey' => 'reservationitems_id', + 'field' => 'id', + ], + 'properties' => [ + 'id' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'readOnly' => true, + ], + 'itemtype' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'description' => 'The itemtype of the reservable item', + ], + 'items_id' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'description' => 'The ID of the reservable item', + ], + ], + ], + 'comment' => ['type' => Doc\Schema::TYPE_STRING], + 'user' => self::getDropdownTypeSchema(class: User::class, full_schema: 'User'), + 'group' => [ + 'type' => Doc\Schema::TYPE_INTEGER, + 'format' => Doc\Schema::FORMAT_INTEGER_INT64, + 'description' => 'A random number used to identify reservations that are part of the same series (recurring reservations)', + ], + 'date_begin' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + 'x-field' => 'begin', + ], + 'date_end' => [ + 'type' => Doc\Schema::TYPE_STRING, + 'format' => Doc\Schema::FORMAT_STRING_DATE_TIME, + 'x-field' => 'end', + ], + ], + ], + ]; + } + + /** + * @param bool $types_only If true, only the type names are returned. If false, the type name => localized name pairs are returned. + * @return array, string> + */ + public static function getToolTypes(bool $types_only = true): array + { + static $types = null; + + if ($types === null) { + $types = [ + 'Reminder' => Reminder::getTypeName(1), + 'RSSFeed' => RSSFeed::getTypeName(1), + ]; + } + return $types_only ? array_keys($types) : $types; + } + + #[Route(path: '/', methods: ['GET'], middlewares: [ResultFormatterMiddleware::class])] + #[RouteVersion(introduced: '2.1')] + #[Doc\Route( + description: 'Get all available tool types', + responses: [ + new Doc\Response(new Doc\Schema( + type: Doc\Schema::TYPE_ARRAY, + items: new Doc\Schema( + type: Doc\Schema::TYPE_OBJECT, + properties: [ + 'itemtype' => new Doc\Schema(Doc\Schema::TYPE_STRING), + 'name' => new Doc\Schema(Doc\Schema::TYPE_STRING), + 'href' => new Doc\Schema(Doc\Schema::TYPE_STRING), + ] + ) + )), + ] + )] + public function index(Request $request): Response + { + $tool_types = self::getToolTypes(false); + $tool_paths = []; + foreach ($tool_types as $tool_type => $tool_name) { + $tool_paths[] = [ + 'itemtype' => $tool_type, + 'name' => $tool_name, + 'href' => self::getAPIPathForRouteFunction(self::class, 'search', ['itemtype' => $tool_type]), + ]; + } + return new JSONResponse($tool_paths); + } + + #[Route(path: '/{itemtype}', methods: ['GET'], requirements: [ + 'itemtype' => [self::class, 'getToolTypes'], + ], middlewares: [ResultFormatterMiddleware::class])] + #[RouteVersion(introduced: '2.1')] + #[Doc\SearchRoute(schema_name: '{itemtype}')] + public function search(Request $request): Response + { + $itemtype = $request->getAttribute('itemtype'); + return ResourceAccessor::searchBySchema($this->getKnownSchema($itemtype, $this->getAPIVersion($request)), $request->getParameters()); + } + + #[Route(path: '/{itemtype}/{id}', methods: ['GET'], requirements: [ + 'itemtype' => [self::class, 'getToolTypes'], + 'id' => '\d+', + ], middlewares: [ResultFormatterMiddleware::class])] + #[RouteVersion(introduced: '2.1')] + #[Doc\GetRoute(schema_name: '{itemtype}')] + public function getItem(Request $request): Response + { + $itemtype = $request->getAttribute('itemtype'); + return ResourceAccessor::getOneBySchema($this->getKnownSchema($itemtype, $this->getAPIVersion($request)), $request->getAttributes(), $request->getParameters()); + } + + #[Route(path: '/{itemtype}', methods: ['POST'], requirements: [ + 'itemtype' => [self::class, 'getToolTypes'], + ])] + #[RouteVersion(introduced: '2.1')] + #[Doc\CreateRoute(schema_name: '{itemtype}')] + public function createItem(Request $request): Response + { + $itemtype = $request->getAttribute('itemtype'); + return ResourceAccessor::createBySchema($this->getKnownSchema($itemtype, $this->getAPIVersion($request)), $request->getParameters() + ['itemtype' => $itemtype], [self::class, 'getItem']); + } + + #[Route(path: '/{itemtype}/{id}', methods: ['PATCH'], requirements: [ + 'itemtype' => [self::class, 'getToolTypes'], + 'id' => '\d+', + ])] + #[RouteVersion(introduced: '2.1')] + #[Doc\UpdateRoute(schema_name: '{itemtype}')] + public function updateItem(Request $request): Response + { + $itemtype = $request->getAttribute('itemtype'); + return ResourceAccessor::updateBySchema($this->getKnownSchema($itemtype, $this->getAPIVersion($request)), $request->getAttributes(), $request->getParameters()); + } + + #[Route(path: '/{itemtype}/{id}', methods: ['DELETE'], requirements: [ + 'itemtype' => [self::class, 'getToolTypes'], + 'id' => '\d+', + ])] + #[RouteVersion(introduced: '2.1')] + #[Doc\DeleteRoute(schema_name: '{itemtype}')] + public function deleteItem(Request $request): Response + { + $itemtype = $request->getAttribute('itemtype'); + return ResourceAccessor::deleteBySchema($this->getKnownSchema($itemtype, $this->getAPIVersion($request)), $request->getAttributes(), $request->getParameters()); + } +} diff --git a/src/Glpi/Api/HL/Doc/Schema.php b/src/Glpi/Api/HL/Doc/Schema.php index 3a2cc3b89ae..3784a96a1a4 100644 --- a/src/Glpi/Api/HL/Doc/Schema.php +++ b/src/Glpi/Api/HL/Doc/Schema.php @@ -338,6 +338,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 diff --git a/src/Glpi/Api/HL/OpenAPIGenerator.php b/src/Glpi/Api/HL/OpenAPIGenerator.php index eccc4ec896f..40824c19726 100644 --- a/src/Glpi/Api/HL/OpenAPIGenerator.php +++ b/src/Glpi/Api/HL/OpenAPIGenerator.php @@ -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', @@ -141,40 +141,62 @@ 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 $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 $itemtype */ + $itemtype = $known_schema['x-itemtype']; + $known_schema['description'] = $itemtype::getTypeName(1); + } + + // Add properties that have 'required' flags to a 'required' array on the nearest parent object + // We add the 'required' on individual properties so that it works well with the API version filtering + $fn_hoist_required_flags = static function (&$schema_part) use (&$fn_hoist_required_flags) { + if (is_array($schema_part)) { + if (isset($schema_part['properties']) && is_array($schema_part['properties'])) { + $required_fields = []; + foreach ($schema_part['properties'] as $prop_name => &$prop_value) { + if (is_array($prop_value)) { + if (isset($prop_value['required']) && $prop_value['required'] === true) { + $required_fields[] = $prop_name; + unset($prop_value['required']); + } + // Recurse into the property value + $fn_hoist_required_flags($prop_value); + } + } + unset($prop_value); + if (count($required_fields) > 0) { + $schema_part['required'] = $required_fields; + } + } + } + }; + $fn_hoist_required_flags($known_schema); + + $schemas[$calculated_name] = $known_schema; + $schemas[$calculated_name]['x-controller'] = $controller::class; + $schemas[$calculated_name]['x-schemaname'] = $schema_name; } } @@ -255,6 +277,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)); } diff --git a/src/Glpi/Api/HL/RoutePath.php b/src/Glpi/Api/HL/RoutePath.php index 29cf1c00e24..abf3b368848 100644 --- a/src/Glpi/Api/HL/RoutePath.php +++ b/src/Glpi/Api/HL/RoutePath.php @@ -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) diff --git a/src/Glpi/Api/HL/Router.php b/src/Glpi/Api/HL/Router.php index beb6487e26a..e72fdba7b9a 100644 --- a/src/Glpi/Api/HL/Router.php +++ b/src/Glpi/Api/HL/Router.php @@ -53,6 +53,7 @@ use Glpi\Api\HL\Controller\ReportController; use Glpi\Api\HL\Controller\RuleController; use Glpi\Api\HL\Controller\SetupController; +use Glpi\Api\HL\Controller\ToolController; use Glpi\Api\HL\Middleware\AbstractMiddleware; use Glpi\Api\HL\Middleware\AuthMiddlewareInterface; use Glpi\Api\HL\Middleware\CookieAuthMiddleware; @@ -89,7 +90,7 @@ class Router { /** @var string */ - public const API_VERSION = '2.0.0'; + public const API_VERSION = '2.1.0'; /** * @var AbstractController[] @@ -152,10 +153,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', @@ -164,9 +161,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', ], ]; } @@ -181,20 +183,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; } @@ -230,6 +236,7 @@ public static function getInstance(): Router self::$instance->registerController(new GraphQLController()); self::$instance->registerController(new ReportController()); self::$instance->registerController(new RuleController()); + self::$instance->registerController(new ToolController()); self::$instance->registerController(new SetupController()); // Register controllers from plugins @@ -488,7 +495,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)) { diff --git a/src/Glpi/Api/HL/Search.php b/src/Glpi/Api/HL/Search.php index e52fb98a587..669735355bc 100644 --- a/src/Glpi/Api/HL/Search.php +++ b/src/Glpi/Api/HL/Search.php @@ -393,7 +393,10 @@ private function getSearchCriteria(): array $fn_update_keys = static function ($restrict) use (&$fn_update_keys, $main_table) { $new_restrict = []; foreach ($restrict as $key => $value) { - $new_key = str_replace($main_table, '_', $key); + $new_key = $key; + if ($key === $main_table || str_starts_with($key, $main_table . '.')) { + $new_key = str_replace($main_table, '_', $key); + } if (is_array($value)) { $value = $fn_update_keys($value); } diff --git a/tests/functional/Glpi/Api/HL/Controller/AdministrationControllerTest.php b/tests/functional/Glpi/Api/HL/Controller/AdministrationControllerTest.php index 817431e15e8..174d4242ff3 100644 --- a/tests/functional/Glpi/Api/HL/Controller/AdministrationControllerTest.php +++ b/tests/functional/Glpi/Api/HL/Controller/AdministrationControllerTest.php @@ -603,4 +603,115 @@ public function testEmailScope() ->isOK(); }); } + + private function assertUserPreferenceResponseOK($content) + { + $this->assertIsArray($content); + // Spot check some known preferences + $this->assertArrayHasKey('language', $content); + $this->assertArrayHasKey('palette', $content); + $this->assertArrayHasKey('csv_delimiter', $content); + $this->assertArrayHasKey('refresh_view_interval', $content); + } + + public function testGetUserPreferencesByID() + { + $this->login(); + + $tu_id = getItemByTypeName('User', TU_USER, true); + $this->api->call(new Request('GET', '/Administration/User/' . $tu_id . '/Preference'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertUserPreferenceResponseOK($content); + }); + }); + } + + public function testGetUserPreferencesByUsername() + { + $this->login(); + + $this->api->call(new Request('GET', '/Administration/User/' . TU_USER . '/Preference'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertUserPreferenceResponseOK($content); + }); + }); + } + + public function testGetMyPreferences() + { + $this->login(); + + $this->api->call(new Request('GET', '/Administration/User/me/Preference'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertUserPreferenceResponseOK($content); + }); + }); + } + + public function testUpdateUserPreferencesByID() + { + $this->login(); + + $tu_id = getItemByTypeName('User', TU_USER, true); + $request = new Request('PATCH', '/Administration/User/' . $tu_id . '/Preference'); + $request->setParameter('palette', 'teclib'); + $request->setParameter('language', 'fr_FR'); + $this->api->call($request, function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertUserPreferenceResponseOK($content); + $this->assertEquals('teclib', $content['palette']); + $this->assertEquals('fr_FR', $content['language']); + }); + }); + } + + public function testUpdateUserPreferencesByUsername() + { + $this->login(); + + $request = new Request('PATCH', '/Administration/User/' . TU_USER . '/Preference'); + $request->setParameter('palette', 'teclib'); + $request->setParameter('language', 'fr_FR'); + $this->api->call($request, function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertUserPreferenceResponseOK($content); + $this->assertEquals('teclib', $content['palette']); + $this->assertEquals('fr_FR', $content['language']); + }); + }); + } + + public function testUpdateMyPreferences() + { + $this->login(); + + $request = new Request('PATCH', '/Administration/User/me/Preference'); + $request->setParameter('palette', 'teclib'); + $request->setParameter('language', 'fr_FR'); + $this->api->call($request, function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertUserPreferenceResponseOK($content); + $this->assertEquals('teclib', $content['palette']); + $this->assertEquals('fr_FR', $content['language']); + }); + }); + } } diff --git a/tests/functional/Glpi/Api/HL/Controller/SetupControllerTest.php b/tests/functional/Glpi/Api/HL/Controller/SetupControllerTest.php index 965034abfad..71cac9227fc 100644 --- a/tests/functional/Glpi/Api/HL/Controller/SetupControllerTest.php +++ b/tests/functional/Glpi/Api/HL/Controller/SetupControllerTest.php @@ -35,6 +35,7 @@ namespace tests\units\Glpi\Api\HL\Controller; use AuthLDAP; +use Config; use Glpi\Api\HL\Middleware\InternalAuthMiddleware; use Glpi\Http\Request; @@ -156,4 +157,173 @@ public function testCRUDNoRights() }); }); } + + public function testCRUDConfigValues() + { + $this->loginWeb(); + + $this->api->getRouter()->registerAuthMiddleware(new InternalAuthMiddleware()); + // Can get a config value + $this->api->call(new Request('GET', '/Setup/Config/core/priority_1'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertEquals('priority_1', $content['name']); + $this->assertEquals('core', $content['context']); + $this->assertEquals('#fff2f2', $content['value']); + }); + }); + + // Get an undisclosable config value + Config::setConfigurationValues('core', ['smtp_passwd' => 'test']); + $this->api->call(new Request('GET', '/Setup/Config/core/smtp_passwd'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response->isAccessDenied(); + }); + + // Not existing config value + $this->api->call(new Request('GET', '/Setup/Config/core/notrealconfig'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response->isNotFoundError(); + }); + + // Can update a config value + $request = new Request('PATCH', '/Setup/Config/core/priority_1'); + $request->setParameter('value', '#ffffff'); + $this->api->call($request, function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertEquals('priority_1', $content['name']); + $this->assertEquals('core', $content['context']); + $this->assertEquals('#ffffff', $content['value']); + }); + }); + $this->api->call(new Request('GET', '/Setup/Config/core/priority_1'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertEquals('priority_1', $content['name']); + $this->assertEquals('core', $content['context']); + $this->assertEquals('#ffffff', $content['value']); + }); + }); + + // Can update an undisclosable config value + $request = new Request('PATCH', '/Setup/Config/core/smtp_passwd'); + $request->setParameter('value', 'newtest'); + $this->api->call($request, function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->status(static fn($status) => $status === 204); + }); + + // Can delete a config value + $this->api->call(new Request('DELETE', '/Setup/Config/core/priority_1'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->status(static fn($status) => $status === 204); + }); + $this->api->call(new Request('GET', '/Setup/Config/core/priority_1'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response->isNotFoundError(); + }); + + // Can delete an undisclosable config value + $this->api->call(new Request('DELETE', '/Setup/Config/core/smtp_passwd'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->status(static fn($status) => $status === 204); + }); + + // Can get a config value using GraphQL + $request = new Request('POST', '/GraphQL', [], 'query { Config(filter: "context==core;name==priority_2") { context, name, value } }'); + $this->api->call($request, function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertArrayHasKey('data', $content); + $this->assertArrayHasKey('Config', $content['data']); + $this->assertCount(1, $content['data']['Config']); + $config = $content['data']['Config'][0]; + $this->assertEquals('core', $config['context']); + $this->assertEquals('priority_2', $config['name']); + $this->assertEquals('#ffe0e0', $config['value']); + }); + }); + + // Cannot get an undisclosable config value using GraphQL + $request = new Request('POST', '/GraphQL', [], 'query { Config(filter: "context==core;name==smtp_passwd") { context, name, value } }'); + $this->api->call($request, function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertArrayHasKey('data', $content); + $this->assertArrayHasKey('Config', $content['data']); + $this->assertEmpty($content['data']['Config']); + }); + }); + + // Can search config values + $request = new Request('GET', '/Setup/Config'); + $request->setParameter('filter', 'name==priority_2'); + $this->api->call($request, function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertCount(1, $content); + $config = $content[0]; + $this->assertEquals('core', $config['context']); + $this->assertEquals('priority_2', $config['name']); + $this->assertEquals('#ffe0e0', $config['value']); + }); + }); + + // Cannot search undisclosable config values + $request = new Request('GET', '/Setup/Config'); + $request->setParameter('filter', 'name==smtp_passwd'); + $this->api->call($request, function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertEmpty($content); + }); + }); + } + + public function testConfigNotIn2_0() + { + $this->login(); + + $v2_api = $this->api->withVersion('2.0.0'); + $v2_api->call(new Request('GET', '/Setup/Config/core/test'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response->isNotFoundError(); + }); + $v2_api->call(new Request('PATCH', '/Setup/Config/core/test'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response->isNotFoundError(); + }); + $v2_api->call(new Request('DELETE', '/Setup/Config/core/test'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response->isNotFoundError(); + }); + + $request = new Request('POST', '/GraphQL', [], 'query { Config(filter: "context==core;name==test") { context, name, value } }'); + $v2_api->call($request, function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertArrayHasKey('errors', $content); + }); + }); + } } diff --git a/tests/functional/Glpi/Api/HL/Controller/ToolControllerTest.php b/tests/functional/Glpi/Api/HL/Controller/ToolControllerTest.php new file mode 100644 index 00000000000..3fbcb6c267b --- /dev/null +++ b/tests/functional/Glpi/Api/HL/Controller/ToolControllerTest.php @@ -0,0 +1,161 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace tests\units\Glpi\Api\HL\Controller; + +use Glpi\Api\HL\Middleware\InternalAuthMiddleware; +use Glpi\Http\Request; + +class ToolControllerTest extends \HLAPITestCase +{ + public function testIndex() + { + $this->login(); + $this->api->call(new Request('GET', '/Tools'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + $this->assertGreaterThanOrEqual(1, count($content)); + foreach ($content as $asset) { + $this->assertNotEmpty($asset['itemtype']); + $this->assertNotEmpty($asset['name']); + $this->assertEquals('/Tools/' . $asset['itemtype'], $asset['href']); + } + }); + }); + } + + public function testAutoSearch() + { + $this->login(); + $entity = $this->getTestRootEntity(true); + $dataset = [ + [ + 'name' => 'testAutoSearch_1', + ], + [ + 'name' => 'testAutoSearch_2', + ], + [ + 'name' => 'testAutoSearch_3', + ], + ]; + $this->api->call(new Request('GET', '/Tools'), function ($call) use ($dataset) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) use ($dataset) { + global $CFG_GLPI; + + $this->assertGreaterThanOrEqual(1, count($content)); + foreach ($content as $type) { + if ($type['itemtype'] === 'RSSFeed') { + $dataset = [ + ['url' => $CFG_GLPI['url_base'] . '/api.php/v2/fakerss'], + ['url' => $CFG_GLPI['url_base'] . '/api.php/v2/fakerss2'], + ['url' => $CFG_GLPI['url_base'] . '/api.php/v2/fakerss3'], + ]; + } + $this->api->autoTestSearch('/Tools/' . $type['itemtype'], $dataset, $type['itemtype'] === 'RSSFeed' ? 'url' : 'name'); + } + }); + }); + } + + public function testAutoCRUD() + { + $this->login(); + $this->api->call(new Request('GET', '/Tools'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + global $CFG_GLPI; + + $this->assertGreaterThanOrEqual(1, count($content)); + foreach ($content as $type) { + $create_params = []; + if ($type['itemtype'] === 'RSSFeed') { + $create_params['url'] = $CFG_GLPI['url_base'] . '/api.php/v2/fakerss'; + } + $this->api->autoTestCRUD('/Tools/' . $type['itemtype'], $create_params); + } + }); + }); + } + + public function testCRUDNoRights() + { + $this->loginWeb(); + $this->api->getRouter()->registerAuthMiddleware(new InternalAuthMiddleware()); + + $this->api->call(new Request('GET', '/Tools'), function ($call) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->jsonContent(function ($content) { + global $CFG_GLPI; + + $this->assertGreaterThanOrEqual(1, count($content)); + foreach ($content as $type) { + $create_request = new Request('POST', $type['href']); + $create_request->setParameter('name', 'testCRUDNoRights' . random_int(0, 10000)); + $create_request->setParameter('entity', getItemByTypeName('Entity', '_test_root_entity', true)); + if ($type['itemtype'] === 'RSSFeed') { + $create_request->setParameter('url', $CFG_GLPI['url_base'] . '/api.php/v2/fakerss'); + } + $new_location = null; + $new_items_id = null; + $this->api->call($create_request, function ($call) use (&$new_location, &$new_items_id) { + /** @var \HLAPICallAsserter $call */ + $call->response + ->isOK() + ->headers(function ($headers) use (&$new_location) { + $new_location = $headers['Location']; + }) + ->jsonContent(function ($content) use (&$new_items_id) { + $new_items_id = $content['id']; + }); + }); + $this->api->autoTestCRUDNoRights( + endpoint: $type['href'], + itemtype: $type['itemtype'], + items_id: (int) $new_items_id, + ); + } + }); + }); + } +} diff --git a/tests/functional/Glpi/Api/HL/RouterTest.php b/tests/functional/Glpi/Api/HL/RouterTest.php index 2f7c29100d2..60ee5ebc6e8 100644 --- a/tests/functional/Glpi/Api/HL/RouterTest.php +++ b/tests/functional/Glpi/Api/HL/RouterTest.php @@ -87,12 +87,81 @@ public function testAllSchemasHaveVersioningInfo() $this->assertEmpty($schemas_missing_versions, 'Schemas missing versioning info: ' . implode(', ', $schemas_missing_versions)); } - public function testNormalizeAPIVersion() + /** + * Ensure all schemas for CommonTreeDropdown itemtypes have the correct readonly properties such as completename and level + * @return void + */ + public function testAllTreeSchemasHaveReadonlyProps() { - $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')); + $router = Router::getInstance(); + $controllers = $router->getControllers(); + + $schemas_errors = []; + $required_readonly_props = ['completename', 'level']; + foreach ($controllers as $controller) { + $schemas = $controller::getKnownSchemas(null); + foreach ($schemas as $schema_name => $schema) { + if (!isset($schema['x-itemtype']) || !is_subclass_of($schema['x-itemtype'], \CommonTreeDropdown::class)) { + continue; + } + foreach ($required_readonly_props as $prop) { + if (!isset($schema['properties'][$prop])) { + $schemas_errors[] = "Schema $schema_name in " . $controller::class . " is missing property '$prop'"; + } else { + if (!isset($schema['properties'][$prop]['readOnly']) || $schema['properties'][$prop]['readOnly'] !== true) { + $schemas_errors[] = "Property '$prop' in schema $schema_name in " . $controller::class . " is not marked as readOnly"; + } + } + } + } + } + $this->assertEmpty($schemas_errors, "Tree schemas with errors: \n" . implode("\n", $schemas_errors)); + } + + /** + * Ensure there are not multiple schemas for the same itemtype (identified by x-itemtype). + * In some cases, like user preferences, we may have multiple schemas for the same itemtype, but those extra schemas + * should use x-table instead to point to the table directly. + * @return void + */ + public function testNoDuplicateItemtypeSchemas() + { + $router = Router::getInstance(); + $controllers = $router->getControllers(); + + $seen_itemtypes = []; + $duplicate_schemas = []; + $all_schemas = []; + foreach ($controllers as $controller) { + /** @noinspection SlowArrayOperationsInLoopInspection */ + $all_schemas = array_merge($all_schemas, $controller::getKnownSchemas(null)); + } + foreach ($all_schemas as $schema_name => $schema) { + // Ignore known duplicate. Cannot fix until v3 + if ($schema_name === 'SoftwareLicense') { + continue; + } + if (isset($schema['x-itemtype'])) { + $itemtype = $schema['x-itemtype']; + if (isset($seen_itemtypes[$itemtype])) { + $duplicate_schemas[] = "Itemtype $itemtype has multiple schemas: " . $seen_itemtypes[$itemtype] . " and $schema_name"; + } else { + $seen_itemtypes[$itemtype] = $schema_name; + } + } + } + ksort($all_schemas['SoftwareLicense']['properties']); + ksort($all_schemas['License']['properties']); + $this->assertEquals( + array_keys($all_schemas['SoftwareLicense']['properties']), + array_keys($all_schemas['License']['properties']), + 'Schemas SoftwareLicense and License should have the same properties', + ); + // Ensure the duplication gets removed in v3 + if (version_compare(Router::API_VERSION, '3.0.0', '>=')) { + $this->assertNotContains('SoftwareLicense', $seen_itemtypes, 'Schema SoftwareLicense should be removed in v3'); + } + $this->assertEmpty($duplicate_schemas, "Duplicate itemtype schemas found: \n" . implode("\n", $duplicate_schemas)); } public function testHLAPIDisabled() @@ -100,7 +169,7 @@ 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()); @@ -110,11 +179,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 { @@ -172,14 +301,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__); + } }