1
0
mirror of https://github.com/dkmstr/openuds.git synced 2025-03-20 06:50:23 +03:00

Refactor API documentation for clarity and consistency in method descriptions

This commit is contained in:
Adolfo Gómez García 2025-02-08 15:00:48 +01:00
parent d82e7dc838
commit 2d524fcdf2
No known key found for this signature in database
GPG Key ID: DD1ABF20724CDA23
17 changed files with 294 additions and 84 deletions

View File

@ -162,7 +162,7 @@ class Documentation(View):
help_data.append(HelpInfo(f'{path}/{func.value.method}', func.value.text, method))
case _:
for method in node.methods:
help_data.append(HelpInfo(path, node.help.text, method))
help_data.append(HelpInfo(path, node.help.description, method))
for child in node.children:
_process_node(child, child.help.path)

View File

@ -29,6 +29,7 @@
"""
Author: Adolfo Gómez, dkmaster at dkmon dot com
"""
import abc
import typing
import logging
import codecs
@ -53,7 +54,7 @@ if typing.TYPE_CHECKING:
logger = logging.getLogger(__name__)
class Handler:
class Handler(abc.ABC):
"""
REST requests handler base class
"""
@ -71,7 +72,7 @@ class Handler:
# For implementing help
# A list of pairs of (path, help) for subpaths on this handler
help_paths: typing.ClassVar[list[types.rest.HelpPath]] = []
help_paths: typing.ClassVar[list[types.rest.HelpDoc]] = []
help_text: typing.ClassVar[str] = 'No help available'
_request: 'ExtendedHttpRequestWithUser' # It's a modified HttpRequest

View File

