mirror of
https://github.com/dkmstr/openuds.git
synced 2025-10-09 23:33:47 +03:00
- 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.
399 lines
17 KiB
Python
399 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
|
#
|
|
# Copyright (c) 2025 Virtual Cable S.L.U.
|
|
# All rights reserved.
|
|
#
|
|
# Redistribution and use in source and binary forms, with or without modification,
|
|
# are permitted provided that the following conditions are met:
|
|
#
|
|
# * Redistributions of source code must retain the above copyright notice,
|
|
# this list of conditions and the following disclaimer.
|
|
# * Redistributions in binary form must reproduce the above copyright notice,
|
|
# this list of conditions and the following disclaimer in the documentation
|
|
# and/or other materials provided with the distribution.
|
|
# * Neither the name of Virtual Cable S.L.U. nor the names of its contributors
|
|
# may be used to endorse or promote products derived from this software
|
|
# without specific prior written permission.
|
|
#
|
|
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
"""
|
|
Author: Adolfo Gómez, dkmaster at dkmon dot com
|
|
"""
|
|
import dataclasses
|
|
import typing
|
|
import logging
|
|
import enum
|
|
|
|
from tests.utils.test import UDSTestCase
|
|
|
|
from uds.REST import dispatcher
|
|
from uds.REST.model import base
|
|
from uds.REST.model.master import ModelHandler
|
|
from uds.core import types, transports, consts, ui
|
|
from uds.core.util import api as util_api
|
|
from uds.models import Transport
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MyEnum(enum.Enum):
|
|
VALUE1 = "value1"
|
|
VALUE2 = "value2"
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class BaseRestItem(types.rest.BaseRestItem):
|
|
field_str: str
|
|
field_int: int
|
|
field_float: float
|
|
field_list: typing.List[str] = dataclasses.field(default_factory=list[str])
|
|
field_list_2: list[str] = dataclasses.field(default_factory=list[str])
|
|
field_dict: typing.Dict[str, str] = dataclasses.field(default_factory=dict[str, str])
|
|
field_dict_2: dict[str, str] = dataclasses.field(default_factory=dict[str, str])
|
|
field_enum: MyEnum = MyEnum.VALUE1
|
|
field_optional: typing.Optional[str] = None
|
|
field_union: typing.Union[str, int] = "value"
|
|
field_union_2: str | int = 1
|
|
|
|
|
|
class TestTransport(transports.Transport):
|
|
"""
|
|
Simpe testing transport.
|
|
"""
|
|
|
|
type_name = 'Test Transport'
|
|
type_type = 'TestTransport'
|
|
type_description = 'Test Transport'
|
|
icon_file = 'transport.png'
|
|
|
|
own_link = True
|
|
supported_oss = consts.os.ALL_OS_LIST
|
|
PROTOCOL = types.transports.Protocol.OTHER
|
|
group = types.transports.Grouping.DIRECT
|
|
|
|
text_fld = ui.gui.TextField(label='text_fld', tooltip='text_fld tooltip', required=True)
|
|
text_auto_fld = ui.gui.TextAutocompleteField(
|
|
label='text_auto_fld', tooltip='text_auto_fld tooltip', required=True
|
|
)
|
|
num_fld = ui.gui.NumericField(label='num_fld', tooltip='num_fld tooltip', required=True)
|
|
pass_fld = ui.gui.PasswordField(label='pass_fld', tooltip='pass_fld tooltip', required=True)
|
|
hidden_fld = ui.gui.HiddenField(label='hidden_fld')
|
|
choice_fld = ui.gui.ChoiceField(label='choice_fld', tooltip='choice_fld tooltip', required=True)
|
|
multi_choice_fld = ui.gui.MultiChoiceField(
|
|
label='multi_choice_fld', tooltip='multi_choice_fld tooltip', required=True
|
|
)
|
|
editable_list_fld = ui.gui.EditableListField(
|
|
label='editable_list_fld', tooltip='editable_list_fld tooltip', required=True
|
|
)
|
|
checkbox_fld = ui.gui.CheckBoxField(label='checkbox_fld', tooltip='checkbox_fld tooltip', required=True)
|
|
image_choice_fld = ui.gui.ImageChoiceField(
|
|
label='image_choice_fld', tooltip='image_choice_fld tooltip', required=True
|
|
)
|
|
date_fld = ui.gui.DateField(label='date_fld', tooltip='date_fld tooltip', required=True)
|
|
info_fld = ui.gui.HelpField(label='info_fld', title='help', help='help text')
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class ManagedObjectRestItem(types.rest.ManagedObjectItem[Transport]):
|
|
field_str: str
|
|
field_union: str | int | None = None
|
|
|
|
|
|
class TestApiGenBasic(UDSTestCase):
|
|
def test_model_handler_componments(self) -> None:
|
|
SECURITY_NAME: typing.Final[str] = 'udsApiAuth'
|
|
root_node = dispatcher.Dispatcher.root_node
|
|
|
|
comps = base.BaseModelHandler.common_components()
|
|
paths = base.BaseModelHandler.common_paths()
|
|
|
|
# Node is a tree, recursively check all children
|
|
def check_node(node: types.rest.HandlerNode):
|
|
nonlocal comps
|
|
if handler := node.handler:
|
|
full_path = '/' + node.full_path().lstrip('/')
|
|
tags = [full_path.split('/')[1].capitalize()] if len(full_path.split('/')) > 1 else []
|
|
security = SECURITY_NAME if handler.ROLE != consts.UserRole.ANONYMOUS else ''
|
|
|
|
logger.info("Checking child node: %s, %s", node.name, handler.__module__)
|
|
components = handler.api_components()
|
|
# Component should not be empty
|
|
self.assertIsInstance(
|
|
components,
|
|
types.rest.api.Components,
|
|
f'Component for {node.name} should be of type Components',
|
|
)
|
|
|
|
handler_paths = handler.api_paths(full_path, tags, security)
|
|
self.assertIsInstance(
|
|
handler_paths,
|
|
dict,
|
|
f'Paths for {node.name} should be of type Paths',
|
|
)
|
|
for path, item in handler_paths.items():
|
|
self.assertIsInstance(
|
|
path,
|
|
str,
|
|
f'Path for {node.name} should be of type str',
|
|
)
|
|
self.assertIsInstance(
|
|
item,
|
|
types.rest.api.PathItem,
|
|
f'Path item for {node.name} path {path} should be of type PathItem',
|
|
)
|
|
# self.assertFalse(components.is_empty(), f'Component for model {node.name} ({node.handler.__module__}) should not be empty')
|
|
comps = comps.union(components)
|
|
paths.update(handler_paths)
|
|
|
|
# If is a ModelHandler, look for DETAIL
|
|
if issubclass(handler, ModelHandler) and handler.DETAIL:
|
|
for name, cls in handler.DETAIL.items():
|
|
logger.info("Found detail for %s: %s", node.name, name)
|
|
|
|
comps = comps.union(cls.api_components())
|
|
paths.update(cls.api_paths(f'{full_path}/{name}', tags, security))
|
|
|
|
for child in node.children.values():
|
|
check_node(child)
|
|
|
|
check_node(root_node)
|
|
logger.info("Components found: %s", ', '.join(comps.schemas.keys()))
|
|
logger.info("Paths found: %s", ', '.join(paths.keys()))
|
|
|
|
# Upgrade comps to include security schema
|
|
comps.securitySchemes = {
|
|
SECURITY_NAME: {
|
|
'type': 'apiKey',
|
|
'in': 'header',
|
|
'name': consts.auth.AUTH_TOKEN_HEADER,
|
|
}
|
|
}
|
|
|
|
import json
|
|
import yaml
|
|
|
|
api = types.rest.api.OpenAPI(paths=paths, components=comps)
|
|
|
|
with open('/tmp/uds_api.json', 'w') as f:
|
|
f.write(json.dumps(api.as_dict(), indent=4))
|
|
|
|
with open('/tmp/uds_api.yaml', 'w') as f:
|
|
f.write(yaml.dump(api.as_dict()))
|
|
|
|
def test_handler_urls(self) -> None:
|
|
root_node = dispatcher.Dispatcher.root_node
|
|
for line in root_node.tree().splitlines():
|
|
logger.info('*> %s', line)
|
|
|
|
def process_node(
|
|
node: 'types.rest.HandlerNode',
|
|
path: str,
|
|
type_: str,
|
|
level: int,
|
|
) -> None:
|
|
if node.handler is None:
|
|
raise ValueError(f'Node {node.name} has no handler, cannot process')
|
|
logger.info("Processing node: %s, %s", node.name, type_)
|
|
# 'handler', 'custom_method', 'detail_method'
|
|
match type_:
|
|
case 'custom_method':
|
|
pass
|
|
case 'detail_method':
|
|
pass
|
|
case 'handler':
|
|
pass
|
|
case _:
|
|
raise ValueError(f'Unknown type {type_} for node {node.name}')
|
|
|
|
root_node.visit(process_node)
|
|
|
|
def test_python_type_to_openapi(self) -> None:
|
|
# Test basic types
|
|
self.assertEqual(
|
|
util_api.python_type_to_openapi(int), types.rest.api.SchemaProperty(type='integer', format='int64')
|
|
)
|
|
self.assertEqual(util_api.python_type_to_openapi(str), types.rest.api.SchemaProperty(type='string'))
|
|
self.assertEqual(util_api.python_type_to_openapi(float), types.rest.api.SchemaProperty(type='number'))
|
|
self.assertEqual(util_api.python_type_to_openapi(bool), types.rest.api.SchemaProperty(type='boolean'))
|
|
self.assertEqual(
|
|
util_api.python_type_to_openapi(type(None)), types.rest.api.SchemaProperty(type='null')
|
|
)
|
|
|
|
# Test list, dict, union and enums (Enum, IntEnum, StrEnum)
|
|
self.assertEqual(
|
|
util_api.python_type_to_openapi(list[str]),
|
|
types.rest.api.SchemaProperty(type='array', items=types.rest.api.SchemaProperty(type='string')),
|
|
)
|
|
self.assertEqual(
|
|
util_api.python_type_to_openapi(dict[str, str]),
|
|
types.rest.api.SchemaProperty(
|
|
type='object', additionalProperties=types.rest.api.SchemaProperty(type='string')
|
|
),
|
|
)
|
|
self.assertEqual(
|
|
util_api.python_type_to_openapi(typing.Union[int, str]),
|
|
types.rest.api.SchemaProperty(
|
|
type='not_used',
|
|
one_of=[
|
|
types.rest.api.SchemaProperty(type='integer', format='int64'),
|
|
types.rest.api.SchemaProperty(type='string'),
|
|
],
|
|
),
|
|
)
|
|
self.assertEqual(
|
|
util_api.python_type_to_openapi(enum.Enum),
|
|
types.rest.api.SchemaProperty(type='string'),
|
|
)
|
|
|
|
self.assertEqual(
|
|
util_api.python_type_to_openapi(MyEnum),
|
|
types.rest.api.SchemaProperty(type='string', enum=[e.value for e in MyEnum]),
|
|
)
|
|
|
|
def test_base_rest_item_api_components(self) -> None:
|
|
|
|
components = BaseRestItem.api_components()
|
|
|
|
self.assertIsInstance(components, types.rest.api.Components)
|
|
self.assertIn('BaseRestItem', components.schemas)
|
|
schema = components.schemas['BaseRestItem']
|
|
|
|
# This way, the syntax checker ccan infer the type of schema
|
|
assert isinstance(schema, types.rest.api.Schema)
|
|
|
|
self.assertEqual(schema.type, 'object')
|
|
self.assertEqual(schema.required, ['field_str', 'field_int', 'field_float'])
|
|
properties = schema.properties
|
|
self.assertIn('field_str', properties)
|
|
self.assertEqual(properties['field_str'], types.rest.api.SchemaProperty(type='string'))
|
|
self.assertIn('field_int', properties)
|
|
self.assertEqual(properties['field_int'], types.rest.api.SchemaProperty(type='integer', format='int64'))
|
|
self.assertIn('field_float', properties)
|
|
self.assertEqual(properties['field_float'], types.rest.api.SchemaProperty(type='number'))
|
|
self.assertIn('field_list', properties)
|
|
self.assertEqual(
|
|
properties['field_list'],
|
|
types.rest.api.SchemaProperty(type='array', items=types.rest.api.SchemaProperty(type='string')),
|
|
)
|
|
self.assertIn('field_dict', properties)
|
|
self.assertEqual(
|
|
properties['field_dict'],
|
|
types.rest.api.SchemaProperty(
|
|
type='object', additionalProperties=types.rest.api.SchemaProperty(type='string')
|
|
),
|
|
)
|
|
self.assertIn('field_enum', properties)
|
|
self.assertEqual(
|
|
properties['field_enum'],
|
|
types.rest.api.SchemaProperty(type='string', enum=[e.value for e in MyEnum]),
|
|
)
|
|
self.assertIn('field_optional', properties)
|
|
self.assertEqual(
|
|
properties['field_optional'],
|
|
types.rest.api.SchemaProperty(
|
|
type='not_used',
|
|
one_of=[
|
|
types.rest.api.SchemaProperty(type='string'),
|
|
types.rest.api.SchemaProperty(type='null'),
|
|
],
|
|
),
|
|
)
|
|
self.assertIn('field_union', properties)
|
|
self.assertEqual(
|
|
properties['field_union'],
|
|
types.rest.api.SchemaProperty(
|
|
type='not_used',
|
|
one_of=[
|
|
types.rest.api.SchemaProperty(type='integer', format='int64'),
|
|
types.rest.api.SchemaProperty(type='string'),
|
|
],
|
|
),
|
|
)
|
|
self.assertIn('field_union_2', properties)
|
|
self.assertEqual(
|
|
properties['field_union_2'],
|
|
types.rest.api.SchemaProperty(
|
|
type='not_used',
|
|
one_of=[
|
|
types.rest.api.SchemaProperty(type='integer', format='int64'),
|
|
types.rest.api.SchemaProperty(type='string'),
|
|
],
|
|
),
|
|
)
|
|
|
|
def test_base_rest_item_as_dict(self) -> None:
|
|
components = BaseRestItem.api_components()
|
|
|
|
dct = components.as_dict()
|
|
|
|
self.assertEqual(
|
|
dct,
|
|
{
|
|
'schemas': {
|
|
'BaseRestItem': {
|
|
'type': 'object',
|
|
'properties': {
|
|
'field_str': {'type': 'string'},
|
|
'field_int': {'format': 'int64', 'type': 'integer'},
|
|
'field_float': {'type': 'number'},
|
|
'field_list': {'items': {'type': 'string'}, 'type': 'array'},
|
|
'field_list_2': {'items': {'type': 'string'}, 'type': 'array'},
|
|
'field_dict': {'additionalProperties': {'type': 'string'}, 'type': 'object'},
|
|
'field_dict_2': {'additionalProperties': {'type': 'string'}, 'type': 'object'},
|
|
'field_enum': {'enum': ['value1', 'value2'], 'type': 'string'},
|
|
'field_optional': {'oneOf': [{'type': 'string'}, {'type': 'null'}]},
|
|
'field_union': {
|
|
'oneOf': [{'type': 'string'}, {'format': 'int64', 'type': 'integer'}]
|
|
},
|
|
'field_union_2': {
|
|
'oneOf': [{'type': 'string'}, {'format': 'int64', 'type': 'integer'}]
|
|
},
|
|
},
|
|
'required': ['field_str', 'field_int', 'field_float'],
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
def test_managed_object_schema(self) -> None:
|
|
components = ManagedObjectRestItem.api_components()
|
|
|
|
self.assertIsInstance(components, types.rest.api.Components)
|
|
self.assertIn('ManagedObjectRestItem', components.schemas)
|
|
schema = components.schemas['ManagedObjectRestItem']
|
|
|
|
assert isinstance(schema, types.rest.api.Schema)
|
|
|
|
self.assertEqual(schema.type, 'object')
|
|
self.assertEqual(schema.required, ['field_str', 'type', 'instance'])
|
|
properties = schema.properties
|
|
self.assertIn('field_str', properties)
|
|
self.assertEqual(properties['field_str'], types.rest.api.SchemaProperty(type='string'))
|
|
self.assertIn('field_union', properties)
|
|
self.assertEqual(
|
|
properties['field_union'],
|
|
types.rest.api.SchemaProperty(
|
|
type='not_used',
|
|
one_of=[
|
|
types.rest.api.SchemaProperty(type='string'),
|
|
types.rest.api.SchemaProperty(type='integer', format='int64'),
|
|
types.rest.api.SchemaProperty(type='null'),
|
|
],
|
|
),
|
|
)
|
|
self.assertIn('type', properties)
|
|
self.assertEqual(properties['type'], types.rest.api.SchemaProperty(type='string'))
|
|
self.assertIn('type_name', properties)
|
|
self.assertEqual(properties['type_name'], types.rest.api.SchemaProperty(type='string'))
|
|
self.assertIn('instance', properties)
|
|
self.assertEqual(properties['instance'], types.rest.api.SchemaProperty(type='object'))
|