From 00fb79244aafa6c80973383ee23aefbe714c6ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20G=C3=B3mez=20Garc=C3=ADa?= Date: Sat, 20 Sep 2025 16:17:18 +0200 Subject: [PATCH] Add REST API information to various handlers and models - Introduced REST_API_INFO class variable to Handler and various ModelHandler subclasses to provide metadata for auto-generated APIs. - Updated api_helpers to utilize REST_API_INFO for dynamic naming and descriptions. - Enhanced API response generation functions to include OData parameters and improved request body descriptions. - Added checks in UserServiceManager to prevent actions on already removed services. - Cleaned up code formatting and comments for better readability. --- server/src/uds/REST/handlers.py | 2 + server/src/uds/REST/methods/accounts.py | 6 + server/src/uds/REST/methods/authenticators.py | 7 +- server/src/uds/REST/methods/calendars.py | 7 +- server/src/uds/REST/methods/images.py | 5 + server/src/uds/REST/methods/meta_pools.py | 5 + server/src/uds/REST/methods/mfas.py | 5 + server/src/uds/REST/methods/networks.py | 6 + server/src/uds/REST/methods/notifiers.py | 5 + server/src/uds/REST/methods/osmanagers.py | 5 + server/src/uds/REST/methods/providers.py | 6 +- server/src/uds/REST/methods/reports.py | 5 + .../uds/REST/methods/servers_management.py | 12 +- server/src/uds/REST/methods/services.py | 5 + .../uds/REST/methods/services_pool_groups.py | 5 + server/src/uds/REST/methods/services_pools.py | 5 + server/src/uds/REST/methods/transports.py | 6 + .../uds/REST/methods/tunnels_management.py | 13 +- .../src/uds/REST/model/detail/api_helpers.py | 64 ++++------ server/src/uds/REST/model/master/__init__.py | 18 +-- .../src/uds/REST/model/master/api_helpers.py | 111 ++++++++---------- server/src/uds/core/managers/userservice.py | 6 + server/src/uds/core/types/rest/api.py | 43 ++++++- server/src/uds/core/util/api.py | 97 ++++++++++++++- server/tests/REST/test_apigen.py | 2 +- 25 files changed, 317 insertions(+), 134 deletions(-) diff --git a/server/src/uds/REST/handlers.py b/server/src/uds/REST/handlers.py index e39ed9034..ccf4e9ab8 100644 --- a/server/src/uds/REST/handlers.py +++ b/server/src/uds/REST/handlers.py @@ -72,6 +72,8 @@ class Handler(abc.ABC): ROLE: typing.ClassVar[consts.UserRole] = consts.UserRole.USER # By default, only users can access + REST_API_INFO: typing.ClassVar[types.rest.api.RestApiInfo] = types.rest.api.RestApiInfo() + _request: 'ExtendedHttpRequestWithUser' # It's a modified HttpRequest _path: str _operation: str diff --git a/server/src/uds/REST/methods/accounts.py b/server/src/uds/REST/methods/accounts.py index 8533ad836..e3b8c0b53 100644 --- a/server/src/uds/REST/methods/accounts.py +++ b/server/src/uds/REST/methods/accounts.py @@ -50,6 +50,7 @@ logger = logging.getLogger(__name__) # Enclosed methods under /item path + @dataclasses.dataclass class AccountItem(types.rest.BaseRestItem): id: str @@ -84,6 +85,11 @@ class Accounts(ModelHandler[AccountItem]): .build() ) + # Rest api related information to complete the auto-generated API + REST_API_INFO = types.rest.api.RestApiInfo( + gui_type=types.rest.api.RestApiInfoGuiType.UNTYPED, + ) + def get_item(self, item: 'models.Model') -> AccountItem: item = ensure.is_instance(item, Account) return AccountItem( diff --git a/server/src/uds/REST/methods/authenticators.py b/server/src/uds/REST/methods/authenticators.py index 08143507c..c8084ef6d 100644 --- a/server/src/uds/REST/methods/authenticators.py +++ b/server/src/uds/REST/methods/authenticators.py @@ -91,7 +91,7 @@ class AuthenticatorItem(types.rest.ManagedObjectItem[Authenticator]): users_count: int permission: int - type_info: types.rest.TypeInfo|None + type_info: types.rest.TypeInfo | None # Enclosed methods under /auth path @@ -119,6 +119,11 @@ class Authenticators(ModelHandler[AuthenticatorItem]): .build() ) + # Rest api related information to complete the auto-generated API + REST_API_INFO = types.rest.api.RestApiInfo( + gui_type=types.rest.api.RestApiInfoGuiType.TYPED, + ) + @classmethod def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[auths.Authenticator]]: return auths.factory().providers().values() diff --git a/server/src/uds/REST/methods/calendars.py b/server/src/uds/REST/methods/calendars.py index 7f328b2eb..d0f3a5aa0 100644 --- a/server/src/uds/REST/methods/calendars.py +++ b/server/src/uds/REST/methods/calendars.py @@ -46,7 +46,6 @@ from uds.REST.model import ModelHandler from .calendarrules import CalendarRules - logger = logging.getLogger(__name__) @@ -62,6 +61,7 @@ class CalendarItem(types.rest.BaseRestItem): number_actions: int permission: types.permissions.PermissionType + class Calendars(ModelHandler[CalendarItem]): """ Processes REST requests about calendars @@ -84,6 +84,11 @@ class Calendars(ModelHandler[CalendarItem]): .build() ) + # Rest api related information to complete the auto-generated API + REST_API_INFO = types.rest.api.RestApiInfo( + gui_type=types.rest.api.RestApiInfoGuiType.UNTYPED, + ) + def get_item(self, item: 'models.Model') -> CalendarItem: item = ensure.is_instance(item, Calendar) return CalendarItem( diff --git a/server/src/uds/REST/methods/images.py b/server/src/uds/REST/methods/images.py index f5dd680db..3bbe40b93 100644 --- a/server/src/uds/REST/methods/images.py +++ b/server/src/uds/REST/methods/images.py @@ -91,6 +91,11 @@ class Images(ModelHandler[ImageItem]): # This has no get_gui because its treated on the admin or client. # We expect an Image List + # Rest api related information to complete the auto-generated API + REST_API_INFO = types.rest.api.RestApiInfo( + gui_type=types.rest.api.RestApiInfoGuiType.NONE, + ) + def get_item(self, item: 'models.Model') -> ImageItem: item = ensure.is_instance(item, Image) return ImageItem( diff --git a/server/src/uds/REST/methods/meta_pools.py b/server/src/uds/REST/methods/meta_pools.py index 65e9e3ddd..047f01fd4 100644 --- a/server/src/uds/REST/methods/meta_pools.py +++ b/server/src/uds/REST/methods/meta_pools.py @@ -132,6 +132,11 @@ class MetaPools(ModelHandler[MetaPoolItem]): types.rest.ModelCustomMethod('get_fallback_access', True), ] + # Rest api related information to complete the auto-generated API + REST_API_INFO = types.rest.api.RestApiInfo( + gui_type=types.rest.api.RestApiInfoGuiType.UNTYPED, + ) + def get_item(self, item: 'models.Model') -> MetaPoolItem: item = ensure.is_instance(item, MetaPool) # if item does not have an associated service, hide it (the case, for example, for a removed service) diff --git a/server/src/uds/REST/methods/mfas.py b/server/src/uds/REST/methods/mfas.py index 687853ec0..b0045fa8e 100644 --- a/server/src/uds/REST/methods/mfas.py +++ b/server/src/uds/REST/methods/mfas.py @@ -78,6 +78,11 @@ class MFA(ModelHandler[MFAItem]): .build() ) + # Rest api related information to complete the auto-generated API + REST_API_INFO = types.rest.api.RestApiInfo( + gui_type=types.rest.api.RestApiInfoGuiType.TYPED, + ) + @classmethod def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[mfas.MFA]]: return mfas.factory().providers().values() diff --git a/server/src/uds/REST/methods/networks.py b/server/src/uds/REST/methods/networks.py index b7698169e..ef38ef26a 100644 --- a/server/src/uds/REST/methods/networks.py +++ b/server/src/uds/REST/methods/networks.py @@ -47,6 +47,7 @@ logger = logging.getLogger(__name__) # Enclosed methods under /item path + @dataclasses.dataclass class NetworkItem(types.rest.BaseRestItem): id: str @@ -77,6 +78,11 @@ class Networks(ModelHandler[NetworkItem]): .build() ) + # Rest api related information to complete the auto-generated API + REST_API_INFO = types.rest.api.RestApiInfo( + gui_type=types.rest.api.RestApiInfoGuiType.UNTYPED, + ) + def get_gui(self, for_type: str) -> list[types.ui.GuiElement]: return ( ui_utils.GuiBuilder() diff --git a/server/src/uds/REST/methods/notifiers.py b/server/src/uds/REST/methods/notifiers.py index 4fbd6665d..b1f50ab05 100644 --- a/server/src/uds/REST/methods/notifiers.py +++ b/server/src/uds/REST/methods/notifiers.py @@ -87,6 +87,11 @@ class Notifiers(ModelHandler[NotifierItem]): .text_column(name='tags', title=_('Tags'), visible=False) ).build() + # Rest api related information to complete the auto-generated API + REST_API_INFO = types.rest.api.RestApiInfo( + gui_type=types.rest.api.RestApiInfoGuiType.TYPED, + ) + @classmethod def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[messaging.Notifier]]: return messaging.factory().providers().values() diff --git a/server/src/uds/REST/methods/osmanagers.py b/server/src/uds/REST/methods/osmanagers.py index 5bef03875..8d2d0e261 100644 --- a/server/src/uds/REST/methods/osmanagers.py +++ b/server/src/uds/REST/methods/osmanagers.py @@ -77,6 +77,11 @@ class OsManagers(ModelHandler[OsManagerItem]): .build() ) + # Rest api related information to complete the auto-generated API + REST_API_INFO = types.rest.api.RestApiInfo( + gui_type=types.rest.api.RestApiInfoGuiType.TYPED, + ) + def os_manager_as_dict(self, item: OSManager) -> OsManagerItem: type_ = item.get_type() ret_value = OsManagerItem( diff --git a/server/src/uds/REST/methods/providers.py b/server/src/uds/REST/methods/providers.py index 8a0b7a6f9..8bee4e839 100644 --- a/server/src/uds/REST/methods/providers.py +++ b/server/src/uds/REST/methods/providers.py @@ -52,7 +52,6 @@ from .services_usage import ServicesUsage logger = logging.getLogger(__name__) - # Helper class for Provider offers @dataclasses.dataclass class OfferItem(types.rest.BaseRestItem): @@ -99,6 +98,11 @@ class Providers(ModelHandler[ProviderItem]): .row_style(prefix='row-maintenance-', field='maintenance_mode') ).build() + # Rest api related information to complete the auto-generated API + REST_API_INFO = types.rest.api.RestApiInfo( + gui_type=types.rest.api.RestApiInfoGuiType.TYPED, + ) + def get_item(self, item: 'Model') -> ProviderItem: item = ensure.is_instance(item, Provider) type_ = item.get_type() diff --git a/server/src/uds/REST/methods/reports.py b/server/src/uds/REST/methods/reports.py index 71287dfdf..4bcfcbada 100644 --- a/server/src/uds/REST/methods/reports.py +++ b/server/src/uds/REST/methods/reports.py @@ -88,6 +88,11 @@ class Reports(model.BaseModelHandler[ReportItem]): .build() ) + # Rest api related information to complete the auto-generated API + REST_API_INFO = types.rest.api.RestApiInfo( + gui_type=types.rest.api.RestApiInfoGuiType.TYPED, + ) + def _locate_report( self, uuid: str, values: typing.Optional[typing.Dict[str, typing.Any]] = None ) -> 'Report': diff --git a/server/src/uds/REST/methods/servers_management.py b/server/src/uds/REST/methods/servers_management.py index 3ee741918..4cda25608 100644 --- a/server/src/uds/REST/methods/servers_management.py +++ b/server/src/uds/REST/methods/servers_management.py @@ -143,6 +143,11 @@ class ServersServers(DetailHandler[ServerItem]): CUSTOM_METHODS = ['maintenance', 'importcsv'] + # Rest api related information to complete the auto-generated API + REST_API_INFO = types.rest.api.RestApiInfo( + gui_type=types.rest.api.RestApiInfoGuiType.UNTYPED, + ) + def get_items(self, parent: 'Model', item: typing.Optional[str]) -> types.rest.ItemsResult[ServerItem]: parent = typing.cast('models.ServerGroup', parent) # We will receive for sure try: @@ -197,7 +202,7 @@ class ServersServers(DetailHandler[ServerItem]): .build() ) - def get_gui(self, parent: 'Model', for_type: str = '') -> list[types.ui.GuiElement]: + def get_gui(self, parent: 'Model', for_type: str) -> list[types.ui.GuiElement]: parent = ensure.is_instance(parent, models.ServerGroup) kind, subkind = parent.server_type, parent.subtype title = _('of type') + f' {subkind.upper()} {kind.name.capitalize()}' @@ -456,6 +461,11 @@ class ServersGroups(ModelHandler[GroupItem]): .build() ) + # Rest api related information to complete the auto-generated API + REST_API_INFO = types.rest.api.RestApiInfo( + gui_type=types.rest.api.RestApiInfoGuiType.TYPED, + ) + def enum_types( self, *args: typing.Any, **kwargs: typing.Any ) -> typing.Generator[types.rest.TypeInfo, None, None]: diff --git a/server/src/uds/REST/methods/services.py b/server/src/uds/REST/methods/services.py index acda498ca..54e19b43f 100644 --- a/server/src/uds/REST/methods/services.py +++ b/server/src/uds/REST/methods/services.py @@ -104,6 +104,11 @@ class Services(DetailHandler[ServiceItem]): # pylint: disable=too-many-public-m CUSTOM_METHODS = ['servicepools'] + # Rest api related information to complete the auto-generated API + REST_API_INFO = types.rest.api.RestApiInfo( + gui_type=types.rest.api.RestApiInfoGuiType.TYPED, + ) + @staticmethod def service_info(item: models.Service) -> ServiceInfo: info = item.get_type() diff --git a/server/src/uds/REST/methods/services_pool_groups.py b/server/src/uds/REST/methods/services_pool_groups.py index e8c7a5806..96eacffc2 100644 --- a/server/src/uds/REST/methods/services_pool_groups.py +++ b/server/src/uds/REST/methods/services_pool_groups.py @@ -74,6 +74,11 @@ class ServicesPoolGroups(ModelHandler[ServicePoolGroupItem]): .build() ) + # Rest api related information to complete the auto-generated API + REST_API_INFO = types.rest.api.RestApiInfo( + gui_type=types.rest.api.RestApiInfoGuiType.UNTYPED, + ) + def pre_save(self, fields: dict[str, typing.Any]) -> None: img_id = fields['image_id'] fields['image_id'] = None diff --git a/server/src/uds/REST/methods/services_pools.py b/server/src/uds/REST/methods/services_pools.py index f7ba240fe..7378cf517 100644 --- a/server/src/uds/REST/methods/services_pools.py +++ b/server/src/uds/REST/methods/services_pools.py @@ -170,6 +170,11 @@ class ServicesPools(ModelHandler[ServicePoolItem]): types.rest.ModelCustomMethod('add_log', True), ] + # Rest api related information to complete the auto-generated API + REST_API_INFO = types.rest.api.RestApiInfo( + gui_type=types.rest.api.RestApiInfoGuiType.UNTYPED, + ) + def get_items( self, *args: typing.Any, **kwargs: typing.Any ) -> typing.Generator[ServicePoolItem, None, None]: diff --git a/server/src/uds/REST/methods/transports.py b/server/src/uds/REST/methods/transports.py index 685c77b60..4d3696c18 100644 --- a/server/src/uds/REST/methods/transports.py +++ b/server/src/uds/REST/methods/transports.py @@ -50,6 +50,7 @@ logger = logging.getLogger(__name__) # Enclosed methods under /item path + @dataclasses.dataclass class TransportItem(types.rest.ManagedObjectItem[Transport]): id: str @@ -92,6 +93,11 @@ class Transports(ModelHandler[TransportItem]): .text_column(name='tags', title=_('tags'), visible=False) ).build() + # Rest api related information to complete the auto-generated API + REST_API_INFO = types.rest.api.RestApiInfo( + gui_type=types.rest.api.RestApiInfoGuiType.TYPED, + ) + @classmethod def possible_types(cls: type[typing.Self]) -> collections.abc.Iterable[type[transports.Transport]]: return transports.factory().providers().values() diff --git a/server/src/uds/REST/methods/tunnels_management.py b/server/src/uds/REST/methods/tunnels_management.py index feba15b16..ce5990903 100644 --- a/server/src/uds/REST/methods/tunnels_management.py +++ b/server/src/uds/REST/methods/tunnels_management.py @@ -47,6 +47,7 @@ from uds.REST.model import DetailHandler, ModelHandler logger = logging.getLogger(__name__) + @dataclasses.dataclass class TunnelServerItem(types.rest.BaseRestItem): id: str @@ -57,9 +58,12 @@ class TunnelServerItem(types.rest.BaseRestItem): class TunnelServers(DetailHandler[TunnelServerItem]): - # tunnels/[id]/servers CUSTOM_METHODS = ['maintenance'] + REST_API_INFO = types.rest.api.RestApiInfo( + name='TunnelServers', description='Tunnel servers assigned to a tunnel' + ) + def get_items( self, parent: 'Model', item: typing.Optional[str] ) -> types.rest.ItemsResult[TunnelServerItem]: @@ -134,6 +138,7 @@ class TunnelServers(DetailHandler[TunnelServerItem]): item.save() return 'ok' + @dataclasses.dataclass class TunnelItem(types.rest.BaseRestItem): id: str @@ -173,6 +178,12 @@ class Tunnels(ModelHandler[TunnelItem]): .build() ) + REST_API_INFO = types.rest.api.RestApiInfo( + name='Tunnels', + description='Tunnel management', + gui_type=types.rest.api.RestApiInfoGuiType.UNTYPED, + ) + def get_gui(self, for_type: str) -> list[types.ui.GuiElement]: return ( ui_utils.GuiBuilder() diff --git a/server/src/uds/REST/model/detail/api_helpers.py b/server/src/uds/REST/model/detail/api_helpers.py index c24bb768a..c2663d80e 100644 --- a/server/src/uds/REST/model/detail/api_helpers.py +++ b/server/src/uds/REST/model/detail/api_helpers.py @@ -58,7 +58,7 @@ def api_paths( Returns the API operations that should be registered """ - name = path.split('/')[-1] + name = cls.REST_API_INFO.name if cls.REST_API_INFO.name else path.split('/')[-1].capitalize() get_tags = tags put_tags = tags # + ['Create', 'Modify'] # post_tags = tags + ['Create'] @@ -71,12 +71,12 @@ def api_paths( else: base_type_name = base_type.__name__ # TODO: Append "custom" methods - return { + api_desc = { path: types.rest.api.PathItem( get=types.rest.api.Operation( summary=f'Get all {name} items', description=f'Retrieve a list of all {name} items', - parameters=[], + parameters=api_utils.gen_odata_parameters(), responses=api_utils.gen_response(base_type_name, single=False), tags=get_tags, security=security, @@ -94,15 +94,7 @@ def api_paths( get=types.rest.api.Operation( summary=f'Get {name} item by UUID', description=f'Retrieve a {name} item by UUID', - parameters=[ - types.rest.api.Parameter( - name='uuid', - in_='path', - required=True, - description='The UUID of the item', - schema=types.rest.api.Schema(type='string', format='uuid'), - ) - ], + parameters=api_utils.gen_uuid_parameters(with_odata=True), responses=api_utils.gen_response(base_type_name, with_404=True), tags=get_tags, security=security, @@ -110,15 +102,7 @@ def api_paths( put=types.rest.api.Operation( summary=f'Update {name} item by UUID', description=f'Update an existing {name} item by UUID', - parameters=[ - types.rest.api.Parameter( - name='uuid', - in_='path', - required=True, - description='The UUID of the item', - schema=types.rest.api.Schema(type='string', format='uuid'), - ) - ], + parameters=api_utils.gen_uuid_parameters(with_odata=True), responses=api_utils.gen_response(base_type_name, with_404=True), tags=put_tags, security=security, @@ -126,15 +110,7 @@ def api_paths( delete=types.rest.api.Operation( summary=f'Delete {name} item by UUID', description=f'Delete a {name} item by UUID', - parameters=[ - types.rest.api.Parameter( - name='uuid', - in_='path', - required=True, - description='The UUID of the item', - schema=types.rest.api.Schema(type='string', format='uuid'), - ) - ], + parameters=api_utils.gen_uuid_parameters(with_odata=True), responses=api_utils.gen_response(base_type_name, with_404=True), tags=delete_tags, security=security, @@ -144,7 +120,7 @@ def api_paths( get=types.rest.api.Operation( summary=f'Get overview of {name} items', description=f'Retrieve an overview of {name} items', - parameters=[], + parameters=api_utils.gen_odata_parameters(), responses=api_utils.gen_response(base_type_name, single=False), tags=get_tags, security=security, @@ -188,32 +164,38 @@ def api_paths( security=security, ) ), - f'{path}/{consts.rest.GUI}': types.rest.api.PathItem( + } + + if cls.REST_API_INFO.gui_type.is_untyped(): + api_desc[f'{path}/{consts.rest.GUI}'] = types.rest.api.PathItem( get=types.rest.api.Operation( summary=f'Get GUI representation of {name} items', description=f'Retrieve the GUI representation of {name} items', parameters=[], - responses=api_utils.gen_response('GuiElement', with_404=True), + responses=api_utils.gen_response('GuiElement', single=False, with_404=True), tags=get_tags, security=security, ) - ), - f'{path}/{consts.rest.GUI}/{{type}}': types.rest.api.PathItem( + ) + + if cls.REST_API_INFO.gui_type.is_typed(): + api_desc[f'{path}/{consts.rest.GUI}/{{type}}'] = types.rest.api.PathItem( get=types.rest.api.Operation( - summary=f'Get {name} item by type', - description=f'Retrieve a {name} item by type', + summary=f'Get GUI representation of {name} type', + description=f'Retrieve a {name} GUI representation by type', parameters=[ types.rest.api.Parameter( name='type', in_='path', required=True, - description='The type of the item', + description=f'The type of the {name} GUI representation', schema=types.rest.api.Schema(type='string'), ) ], - responses=api_utils.gen_response('GuiElement', with_404=True), + responses=api_utils.gen_response('GuiElement', single=False, with_404=True), tags=get_tags, security=security, ) - ), - } + ) + + return api_desc diff --git a/server/src/uds/REST/model/master/__init__.py b/server/src/uds/REST/model/master/__init__.py index 44df889b2..9c94a17ea 100644 --- a/server/src/uds/REST/model/master/__init__.py +++ b/server/src/uds/REST/model/master/__init__.py @@ -85,16 +85,15 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC): # Same, but for exclude EXCLUDE: 'typing.ClassVar[typing.Optional[collections.abc.Mapping[str, typing.Any]]]' = None + # If this model respond to "custom" methods, we will declare them here # This is an array of tuples of two items, where first is method and second inticates if method needs parent id (normal behavior is it needs it) # For example ('services', True) -- > .../id_parent/services # ('services', False) --> ..../services - CUSTOM_METHODS: typing.ClassVar[list[types.rest.ModelCustomMethod]] = ( - [] - ) # If this model respond to "custom" methods, we will declare them here + CUSTOM_METHODS: typing.ClassVar[list[types.rest.ModelCustomMethod]] = [] + # If this model has details, which ones - DETAIL: typing.ClassVar[typing.Optional[dict[str, type['DetailHandler[typing.Any]']]]] = ( - None # Dictionary containing detail routing - ) + # Dictionary containing detail routing + DETAIL: typing.ClassVar[typing.Optional[dict[str, type['DetailHandler[typing.Any]']]]] = None # Fields that are going to be saved directly # * If a field is in the form "field:default" and field is not present in the request, default will be used # * If the "default" is the string "None", then the default will be None @@ -268,13 +267,6 @@ class ModelHandler(BaseModelHandler[types.rest.T_Item], abc.ABC): # logger.exception('Exception getting item from {0}'.format(self.model)) def get(self) -> typing.Any: - """ - Wraps real get method so we can process filters if they exists - """ - return self.process_get() - - # pylint: disable=too-many-return-statements - def process_get(self) -> typing.Any: logger.debug('method GET for %s, %s', self.__class__.__name__, self._args) number_of_args = len(self._args) diff --git a/server/src/uds/REST/model/master/api_helpers.py b/server/src/uds/REST/model/master/api_helpers.py index 16e5a960b..9e196329d 100644 --- a/server/src/uds/REST/model/master/api_helpers.py +++ b/server/src/uds/REST/model/master/api_helpers.py @@ -56,12 +56,12 @@ def api_paths( """ Returns the API operations that should be registered """ - # The the base pathº - cls_model = cls.MODEL.__name__ + + name = cls.REST_API_INFO.name if cls.REST_API_INFO.name else cls.MODEL.__name__ get_tags = tags - put_tags = tags # + ['Create', 'Modify'] + put_tags = tags # + ['Create', 'Modify'] # post_tags = tags + ['Create'] - delete_tags = tags # + ['Delete'] + delete_tags = tags # + ['Delete'] base_type = next(iter(api_utils.get_generic_types(cls)), None) if base_type is None: @@ -70,20 +70,21 @@ def api_paths( else: base_type_name = base_type.__name__ - return { + api_desc = { path: types.rest.api.PathItem( get=types.rest.api.Operation( - summary=f'Get all {cls_model} items', - description=f'Retrieve a list of all {cls_model} items', - parameters=[], + summary=f'Get all {name} items', + description=f'Retrieve a list of all {name} items', + parameters=api_utils.gen_odata_parameters(), responses=api_utils.gen_response(base_type_name, single=False), tags=get_tags, security=security, ), put=types.rest.api.Operation( - summary=f'Creates a new {cls_model} item', - description=f'Creates a new, nonexisting {cls_model} item', + summary=f'Creates a new {name} item', + description=f'Creates a new, nonexisting {name} item', parameters=[], + requestBody=api_utils.gen_request_body(base_type_name, create=True), responses=api_utils.gen_response(base_type_name, with_404=True), tags=put_tags, security=security, @@ -91,49 +92,25 @@ def api_paths( ), f'{path}/{{uuid}}': types.rest.api.PathItem( get=types.rest.api.Operation( - summary=f'Get {cls_model} item by UUID', - description=f'Retrieve a {cls_model} item by UUID', - parameters=[ - types.rest.api.Parameter( - name='uuid', - in_='path', - required=True, - description='The UUID of the item', - schema=types.rest.api.Schema(type='string', format='uuid'), - ) - ], + summary=f'Get {name} item by UUID', + description=f'Retrieve a {name} item by UUID', + parameters=api_utils.gen_uuid_parameters(with_odata=True), responses=api_utils.gen_response(base_type_name, with_404=True), tags=get_tags, security=security, ), put=types.rest.api.Operation( - summary=f'Update {cls_model} item by UUID', - description=f'Update an existing {cls_model} item by UUID', - parameters=[ - types.rest.api.Parameter( - name='uuid', - in_='path', - required=True, - description='The UUID of the item', - schema=types.rest.api.Schema(type='string', format='uuid'), - ) - ], + summary=f'Update {name} item by UUID', + description=f'Update an existing {name} item by UUID', + parameters=api_utils.gen_uuid_parameters(with_odata=False), responses=api_utils.gen_response(base_type_name, with_404=True), tags=put_tags, security=security, ), delete=types.rest.api.Operation( - summary=f'Delete {cls_model} item by UUID', - description=f'Delete a {cls_model} item by UUID', - parameters=[ - types.rest.api.Parameter( - name='uuid', - in_='path', - required=True, - description='The UUID of the item', - schema=types.rest.api.Schema(type='string', format='uuid'), - ) - ], + summary=f'Delete {name} item by UUID', + description=f'Delete a {name} item by UUID', + parameters=api_utils.gen_uuid_parameters(with_odata=False), responses=api_utils.gen_response(base_type_name, with_404=True), tags=delete_tags, security=security, @@ -141,9 +118,9 @@ def api_paths( ), f'{path}/{consts.rest.OVERVIEW}': types.rest.api.PathItem( get=types.rest.api.Operation( - summary=f'Get overview of {cls_model} items', - description=f'Retrieve an overview of {cls_model} items', - parameters=[], + summary=f'Get overview of {name} items', + description=f'Retrieve an overview of {name} items', + parameters=api_utils.gen_odata_parameters(), responses=api_utils.gen_response(base_type_name, single=False), tags=get_tags, security=security, @@ -151,8 +128,8 @@ def api_paths( ), f'{path}/{consts.rest.TABLEINFO}': types.rest.api.PathItem( get=types.rest.api.Operation( - summary=f'Get table info of {cls_model} items', - description=f'Retrieve table info of {cls_model} items', + summary=f'Get table info of {name} items', + description=f'Retrieve table info of {name} items', parameters=[], responses=api_utils.gen_response('TableInfo', with_404=True), tags=get_tags, @@ -161,8 +138,8 @@ def api_paths( ), f'{path}/{consts.rest.TYPES}': types.rest.api.PathItem( get=types.rest.api.Operation( - summary=f'Get types of {cls_model} items', - description=f'Retrieve types of {cls_model} items', + summary=f'Get types of {name} items', + description=f'Retrieve types of {name} items', parameters=[], responses=api_utils.gen_response(base_type_name, single=False), tags=get_tags, @@ -171,8 +148,8 @@ def api_paths( ), f'{path}/{consts.rest.TYPES}/{{type}}': types.rest.api.PathItem( get=types.rest.api.Operation( - summary=f'Get {cls_model} item by type', - description=f'Retrieve a {cls_model} item by type', + summary=f'Get {name} item by type', + description=f'Retrieve a {name} item by type', parameters=[ types.rest.api.Parameter( name='type', @@ -187,33 +164,37 @@ def api_paths( security=security, ) ), - # TODO: Fix this - f'{path}/{consts.rest.GUI}': types.rest.api.PathItem( + } + if cls.REST_API_INFO.gui_type.is_untyped(): + api_desc[f'{path}/{consts.rest.GUI}'] = types.rest.api.PathItem( get=types.rest.api.Operation( - summary=f'Get GUI representation of {cls_model} items', - description=f'Retrieve the GUI representation of {cls_model} items', + summary=f'Get GUI representation of {name} items', + description=f'Retrieve the GUI representation of {name} items', parameters=[], - responses=api_utils.gen_response('GuiElement', with_404=True), + responses=api_utils.gen_response('GuiElement', single=False, with_404=True), tags=get_tags, security=security, ) - ), - f'{path}/{consts.rest.GUI}/{{type}}': types.rest.api.PathItem( + ) + + if cls.REST_API_INFO.gui_type.is_typed(): + api_desc[f'{path}/{consts.rest.GUI}/{{type}}'] = types.rest.api.PathItem( get=types.rest.api.Operation( - summary=f'Get {cls_model} item by type', - description=f'Retrieve a {cls_model} item by type', + summary=f'Get GUI representation of {name} type', + description=f'Retrieve a {name} GUI representation by type', parameters=[ types.rest.api.Parameter( name='type', in_='path', required=True, - description='The type of the item', + description=f'The type of the {name} GUI representation', schema=types.rest.api.Schema(type='string'), ) ], - responses=api_utils.gen_response('GuiElement', with_404=True), + responses=api_utils.gen_response('GuiElement', single=False, with_404=True), tags=get_tags, security=security, ) - ), - } + ) + + return api_desc diff --git a/server/src/uds/core/managers/userservice.py b/server/src/uds/core/managers/userservice.py index bbe199bb8..f2a0d6de2 100644 --- a/server/src/uds/core/managers/userservice.py +++ b/server/src/uds/core/managers/userservice.py @@ -487,6 +487,12 @@ class UserServiceManager(metaclass=singleton.Singleton): with transaction.atomic(): userservice = UserService.objects.select_for_update().get(id=userservice.id) operations_logger.info('Removing userservice %a', userservice.name) + + # If already removing or removed, do nothing + if State.from_str(userservice.state) in (State.REMOVING, State.REMOVED): + logger.debug('Userservice %s already removing or removed', userservice.name) + return + if userservice.is_usable() is False and State.from_str(userservice.state).is_removable() is False: if not forced: raise OperationException( diff --git a/server/src/uds/core/types/rest/api.py b/server/src/uds/core/types/rest/api.py index 2fc1fe1bd..08c97a498 100644 --- a/server/src/uds/core/types/rest/api.py +++ b/server/src/uds/core/types/rest/api.py @@ -1,3 +1,4 @@ +import enum import typing import dataclasses @@ -25,9 +26,35 @@ def _as_dict_without_none(v: typing.Any) -> typing.Any: return v.as_dict() return v + +# This class is used to provide extra information about a handler +# (handler, model, detail, etc.) +# So we can override names or whatever we need + +# Types of GUI info that can be provided +class RestApiInfoGuiType(enum.Enum): + UNTYPED = 0 + TYPED = 1 + NONE = 3 + + def is_untyped(self) -> bool: + return self == RestApiInfoGuiType.UNTYPED + + def is_typed(self) -> bool: + return self == RestApiInfoGuiType.TYPED + @dataclasses.dataclass -class HandlerApiInfo: - pass +class RestApiInfo: + + name: str | None = None + description: str | None = None + + # Guis can have different types: + # - MAIN: the gui returns with no type specified (for example, /gui) + # - TYPED: the gui returns with a type specified (for example, /gui/whatever_type) + # - NONE: no gui is provided + gui_type: 'RestApiInfoGuiType' = RestApiInfoGuiType.NONE + # Parameter @dataclasses.dataclass @@ -74,12 +101,14 @@ class Content: class RequestBody: required: bool content: Content # e.g. {'application/json': {'schema': {...}}} + description: str | None = None def as_dict(self) -> dict[str, typing.Any]: return _as_dict_without_none( { 'required': self.required, 'content': self.content.as_dict(), + 'description': self.description, } ) @@ -159,7 +188,7 @@ class SchemaProperty: enum: list[str | int] | None = None # For enum types properties: dict[str, 'SchemaProperty'] | None = None one_of: list['SchemaProperty'] | None = None - + def __eq__(self, value: object) -> bool: if not isinstance(value, SchemaProperty): return False @@ -173,7 +202,8 @@ class SchemaProperty: and self.discriminator == value.discriminator and self.enum == value.enum and self.properties == value.properties - and sorted(self.one_of or [], key=lambda x: x.type) == sorted(value.one_of or [], key=lambda x: x.type) + and sorted(self.one_of or [], key=lambda x: x.type) + == sorted(value.one_of or [], key=lambda x: x.type) ) @staticmethod @@ -256,9 +286,10 @@ class Schema: properties: dict[str, SchemaProperty] = dataclasses.field(default_factory=dict[str, SchemaProperty]) required: list[str] = dataclasses.field(default_factory=list[str]) description: str | None = None + minimum: int | None = None + maximum: int | None = None # For use on generating schemas - def as_dict(self) -> dict[str, typing.Any]: return _as_dict_without_none( { @@ -267,6 +298,8 @@ class Schema: 'properties': {k: v.as_dict() for k, v in self.properties.items()} if self.properties else None, 'required': self.required if self.required else None, 'description': self.description, + 'minimum': self.minimum, + 'maximum': self.maximum, } ) diff --git a/server/src/uds/core/util/api.py b/server/src/uds/core/util/api.py index b91ab0644..d1f5e9dd8 100644 --- a/server/src/uds/core/util/api.py +++ b/server/src/uds/core/util/api.py @@ -119,9 +119,7 @@ def get_component_from_type( components.schemas[type_.type_type] = type_schema - if is_managed_object and isinstance( - item_schema, types.rest.api.Schema - ): + if is_managed_object and isinstance(item_schema, types.rest.api.Schema): # item_schema.discriminator = types.rest.api.Discriminator(propertyName='type') instance_name = f'{item_name}Instance' item_schema.properties['instance'] = types.rest.api.SchemaProperty( @@ -323,7 +321,11 @@ def api_components( def gen_response( - type: str, with_404: bool = False, single: bool = True, delete: bool = False + type: str, + with_404: bool = False, + single: bool = True, + delete: bool = False, + with_403: bool = True, ) -> dict[str, types.rest.api.Response]: data: dict[str, types.rest.api.Response] @@ -370,5 +372,92 @@ def gen_response( ), ), ) + if with_403: + data['403'] = types.rest.api.Response( + description='Forbidden. You do not have permission to access this resource with your current role.', + content=types.rest.api.Content( + media_type='application/json', + schema=types.rest.api.SchemaProperty( + type='object', + properties={ + 'detail': types.rest.api.SchemaProperty( + type='string', + ) + }, + ), + ), + ) return data + + +def gen_request_body(type: str, create: bool = True) -> types.rest.api.RequestBody: + return types.rest.api.RequestBody( + description=f'{"New" if create else "Updated"} {type} item{"s" if not create else ""} to create', + required=True, + content=types.rest.api.Content( + media_type='application/json', + schema=types.rest.api.SchemaProperty( + type=f'#/components/schemas/{type}' if create else 'array', + items=( + types.rest.api.SchemaProperty( + type=f'#/components/schemas/{type}', + ) + if not create + else None + ), + ), + ), + ) + + +def gen_odata_parameters() -> list[types.rest.api.Parameter]: + return [ + types.rest.api.Parameter( + name='$filter', + in_='query', + required=False, + description='Filter items by property values (e.g., $filter=property eq value)', + schema=types.rest.api.Schema(type='string'), + ), + types.rest.api.Parameter( + name='$select', + in_='query', + required=False, + description='Select properties to be returned', + schema=types.rest.api.Schema(type='string'), + ), + types.rest.api.Parameter( + name='$orderby', + in_='query', + required=False, + description='Order items by property values (e.g., $orderby=property desc)', + schema=types.rest.api.Schema(type='string'), + ), + types.rest.api.Parameter( + name='$top', + in_='query', + required=False, + description='Show only the first N items', + schema=types.rest.api.Schema(type='integer', format='int32', minimum=1), + ), + types.rest.api.Parameter( + name='$skip', + in_='query', + required=False, + description='Skip the first N items', + schema=types.rest.api.Schema(type='integer', format='int32', minimum=0), + ), + ] + + +def gen_uuid_parameters(with_odata: bool) -> list[types.rest.api.Parameter]: + return [ + types.rest.api.Parameter( + name='uuid', + in_='path', + required=True, + description='The UUID of the item', + schema=types.rest.api.Schema(type='string', format='uuid'), + ) + ] + (gen_odata_parameters() if with_odata else []) diff --git a/server/tests/REST/test_apigen.py b/server/tests/REST/test_apigen.py index c1846e8ae..7db4e3e97 100644 --- a/server/tests/REST/test_apigen.py +++ b/server/tests/REST/test_apigen.py @@ -67,7 +67,7 @@ class BaseRestItem(types.rest.BaseRestItem): class TestTransport(transports.Transport): """ - Simpe testing transport. Currently a copy of URLCustomTransport + Simpe testing transport. """ type_name = 'Test Transport'