@ -91,18 +91,14 @@ class Accounts(ModelHandler):
def timemark(self, item: 'Model') -> typing.Any:
"""
API:
Description:
Generates a time mark associated with the account.
This is useful to easily identify when the account data was last updated.
(For example, one user enters an service, we get the usage data and "timemark" it, later read again
and we can identify that all data before this timemark has already been processed)
Generates a time mark associated with the account.
This is useful to easily identify when the account data was last updated.
(For example, one user enters an service, we get the usage data and "timemark" it, later read again
and we can identify that all data before this timemark has already been processed)
Arguments:
item: Account to timemark
Parameters:
- item: Account
Description of the item parameter
Response:
200: All fine, no data returned
"""
item = ensure.is_instance(item, Account)
item.time_mark = datetime.datetime.now()
@ -114,15 +110,7 @@ class Accounts(ModelHandler):
Api documentation for the method. From here, will be used by the documentation generator
Always starts with API:
API:
Description:
Clears all usage associated with the account
Parameters:
- item: Account
Description of the item parameter
Response:
200: All fine, no data returned
Clears all usage associated with the account
"""
item = ensure.is_instance(item, Account)
self.ensure_has_access(item, uds.core.types.permissions.PermissionType.MANAGEMENT)

View File

@ -201,13 +201,7 @@ class Authenticators(ModelHandler):
def search(self, item: 'Model') -> list[types.rest.ItemDictType]:
"""
API:
Description:
Search for users or groups in this authenticator
Args:
item: Authenticator instance
Search for users or groups in this authenticator
"""
item = ensure.is_instance(item, Authenticator)
self.ensure_has_access(item, types.permissions.PermissionType.READ)

View File

@ -45,12 +45,7 @@ logger = logging.getLogger(__name__)
class Config(Handler):
"""
API:
Description: Get or update UDS configuration
Actions:
- GET: Returns the configuration values
- PUT: Updates the configuration values
Get or update UDS configuration
"""
min_access_role = consts.UserRole.ADMIN

View File

@ -44,8 +44,7 @@ logger = logging.getLogger(__name__)
class Callback(Handler):
"""
API:
Description:
Executes a callback from the GUI. Internal use, not intended to be called from outside.
Executes a callback from the GUI. Internal use, not intended to be called from outside.
"""
path = 'gui'

View File

@ -292,11 +292,7 @@ class MetaPools(ModelHandler):
def set_fallback_access(self, item: MetaPool) -> typing.Any:
"""
API:
Description:
Sets the fallback access for a metapool
Response:
200: All fine, no data returned
Sets the fallback access for a metapool
"""
self.ensure_has_access(item, types.permissions.PermissionType.MANAGEMENT)

View File

@ -47,7 +47,7 @@ class Stats(Handler):
min_access_role = consts.UserRole.ADMIN
help_paths = [
types.rest.HelpPath('', 'Returns the last day usage statistics for all authenticators'),
types.rest.HelpDoc('', 'Returns the last day usage statistics for all authenticators'),
]
help_text = 'Provides access to usage statistics'

View File

@ -146,15 +146,15 @@ class System(Handler):
min_access_role = consts.UserRole.STAFF
help_paths = [
types.rest.HelpPath('', ''),
types.rest.HelpPath('stats/assigned', ''),
types.rest.HelpPath('stats/inuse', ''),
types.rest.HelpPath('stats/cached', ''),
types.rest.HelpPath('stats/complete', ''),
types.rest.HelpPath('stats/assigned/<uuuid>', 'Get service pool assigned stats'),
types.rest.HelpPath('stats/inuse/<uuid>', 'Get service pool in use stats'),
types.rest.HelpPath('stats/cached/<uuid>', 'Get service pool cached stats'),
types.rest.HelpPath('stats/complete/<uuid>', 'Get service pool complete stats'),
types.rest.HelpDoc('', ''),
types.rest.HelpDoc('stats/assigned', ''),
types.rest.HelpDoc('stats/inuse', ''),
types.rest.HelpDoc('stats/cached', ''),
types.rest.HelpDoc('stats/complete', ''),
types.rest.HelpDoc('stats/assigned/<uuuid>', 'Get service pool assigned stats'),
types.rest.HelpDoc('stats/inuse/<uuid>', 'Get service pool in use stats'),
types.rest.HelpDoc('stats/cached/<uuid>', 'Get service pool cached stats'),
types.rest.HelpDoc('stats/complete/<uuid>', 'Get service pool complete stats'),
]
help_text = 'Provides system information. Must be admin to access this'

View File

@ -124,8 +124,7 @@ class TunnelServers(DetailHandler):
def maintenance(self, parent: 'Model', id: str) -> typing.Any:
"""
API:
Description:
Custom method that swaps maintenance mode state for a tunnel server
Custom method that swaps maintenance mode state for a tunnel server
"""
parent = ensure.is_instance(parent, models.ServerGroup)

View File

@ -295,14 +295,7 @@ class Users(DetailHandler):
def services_pools(self, parent: 'Model', item: str) -> list[dict[str, typing.Any]]:
"""
API:
Description:
Returns the service pools assigned to a user
Parameters:
- uuid: User
Response:
- 200: A list of service pools assigned to the user
Returns the service pools assigned to a user
"""
parent = ensure.is_instance(parent, Authenticator)
uuid = process_uuid(item)

View File

@ -40,7 +40,7 @@ import typing
from django.http import HttpResponse
from django.utils.functional import Promise as DjangoPromise
from uds.core import consts
from uds.core import consts, types
from .utils import to_incremental_json
@ -112,6 +112,10 @@ class ContentProcessor:
if obj is None or isinstance(obj, (bool, int, float, str)):
return obj
# If we are a typed response...
if isinstance(obj, types.rest.TypedResponse):
return ContentProcessor.process_for_render(obj.as_dict())
if isinstance(obj, DjangoPromise):
return str(obj) # This is for translations

View File

@ -40,6 +40,51 @@ if typing.TYPE_CHECKING:
from uds.REST.handlers import Handler
# TypedResponse related.
# Typed responses are used to define the type of the response that a method will return.
# This allow us to "describe" it later on the documentation, and also to check that the
# response is correct (and also to generate the response in the correct format)
class TypedResponse(abc.ABC):
def as_dict(self) -> dict[str, typing.Any]:
# If we are a dataclass
if dataclasses.is_dataclass(self):
return dataclasses.asdict(self)
# If we are a dict
if isinstance(self, dict):
return self
raise Exception(f'Cannot convert {self} to dict')
@classmethod
def as_help(cls: type) -> dict[str, typing.Any]:
"""
Returns a representation, as json, of the response type to be used on documentation
For this, we build a dict of "name": "<type>" for each field of the response and returns it
Note that we support nested dataclasses and dicts, but not lists
"""
CLASS_REPR: dict[typing.Any, str] = {
str: '<string>',
int: '<integer>',
float: '<float>',
bool: '<boolean>',
dict: '<dict>',
list: '<list>',
typing.Any: '<any>',
}
def _as_help(obj: typing.Any) -> typing.Union[str, dict[str, typing.Any]]:
if dataclasses.is_dataclass(obj):
return {field.name: _as_help(field.type) for field in dataclasses.fields(obj)}
if isinstance(obj, dict):
return {k: str(_as_help(v)) for k, v in typing.cast(dict[str, typing.Any], obj).items()}
return CLASS_REPR.get(obj, str(obj))
return {field.name: _as_help(field.type) for field in dataclasses.fields(cls)}
# Type related definitions
TypeInfoDict = dict[str, typing.Any] # Alias for type info dict
@ -121,40 +166,78 @@ API_RE = re.compile(r'(?ms)^\s*API:\s*$')
@dataclasses.dataclass(eq=False)
class HelpPath:
class HelpDoc:
"""
Help helper class
"""
@dataclasses.dataclass
class ArgumentInfo:
name: str
type: str
description: str
path: str
text: str
description: str
arguments: list[ArgumentInfo] = dataclasses.field(default_factory=list)
# Result is always a json ressponse, so we can describe it as a dict
# Note that this dict can be nested
returns: dict[str, typing.Any] = dataclasses.field(default_factory=dict)
def __init__(self, path: str, help: str):
def __init__(
self,
path: str,
help: str,
*,
arguments: typing.Optional[list[ArgumentInfo]] = None,
returns: typing.Optional[dict[str, typing.Any]] = None,
) -> None:
self.path = path
self.text = HelpPath.process_help(help)
self.description = help
self.arguments = arguments or []
self.returns = returns or {}
def __hash__(self) -> int:
return hash(self.path)
def __eq__(self, other: object) -> bool:
if not isinstance(other, HelpPath):
if not isinstance(other, HelpDoc):
return False
return self.path == other.path
def as_str(self) -> str:
return f'{self.path} - {self.description}'
@property
def is_empty(self) -> bool:
return self.path == '' and self.text == ''
return self.path == '' and self.description == ''
@staticmethod
def process_help(help: str) -> str:
def _process_help(self, help: str, annotations: typing.Optional[dict[str, typing.Any]] = None) -> None:
"""
Processes the help string, removing leading and trailing spaces
"""
self.description = ''
self.arguments = []
self.returns = {}
match = API_RE.search(help)
if match:
return help[match.end() :].strip()
self.description = help[: match.start()].strip()
return ''
if annotations:
if 'return' in annotations and issubclass(annotations['return'], TypedResponse):
self.returns = annotations['return'].as_help()
@staticmethod
def from_typed_response(path: str, help: str, TR: type[TypedResponse]) -> 'HelpDoc':
"""
Returns a HelpDoc from a TypedResponse class
"""
return HelpDoc(
path=path,
help=help,
returns=TR.as_help(),
)
@dataclasses.dataclass(frozen=True)
@ -164,7 +247,7 @@ class HelpNode:
DETAIL = 'detail'
CUSTOM = 'custom'
PATH = 'path'
class Methods(enum.StrEnum):
GET = 'GET'
POST = 'POST'
@ -172,7 +255,7 @@ class HelpNode:
DELETE = 'DELETE'
PATCH = 'PATCH'
help: HelpPath
help: HelpDoc
children: list['HelpNode'] # Children nodes
kind: Type
methods: set[Methods] = dataclasses.field(default_factory=lambda: {HelpNode.Methods.GET})
@ -183,7 +266,7 @@ class HelpNode:
def __eq__(self, other: object) -> bool:
if isinstance(other, HelpNode):
return self.help.path == other.help.path and self.methods == other.methods
if not isinstance(other, HelpPath):
if not isinstance(other, HelpDoc):
return False
return self.help.path == other.path
@ -256,10 +339,14 @@ class HandlerNode:
# Method is a Me CustomModelMethod,
# We access the __doc__ of the function inside the handler with method.name
doc = getattr(self.handler, method.name).__doc__ or ''
path = f'{self.full_path()}/{method.name}' if not method.needs_parent else f'{self.full_path()}/<uuid>/{method.name}'
path = (
f'{self.full_path()}/{method.name}'
if not method.needs_parent
else f'{self.full_path()}/<uuid>/{method.name}'
)
custom_help.add(
HelpNode(
HelpPath(path=path, help=doc),
HelpDoc(path=path, help=doc),
[],
HelpNode.Type.CUSTOM,
)
@ -270,7 +357,7 @@ class HandlerNode:
for method_name, method_class in self.handler.detail.items():
custom_help.add(
HelpNode(
HelpPath(path=self.full_path() + '/' + method_name, help=''),
HelpDoc(path=self.full_path() + '/' + method_name, help=''),
[],
HelpNode.Type.DETAIL,
)
@ -282,7 +369,7 @@ class HandlerNode:
doc = getattr(method_class, detail_method).__doc__ or ''
custom_help.add(
HelpNode(
HelpPath(
HelpDoc(
path=self.full_path()
+ '/<uuid>/'
+ method_name
@ -297,9 +384,9 @@ class HandlerNode:
custom_help |= {
HelpNode(
HelpPath(
HelpDoc(
path=self.full_path() + '/' + help_info.path,
help=help_info.text,
help=help_info.description,
),
[],
help_node_type,
@ -310,7 +397,7 @@ class HandlerNode:
custom_help |= {child.help_node() for child in self.children.values()}
return HelpNode(
help=HelpPath(path=self.full_path(), help=self.handler.__doc__ or ''),
help=HelpDoc(path=self.full_path(), help=self.handler.__doc__ or ''),
children=list(custom_help),
kind=help_node_type,
)

View File

View File

@ -0,0 +1,26 @@
#
# 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.

View File

@ -0,0 +1,26 @@
#
# 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.

View File

@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
#
# Copyright (c) 2022 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 logging
from unittest import TestCase
from uds.core.types import rest
logger = logging.getLogger(__name__)
class TestHelpDoc(TestCase):
def test_helpdoc_basic(self) -> None:
h = rest.HelpDoc('/path', 'help_text')
self.assertEqual(h.path, '/path')
self.assertEqual(h.description, 'help_text')
self.assertEqual(h.arguments, [])
def test_helpdoc_with_args(self) -> None:
arguments = [
rest.HelpDoc.ArgumentInfo('arg1', 'arg1_type', 'arg1_description'),
rest.HelpDoc.ArgumentInfo('arg2', 'arg2_type', 'arg2_description'),
]
h = rest.HelpDoc(
'/path',
'help_text',
arguments=arguments,
)
self.assertEqual(h.path, '/path')
self.assertEqual(h.description, 'help_text')
self.assertEqual(h.arguments, arguments)
def test_helpdoc_with_args_and_return(self) -> None:
arguments = [
rest.HelpDoc.ArgumentInfo('arg1', 'arg1_type', 'arg1_description'),
rest.HelpDoc.ArgumentInfo('arg2', 'arg2_type', 'arg2_description'),
]
returns = {
'name': 'return_name',
}
h = rest.HelpDoc(
'/path',
'help_text',
arguments=arguments,
returns=returns,
)
self.assertEqual(h.path, '/path')
self.assertEqual(h.description, 'help_text')
self.assertEqual(h.arguments, arguments)
self.assertEqual(h.returns, returns)
def test_help_doc_from_typed_response(self) -> None:
@dataclasses.dataclass
class TestResponse(rest.TypedResponse):
name: str = 'test_name'
age: int = 0
money: float = 0.0
h = rest.HelpDoc.from_typed_response('path', 'help', TestResponse)
self.assertEqual(h.path, 'path')
self.assertEqual(h.description, 'help')
self.assertEqual(h.arguments, [])
self.assertEqual(h.returns, {
'name': '<string>',
'age': '<integer>',
'money': '<float>',
})